From 4e428436b9bc9a9b6843cd11a0057b839fb71d04 Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Fri, 23 Jan 2026 15:33:14 +0100 Subject: [PATCH 01/47] Ban database refactor (#42495) * Ban DB refactor seems to work at a basic level for PostgreSQL * New ban creation API Supports all the new functionality (multiple players/addresses/hwids/roles/rounds per ban). * Make the migration irreversible * Re-implement ban notifications The server ID check is no longer done as admins may want to place bans spanning multiple rounds irrelevant of the source server. * Fix some split query warnings * Implement migration on SQLite * More comments * Remove required from ban reason SS14.Admin changes would like this * More missing AsSplitQuery() calls * Fix missing ban type filter * Fix old CreateServerBan API with permanent time * Fix department and role ban commands with permanent time * Re-add banhits navigation property Dropped this on accident, SS14.Admin needs it. * More ban API fixes. * Don't fetch ban exemption info for role bans Not relevant, reduces query performance * Regenerate migrations * Fix adminnotes command for players that never connected Would blow up handling null player records. Not a new bug introduced by the refactor, but I ran into it. * Great shame... I accidentally committed submodule update... * Update GDPR scripts * Fix sandbox violation * Fix bans with duplicate info causing DB exceptions Most notably happened with role bans, as multiple departments may include the same role. --- .../Administration/UI/BanList/BanListEui.cs | 17 +- .../UI/BanList/Bans/BanListControl.xaml.cs | 2 +- .../UI/BanList/Bans/BanListLine.xaml.cs | 6 +- .../Administration/UI/BanList/IBanListLine.cs | 2 +- .../RoleBans/RoleBanListControl.xaml.cs | 2 +- .../BanList/RoleBans/RoleBanListLine.xaml.cs | 8 +- .../UI/Notes/AdminNotesLine.xaml.cs | 9 +- .../UI/Notes/AdminNotesLinePopup.xaml.cs | 4 +- .../JobRequirementsManager.cs | 4 +- .../Tests/Commands/PardonCommand.cs | 40 +- .../20260120200503_BanRefactor.Designer.cs | 2125 +++++++++++++++++ .../Postgres/20260120200503_BanRefactor.cs | 535 +++++ .../PostgresServerDbContextModelSnapshot.cs | 787 +++--- .../20260120200455_BanRefactor.Designer.cs | 2044 ++++++++++++++++ .../Sqlite/20260120200455_BanRefactor.cs | 498 ++++ .../SqliteServerDbContextModelSnapshot.cs | 756 +++--- Content.Server.Database/Model.Ban.cs | 328 +++ Content.Server.Database/Model.cs | 315 +-- Content.Server.Database/ModelPostgres.cs | 5 +- Content.Server.Database/ModelSqlite.cs | 8 +- .../Administration/BanList/BanListEui.cs | 80 +- Content.Server/Administration/BanPanelEui.cs | 92 +- .../Administration/Commands/BanCommand.cs | 10 +- .../Administration/Commands/BanListCommand.cs | 2 +- .../Commands/DepartmentBanCommand.cs | 15 +- .../Commands/OpenAdminNotesCommand.cs | 3 +- .../Administration/Commands/PardonCommand.cs | 4 +- .../Administration/Commands/RoleBanCommand.cs | 25 +- .../Commands/RoleBanListCommand.cs | 10 +- .../Managers/BanManager.Notification.cs | 18 +- .../Administration/Managers/BanManager.cs | 378 +-- .../Administration/Managers/IBanManager.cs | 353 ++- .../Administration/Notes/AdminNotesEui.cs | 8 +- .../Notes/AdminNotesExtensions.cs | 18 +- .../Administration/Notes/AdminNotesManager.cs | 25 +- .../Notes/IAdminNotesManager.cs | 3 +- .../Administration/PlayerPanelEui.cs | 7 +- .../Administration/Systems/BwoinkSystem.cs | 2 +- .../Connection/ConnectionManager.cs | 4 +- Content.Server/Database/BanDef.cs | 128 + Content.Server/Database/BanMatcher.cs | 50 +- Content.Server/Database/DatabaseRecords.cs | 53 +- Content.Server/Database/EFCoreExtensions.cs | 37 + Content.Server/Database/ServerBanDef.cs | 93 - Content.Server/Database/ServerDbBase.cs | 334 +-- Content.Server/Database/ServerDbManager.cs | 170 +- Content.Server/Database/ServerDbPostgres.cs | 391 +-- Content.Server/Database/ServerDbSqlite.cs | 314 +-- Content.Server/Database/ServerRoleBanDef.cs | 65 - Content.Server/Database/ServerRoleUnbanDef.cs | 19 - .../{ServerUnbanDef.cs => UnbanDef.cs} | 4 +- Content.Server/IP/IPAddressExt.cs | 16 +- .../Managers/VoteManager.DefaultVotes.cs | 19 +- Content.Shared.Database/Bans.cs | 33 + .../Administration/BanList/BanListEuiState.cs | 6 +- .../Administration/BanList/SharedBan.cs | 20 + .../Administration/BanList/SharedServerBan.cs | 17 - .../BanList/SharedServerRoleBan.cs | 18 - .../{SharedServerUnban.cs => SharedUnban.cs} | 2 +- .../Administration/Notes/SharedAdminNote.cs | 7 +- Content.Shared/Players/MsgRoleBans.cs | 6 +- .../Locale/en-US/job/role-ban-command.ftl | 1 - Resources/engineCommandPerms.yml | 1 + Tools/dump_user_data.py | 37 +- Tools/erase_user_data.py | 24 +- 65 files changed, 7716 insertions(+), 2701 deletions(-) create mode 100644 Content.Server.Database/Migrations/Postgres/20260120200503_BanRefactor.Designer.cs create mode 100644 Content.Server.Database/Migrations/Postgres/20260120200503_BanRefactor.cs create mode 100644 Content.Server.Database/Migrations/Sqlite/20260120200455_BanRefactor.Designer.cs create mode 100644 Content.Server.Database/Migrations/Sqlite/20260120200455_BanRefactor.cs create mode 100644 Content.Server.Database/Model.Ban.cs create mode 100644 Content.Server/Database/BanDef.cs create mode 100644 Content.Server/Database/EFCoreExtensions.cs delete mode 100644 Content.Server/Database/ServerBanDef.cs delete mode 100644 Content.Server/Database/ServerRoleBanDef.cs delete mode 100644 Content.Server/Database/ServerRoleUnbanDef.cs rename Content.Server/Database/{ServerUnbanDef.cs => UnbanDef.cs} (72%) create mode 100644 Content.Shared.Database/Bans.cs create mode 100644 Content.Shared/Administration/BanList/SharedBan.cs delete mode 100644 Content.Shared/Administration/BanList/SharedServerBan.cs delete mode 100644 Content.Shared/Administration/BanList/SharedServerRoleBan.cs rename Content.Shared/Administration/BanList/{SharedServerUnban.cs => SharedUnban.cs} (81%) diff --git a/Content.Client/Administration/UI/BanList/BanListEui.cs b/Content.Client/Administration/UI/BanList/BanListEui.cs index 2fca1dee52..00b27cd173 100644 --- a/Content.Client/Administration/UI/BanList/BanListEui.cs +++ b/Content.Client/Administration/UI/BanList/BanListEui.cs @@ -1,4 +1,5 @@ -using System.Numerics; +using System.Linq; +using System.Numerics; using Content.Client.Administration.UI.BanList.Bans; using Content.Client.Administration.UI.BanList.RoleBans; using Content.Client.Eui; @@ -73,7 +74,7 @@ public sealed class BanListEui : BaseEui return date.ToString("MM/dd/yyyy h:mm tt"); } - public static void SetData(IBanListLine line, SharedServerBan ban) where T : SharedServerBan + public static void SetData(IBanListLine line, SharedBan ban) where T : SharedBan { line.Reason.Text = ban.Reason; line.BanTime.Text = FormatDate(ban.BanTime); @@ -94,20 +95,20 @@ public sealed class BanListEui : BaseEui line.BanningAdmin.Text = ban.BanningAdminName; } - private void OnLineIdsClicked(IBanListLine line) where T : SharedServerBan + private void OnLineIdsClicked(IBanListLine line) where T : SharedBan { _popup?.Close(); _popup = null; var ban = line.Ban; var id = ban.Id == null ? string.Empty : Loc.GetString("ban-list-id", ("id", ban.Id.Value)); - var ip = ban.Address == null + var ip = ban.Addresses.Length == 0 ? string.Empty - : Loc.GetString("ban-list-ip", ("ip", ban.Address.Value.address)); - var hwid = ban.HWId == null ? string.Empty : Loc.GetString("ban-list-hwid", ("hwid", ban.HWId)); - var guid = ban.UserId == null + : Loc.GetString("ban-list-ip", ("ip", string.Join(',', ban.Addresses.Select(a => a.address)))); + var hwid = ban.HWIds.Length == 0 ? string.Empty : Loc.GetString("ban-list-hwid", ("hwid", string.Join(',', ban.HWIds))); + var guid = ban.UserIds.Length == 0 ? string.Empty - : Loc.GetString("ban-list-guid", ("guid", ban.UserId.Value.ToString())); + : Loc.GetString("ban-list-guid", ("guid", string.Join(',', ban.UserIds))); _popup = new BanListIdsPopup(id, ip, hwid, guid); diff --git a/Content.Client/Administration/UI/BanList/Bans/BanListControl.xaml.cs b/Content.Client/Administration/UI/BanList/Bans/BanListControl.xaml.cs index 431087568a..a79fc4a137 100644 --- a/Content.Client/Administration/UI/BanList/Bans/BanListControl.xaml.cs +++ b/Content.Client/Administration/UI/BanList/Bans/BanListControl.xaml.cs @@ -16,7 +16,7 @@ public sealed partial class BanListControl : Control RobustXamlLoader.Load(this); } - public void SetBans(List bans) + public void SetBans(List bans) { for (var i = Bans.ChildCount - 1; i >= 1; i--) { diff --git a/Content.Client/Administration/UI/BanList/Bans/BanListLine.xaml.cs b/Content.Client/Administration/UI/BanList/Bans/BanListLine.xaml.cs index 0c4e6e60d0..f1320ef7b9 100644 --- a/Content.Client/Administration/UI/BanList/Bans/BanListLine.xaml.cs +++ b/Content.Client/Administration/UI/BanList/Bans/BanListLine.xaml.cs @@ -7,13 +7,13 @@ using static Robust.Client.UserInterface.Controls.BaseButton; namespace Content.Client.Administration.UI.BanList.Bans; [GenerateTypedNameReferences] -public sealed partial class BanListLine : BoxContainer, IBanListLine +public sealed partial class BanListLine : BoxContainer, IBanListLine { - public SharedServerBan Ban { get; } + public SharedBan Ban { get; } public event Action? IdsClicked; - public BanListLine(SharedServerBan ban) + public BanListLine(SharedBan ban) { RobustXamlLoader.Load(this); diff --git a/Content.Client/Administration/UI/BanList/IBanListLine.cs b/Content.Client/Administration/UI/BanList/IBanListLine.cs index 097bae15df..565e707218 100644 --- a/Content.Client/Administration/UI/BanList/IBanListLine.cs +++ b/Content.Client/Administration/UI/BanList/IBanListLine.cs @@ -3,7 +3,7 @@ using Robust.Client.UserInterface.Controls; namespace Content.Client.Administration.UI.BanList; -public interface IBanListLine where T : SharedServerBan +public interface IBanListLine where T : SharedBan { T Ban { get; } Label Reason { get; } diff --git a/Content.Client/Administration/UI/BanList/RoleBans/RoleBanListControl.xaml.cs b/Content.Client/Administration/UI/BanList/RoleBans/RoleBanListControl.xaml.cs index 1ea751deb7..f217dec5e6 100644 --- a/Content.Client/Administration/UI/BanList/RoleBans/RoleBanListControl.xaml.cs +++ b/Content.Client/Administration/UI/BanList/RoleBans/RoleBanListControl.xaml.cs @@ -16,7 +16,7 @@ public sealed partial class RoleBanListControl : Control RobustXamlLoader.Load(this); } - public void SetRoleBans(List bans) + public void SetRoleBans(List bans) { for (var i = RoleBans.ChildCount - 1; i >= 1; i--) { diff --git a/Content.Client/Administration/UI/BanList/RoleBans/RoleBanListLine.xaml.cs b/Content.Client/Administration/UI/BanList/RoleBans/RoleBanListLine.xaml.cs index 4f77d662e1..ca0d214e31 100644 --- a/Content.Client/Administration/UI/BanList/RoleBans/RoleBanListLine.xaml.cs +++ b/Content.Client/Administration/UI/BanList/RoleBans/RoleBanListLine.xaml.cs @@ -7,13 +7,13 @@ using static Robust.Client.UserInterface.Controls.BaseButton; namespace Content.Client.Administration.UI.BanList.RoleBans; [GenerateTypedNameReferences] -public sealed partial class RoleBanListLine : BoxContainer, IBanListLine +public sealed partial class RoleBanListLine : BoxContainer, IBanListLine { - public SharedServerRoleBan Ban { get; } + public SharedBan Ban { get; } public event Action? IdsClicked; - public RoleBanListLine(SharedServerRoleBan ban) + public RoleBanListLine(SharedBan ban) { RobustXamlLoader.Load(this); @@ -21,7 +21,7 @@ public sealed partial class RoleBanListLine : BoxContainer, IBanListLine?)Note.BannedRoles ?? [new BanRoleDef("what", "You should not be seeing this")]); + + var banMessage = new StringBuilder($"{Loc.GetString("admin-notes-banned-from")} {rolesText} "); return FormatBanMessageCommon(banMessage); } diff --git a/Content.Client/Administration/UI/Notes/AdminNotesLinePopup.xaml.cs b/Content.Client/Administration/UI/Notes/AdminNotesLinePopup.xaml.cs index 18a5003158..e82b85acb6 100644 --- a/Content.Client/Administration/UI/Notes/AdminNotesLinePopup.xaml.cs +++ b/Content.Client/Administration/UI/Notes/AdminNotesLinePopup.xaml.cs @@ -32,9 +32,9 @@ public sealed partial class AdminNotesLinePopup : Popup IdLabel.Text = Loc.GetString("admin-notes-id", ("id", note.Id)); TypeLabel.Text = Loc.GetString("admin-notes-type", ("type", note.NoteType)); SeverityLabel.Text = Loc.GetString("admin-notes-severity", ("severity", note.NoteSeverity ?? NoteSeverity.None)); - RoundIdLabel.Text = note.Round == null + RoundIdLabel.Text = note.Rounds.Length == 0 ? Loc.GetString("admin-notes-round-id-unknown") - : Loc.GetString("admin-notes-round-id", ("id", note.Round)); + : Loc.GetString("admin-notes-round-id", ("id", string.Join(',', note.Rounds))); CreatedByLabel.Text = Loc.GetString("admin-notes-created-by", ("author", note.CreatedByName)); CreatedAtLabel.Text = Loc.GetString("admin-notes-created-at", ("date", note.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"))); EditedByLabel.Text = Loc.GetString("admin-notes-last-edited-by", ("author", note.EditedByName)); diff --git a/Content.Client/Players/PlayTimeTracking/JobRequirementsManager.cs b/Content.Client/Players/PlayTimeTracking/JobRequirementsManager.cs index d085d9005c..9325507c53 100644 --- a/Content.Client/Players/PlayTimeTracking/JobRequirementsManager.cs +++ b/Content.Client/Players/PlayTimeTracking/JobRequirementsManager.cs @@ -25,8 +25,8 @@ public sealed class JobRequirementsManager : ISharedPlaytimeManager [Dependency] private readonly IPrototypeManager _prototypes = default!; private readonly Dictionary _roles = new(); - private readonly List _jobBans = new(); - private readonly List _antagBans = new(); + private readonly List> _jobBans = new(); + private readonly List> _antagBans = new(); private readonly List _jobWhitelists = new(); private ISawmill _sawmill = default!; diff --git a/Content.IntegrationTests/Tests/Commands/PardonCommand.cs b/Content.IntegrationTests/Tests/Commands/PardonCommand.cs index 9e57cd4b0e..5f77af1b10 100644 --- a/Content.IntegrationTests/Tests/Commands/PardonCommand.cs +++ b/Content.IntegrationTests/Tests/Commands/PardonCommand.cs @@ -32,9 +32,9 @@ namespace Content.IntegrationTests.Tests.Commands // No bans on record Assert.Multiple(async () => { - Assert.That(await sDatabase.GetServerBanAsync(null, clientId, null, null), Is.Null); - Assert.That(await sDatabase.GetServerBanAsync(1), Is.Null); - Assert.That(await sDatabase.GetServerBansAsync(null, clientId, null, null), Is.Empty); + Assert.That(await sDatabase.GetBanAsync(null, clientId, null, null), Is.Null); + Assert.That(await sDatabase.GetBanAsync(1), Is.Null); + Assert.That(await sDatabase.GetBansAsync(null, clientId, null, null), Is.Empty); }); // Try to pardon a ban that does not exist @@ -43,9 +43,9 @@ namespace Content.IntegrationTests.Tests.Commands // Still no bans on record Assert.Multiple(async () => { - Assert.That(await sDatabase.GetServerBanAsync(null, clientId, null, null), Is.Null); - Assert.That(await sDatabase.GetServerBanAsync(1), Is.Null); - Assert.That(await sDatabase.GetServerBansAsync(null, clientId, null, null), Is.Empty); + Assert.That(await sDatabase.GetBanAsync(null, clientId, null, null), Is.Null); + Assert.That(await sDatabase.GetBanAsync(1), Is.Null); + Assert.That(await sDatabase.GetBansAsync(null, clientId, null, null), Is.Empty); }); var banReason = "test"; @@ -57,9 +57,9 @@ namespace Content.IntegrationTests.Tests.Commands // Should have one ban on record now Assert.Multiple(async () => { - Assert.That(await sDatabase.GetServerBanAsync(null, clientId, null, null), Is.Not.Null); - Assert.That(await sDatabase.GetServerBanAsync(1), Is.Not.Null); - Assert.That(await sDatabase.GetServerBansAsync(null, clientId, null, null), Has.Count.EqualTo(1)); + Assert.That(await sDatabase.GetBanAsync(null, clientId, null, null), Is.Not.Null); + Assert.That(await sDatabase.GetBanAsync(1), Is.Not.Null); + Assert.That(await sDatabase.GetBansAsync(null, clientId, null, null), Has.Count.EqualTo(1)); }); await pair.RunTicksSync(5); @@ -70,17 +70,17 @@ namespace Content.IntegrationTests.Tests.Commands await server.WaitPost(() => sConsole.ExecuteCommand("pardon 2")); // The existing ban is unaffected - Assert.That(await sDatabase.GetServerBanAsync(null, clientId, null, null), Is.Not.Null); + Assert.That(await sDatabase.GetBanAsync(null, clientId, null, null), Is.Not.Null); - var ban = await sDatabase.GetServerBanAsync(1); + var ban = await sDatabase.GetBanAsync(1); Assert.Multiple(async () => { Assert.That(ban, Is.Not.Null); - Assert.That(await sDatabase.GetServerBansAsync(null, clientId, null, null), Has.Count.EqualTo(1)); + Assert.That(await sDatabase.GetBansAsync(null, clientId, null, null), Has.Count.EqualTo(1)); // Check that it matches Assert.That(ban.Id, Is.EqualTo(1)); - Assert.That(ban.UserId, Is.EqualTo(clientId)); + Assert.That(ban.UserIds, Is.EquivalentTo([clientId])); Assert.That(ban.BanTime.UtcDateTime - DateTime.UtcNow, Is.LessThanOrEqualTo(MarginOfError)); Assert.That(ban.ExpirationTime, Is.Not.Null); Assert.That(ban.ExpirationTime.Value.UtcDateTime - DateTime.UtcNow.AddHours(24), Is.LessThanOrEqualTo(MarginOfError)); @@ -95,20 +95,20 @@ namespace Content.IntegrationTests.Tests.Commands await server.WaitPost(() => sConsole.ExecuteCommand("pardon 1")); // No bans should be returned - Assert.That(await sDatabase.GetServerBanAsync(null, clientId, null, null), Is.Null); + Assert.That(await sDatabase.GetBanAsync(null, clientId, null, null), Is.Null); // Direct id lookup returns a pardoned ban - var pardonedBan = await sDatabase.GetServerBanAsync(1); + var pardonedBan = await sDatabase.GetBanAsync(1); Assert.Multiple(async () => { // Check that it matches Assert.That(pardonedBan, Is.Not.Null); // The list is still returned since that ignores pardons - Assert.That(await sDatabase.GetServerBansAsync(null, clientId, null, null), Has.Count.EqualTo(1)); + Assert.That(await sDatabase.GetBansAsync(null, clientId, null, null), Has.Count.EqualTo(1)); Assert.That(pardonedBan.Id, Is.EqualTo(1)); - Assert.That(pardonedBan.UserId, Is.EqualTo(clientId)); + Assert.That(pardonedBan.UserIds, Is.EquivalentTo([clientId])); Assert.That(pardonedBan.BanTime.UtcDateTime - DateTime.UtcNow, Is.LessThanOrEqualTo(MarginOfError)); Assert.That(pardonedBan.ExpirationTime, Is.Not.Null); Assert.That(pardonedBan.ExpirationTime.Value.UtcDateTime - DateTime.UtcNow.AddHours(24), Is.LessThanOrEqualTo(MarginOfError)); @@ -133,13 +133,13 @@ namespace Content.IntegrationTests.Tests.Commands Assert.Multiple(async () => { // No bans should be returned - Assert.That(await sDatabase.GetServerBanAsync(null, clientId, null, null), Is.Null); + Assert.That(await sDatabase.GetBanAsync(null, clientId, null, null), Is.Null); // Direct id lookup returns a pardoned ban - Assert.That(await sDatabase.GetServerBanAsync(1), Is.Not.Null); + Assert.That(await sDatabase.GetBanAsync(1), Is.Not.Null); // The list is still returned since that ignores pardons - Assert.That(await sDatabase.GetServerBansAsync(null, clientId, null, null), Has.Count.EqualTo(1)); + Assert.That(await sDatabase.GetBansAsync(null, clientId, null, null), Has.Count.EqualTo(1)); }); // Reconnect client. Slightly faster than dirtying the pair. diff --git a/Content.Server.Database/Migrations/Postgres/20260120200503_BanRefactor.Designer.cs b/Content.Server.Database/Migrations/Postgres/20260120200503_BanRefactor.Designer.cs new file mode 100644 index 0000000000..62dde10b99 --- /dev/null +++ b/Content.Server.Database/Migrations/Postgres/20260120200503_BanRefactor.Designer.cs @@ -0,0 +1,2125 @@ +// +using System; +using System.Collections.Generic; +using System.Net; +using System.Text.Json; +using Content.Server.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using NpgsqlTypes; + +#nullable disable + +namespace Content.Server.Database.Migrations.Postgres +{ + [DbContext(typeof(PostgresServerDbContext))] + [Migration("20260120200503_BanRefactor")] + partial class BanRefactor + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Content.Server.Database.Admin", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("AdminRankId") + .HasColumnType("integer") + .HasColumnName("admin_rank_id"); + + b.Property("Deadminned") + .HasColumnType("boolean") + .HasColumnName("deadminned"); + + b.Property("Suspended") + .HasColumnType("boolean") + .HasColumnName("suspended"); + + b.Property("Title") + .HasColumnType("text") + .HasColumnName("title"); + + b.HasKey("UserId") + .HasName("PK_admin"); + + b.HasIndex("AdminRankId") + .HasDatabaseName("IX_admin_admin_rank_id"); + + b.ToTable("admin", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("admin_flag_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdminId") + .HasColumnType("uuid") + .HasColumnName("admin_id"); + + b.Property("Flag") + .IsRequired() + .HasColumnType("text") + .HasColumnName("flag"); + + b.Property("Negative") + .HasColumnType("boolean") + .HasColumnName("negative"); + + b.HasKey("Id") + .HasName("PK_admin_flag"); + + b.HasIndex("AdminId") + .HasDatabaseName("IX_admin_flag_admin_id"); + + b.HasIndex("Flag", "AdminId") + .IsUnique(); + + b.ToTable("admin_flag", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLog", b => + { + b.Property("RoundId") + .HasColumnType("integer") + .HasColumnName("round_id"); + + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("admin_log_id"); + + b.Property("Date") + .HasColumnType("timestamp with time zone") + .HasColumnName("date"); + + b.Property("Impact") + .HasColumnType("smallint") + .HasColumnName("impact"); + + b.Property("Json") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("json"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("RoundId", "Id") + .HasName("PK_admin_log"); + + b.HasIndex("Date"); + + b.HasIndex("Message") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Message"), "GIN"); + + b.HasIndex("Type") + .HasDatabaseName("IX_admin_log_type"); + + b.ToTable("admin_log", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLogPlayer", b => + { + b.Property("RoundId") + .HasColumnType("integer") + .HasColumnName("round_id"); + + b.Property("LogId") + .HasColumnType("integer") + .HasColumnName("log_id"); + + b.Property("PlayerUserId") + .HasColumnType("uuid") + .HasColumnName("player_user_id"); + + b.HasKey("RoundId", "LogId", "PlayerUserId") + .HasName("PK_admin_log_player"); + + b.HasIndex("PlayerUserId") + .HasDatabaseName("IX_admin_log_player_player_user_id"); + + b.ToTable("admin_log_player", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("admin_messages_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("Deleted") + .HasColumnType("boolean") + .HasColumnName("deleted"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Dismissed") + .HasColumnType("boolean") + .HasColumnName("dismissed"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_time"); + + b.Property("LastEditedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_edited_at"); + + b.Property("LastEditedById") + .HasColumnType("uuid") + .HasColumnName("last_edited_by_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("message"); + + b.Property("PlayerUserId") + .HasColumnType("uuid") + .HasColumnName("player_user_id"); + + b.Property("PlaytimeAtNote") + .HasColumnType("interval") + .HasColumnName("playtime_at_note"); + + b.Property("RoundId") + .HasColumnType("integer") + .HasColumnName("round_id"); + + b.Property("Seen") + .HasColumnType("boolean") + .HasColumnName("seen"); + + b.HasKey("Id") + .HasName("PK_admin_messages"); + + b.HasIndex("CreatedById"); + + b.HasIndex("DeletedById"); + + b.HasIndex("LastEditedById"); + + b.HasIndex("PlayerUserId") + .HasDatabaseName("IX_admin_messages_player_user_id"); + + b.HasIndex("RoundId") + .HasDatabaseName("IX_admin_messages_round_id"); + + b.ToTable("admin_messages", null, t => + { + t.HasCheckConstraint("NotDismissedAndSeen", "NOT dismissed OR seen"); + }); + }); + + modelBuilder.Entity("Content.Server.Database.AdminNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("admin_notes_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("Deleted") + .HasColumnType("boolean") + .HasColumnName("deleted"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_time"); + + b.Property("LastEditedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_edited_at"); + + b.Property("LastEditedById") + .HasColumnType("uuid") + .HasColumnName("last_edited_by_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("message"); + + b.Property("PlayerUserId") + .HasColumnType("uuid") + .HasColumnName("player_user_id"); + + b.Property("PlaytimeAtNote") + .HasColumnType("interval") + .HasColumnName("playtime_at_note"); + + b.Property("RoundId") + .HasColumnType("integer") + .HasColumnName("round_id"); + + b.Property("Secret") + .HasColumnType("boolean") + .HasColumnName("secret"); + + b.Property("Severity") + .HasColumnType("integer") + .HasColumnName("severity"); + + b.HasKey("Id") + .HasName("PK_admin_notes"); + + b.HasIndex("CreatedById"); + + b.HasIndex("DeletedById"); + + b.HasIndex("LastEditedById"); + + b.HasIndex("PlayerUserId") + .HasDatabaseName("IX_admin_notes_player_user_id"); + + b.HasIndex("RoundId") + .HasDatabaseName("IX_admin_notes_round_id"); + + b.ToTable("admin_notes", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminRank", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("admin_rank_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("PK_admin_rank"); + + b.ToTable("admin_rank", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminRankFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("admin_rank_flag_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdminRankId") + .HasColumnType("integer") + .HasColumnName("admin_rank_id"); + + b.Property("Flag") + .IsRequired() + .HasColumnType("text") + .HasColumnName("flag"); + + b.HasKey("Id") + .HasName("PK_admin_rank_flag"); + + b.HasIndex("AdminRankId"); + + b.HasIndex("Flag", "AdminRankId") + .IsUnique(); + + b.ToTable("admin_rank_flag", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminWatchlist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("admin_watchlists_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("Deleted") + .HasColumnType("boolean") + .HasColumnName("deleted"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_time"); + + b.Property("LastEditedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_edited_at"); + + b.Property("LastEditedById") + .HasColumnType("uuid") + .HasColumnName("last_edited_by_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("message"); + + b.Property("PlayerUserId") + .HasColumnType("uuid") + .HasColumnName("player_user_id"); + + b.Property("PlaytimeAtNote") + .HasColumnType("interval") + .HasColumnName("playtime_at_note"); + + b.Property("RoundId") + .HasColumnType("integer") + .HasColumnName("round_id"); + + b.HasKey("Id") + .HasName("PK_admin_watchlists"); + + b.HasIndex("CreatedById"); + + b.HasIndex("DeletedById"); + + b.HasIndex("LastEditedById"); + + b.HasIndex("PlayerUserId") + .HasDatabaseName("IX_admin_watchlists_player_user_id"); + + b.HasIndex("RoundId") + .HasDatabaseName("IX_admin_watchlists_round_id"); + + b.ToTable("admin_watchlists", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Antag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("antag_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AntagName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("antag_name"); + + b.Property("ProfileId") + .HasColumnType("integer") + .HasColumnName("profile_id"); + + b.HasKey("Id") + .HasName("PK_antag"); + + b.HasIndex("ProfileId", "AntagName") + .IsUnique(); + + b.ToTable("antag", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AssignedUserId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("assigned_user_id_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("PK_assigned_user_id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("assigned_user_id", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Ban", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("ban_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AutoDelete") + .HasColumnType("boolean") + .HasColumnName("auto_delete"); + + b.Property("BanTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("ban_time"); + + b.Property("BanningAdmin") + .HasColumnType("uuid") + .HasColumnName("banning_admin"); + + b.Property("ExemptFlags") + .HasColumnType("integer") + .HasColumnName("exempt_flags"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_time"); + + b.Property("Hidden") + .HasColumnType("boolean") + .HasColumnName("hidden"); + + b.Property("LastEditedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_edited_at"); + + b.Property("LastEditedById") + .HasColumnType("uuid") + .HasColumnName("last_edited_by_id"); + + b.Property("PlaytimeAtNote") + .HasColumnType("interval") + .HasColumnName("playtime_at_note"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("Severity") + .HasColumnType("integer") + .HasColumnName("severity"); + + b.Property("Type") + .HasColumnType("smallint") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("PK_ban"); + + b.HasIndex("BanningAdmin"); + + b.HasIndex("LastEditedById"); + + b.ToTable("ban", null, t => + { + t.HasCheckConstraint("NoExemptOnRoleBan", "type = 0 OR exempt_flags = 0"); + }); + }); + + modelBuilder.Entity("Content.Server.Database.BanAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("ban_address_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("inet") + .HasColumnName("address"); + + b.Property("BanId") + .HasColumnType("integer") + .HasColumnName("ban_id"); + + b.HasKey("Id") + .HasName("PK_ban_address"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_ban_address_ban_id"); + + b.ToTable("ban_address", null, t => + { + t.HasCheckConstraint("AddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= address"); + }); + }); + + modelBuilder.Entity("Content.Server.Database.BanHwid", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("ban_hwid_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BanId") + .HasColumnType("integer") + .HasColumnName("ban_id"); + + b.HasKey("Id") + .HasName("PK_ban_hwid"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_ban_hwid_ban_id"); + + b.ToTable("ban_hwid", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.BanPlayer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("ban_player_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BanId") + .HasColumnType("integer") + .HasColumnName("ban_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_ban_player"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_ban_player_ban_id"); + + b.HasIndex("UserId", "BanId") + .IsUnique(); + + b.ToTable("ban_player", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.BanRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("ban_role_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BanId") + .HasColumnType("integer") + .HasColumnName("ban_id"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role_id"); + + b.Property("RoleType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role_type"); + + b.HasKey("Id") + .HasName("PK_ban_role"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_ban_role_ban_id"); + + b.HasIndex("RoleType", "RoleId", "BanId") + .IsUnique(); + + b.ToTable("ban_role", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.BanRound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("ban_round_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BanId") + .HasColumnType("integer") + .HasColumnName("ban_id"); + + b.Property("RoundId") + .HasColumnType("integer") + .HasColumnName("round_id"); + + b.HasKey("Id") + .HasName("PK_ban_round"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_ban_round_ban_id"); + + b.HasIndex("RoundId", "BanId") + .IsUnique(); + + b.ToTable("ban_round", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.BanTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("ban_template_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AutoDelete") + .HasColumnType("boolean") + .HasColumnName("auto_delete"); + + b.Property("ExemptFlags") + .HasColumnType("integer") + .HasColumnName("exempt_flags"); + + b.Property("Hidden") + .HasColumnType("boolean") + .HasColumnName("hidden"); + + b.Property("Length") + .HasColumnType("interval") + .HasColumnName("length"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("Severity") + .HasColumnType("integer") + .HasColumnName("severity"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("PK_ban_template"); + + b.ToTable("ban_template", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Blacklist", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("UserId") + .HasName("PK_blacklist"); + + b.ToTable("blacklist", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("connection_log_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("address"); + + b.Property("Denied") + .HasColumnType("smallint") + .HasColumnName("denied"); + + b.Property("ServerId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("server_id"); + + b.Property("Time") + .HasColumnType("timestamp with time zone") + .HasColumnName("time"); + + b.Property("Trust") + .HasColumnType("real") + .HasColumnName("trust"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("PK_connection_log"); + + b.HasIndex("ServerId") + .HasDatabaseName("IX_connection_log_server_id"); + + b.HasIndex("Time"); + + b.HasIndex("UserId"); + + b.ToTable("connection_log", null, t => + { + t.HasCheckConstraint("AddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= address"); + }); + }); + + modelBuilder.Entity("Content.Server.Database.IPIntelCache", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("ipintel_cache_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("address"); + + b.Property("Score") + .HasColumnType("real") + .HasColumnName("score"); + + b.Property("Time") + .HasColumnType("timestamp with time zone") + .HasColumnName("time"); + + b.HasKey("Id") + .HasName("PK_ipintel_cache"); + + b.ToTable("ipintel_cache", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("job_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("ProfileId") + .HasColumnType("integer") + .HasColumnName("profile_id"); + + b.HasKey("Id") + .HasName("PK_job"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "JobName") + .IsUnique(); + + b.HasIndex(new[] { "ProfileId" }, "IX_job_one_high_priority") + .IsUnique() + .HasFilter("priority = 3"); + + b.ToTable("job", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.PlayTime", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("play_time_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PlayerId") + .HasColumnType("uuid") + .HasColumnName("player_id"); + + b.Property("TimeSpent") + .HasColumnType("interval") + .HasColumnName("time_spent"); + + b.Property("Tracker") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tracker"); + + b.HasKey("Id") + .HasName("PK_play_time"); + + b.HasIndex("PlayerId", "Tracker") + .IsUnique(); + + b.ToTable("play_time", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("player_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FirstSeenTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_seen_time"); + + b.Property("LastReadRules") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_read_rules"); + + b.Property("LastSeenAddress") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("last_seen_address"); + + b.Property("LastSeenTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_time"); + + b.Property("LastSeenUserName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_seen_user_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_player"); + + b.HasAlternateKey("UserId") + .HasName("ak_player_user_id"); + + b.HasIndex("LastSeenUserName"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("player", null, t => + { + t.HasCheckConstraint("LastSeenAddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= last_seen_address"); + }); + }); + + modelBuilder.Entity("Content.Server.Database.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("preference_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdminOOCColor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("admin_ooc_color"); + + b.PrimitiveCollection>("ConstructionFavorites") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("construction_favorites"); + + b.Property("SelectedCharacterSlot") + .HasColumnType("integer") + .HasColumnName("selected_character_slot"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_preference"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("preference", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Profile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("profile_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Age") + .HasColumnType("integer") + .HasColumnName("age"); + + b.Property("CharacterName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("char_name"); + + b.Property("EyeColor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("eye_color"); + + b.Property("FacialHairColor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("facial_hair_color"); + + b.Property("FacialHairName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("facial_hair_name"); + + b.Property("FlavorText") + .IsRequired() + .HasColumnType("text") + .HasColumnName("flavor_text"); + + b.Property("Gender") + .IsRequired() + .HasColumnType("text") + .HasColumnName("gender"); + + b.Property("HairColor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("hair_color"); + + b.Property("HairName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("hair_name"); + + b.Property("Markings") + .HasColumnType("jsonb") + .HasColumnName("markings"); + + b.Property("PreferenceId") + .HasColumnType("integer") + .HasColumnName("preference_id"); + + b.Property("PreferenceUnavailable") + .HasColumnType("integer") + .HasColumnName("pref_unavailable"); + + b.Property("Sex") + .IsRequired() + .HasColumnType("text") + .HasColumnName("sex"); + + b.Property("SkinColor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("skin_color"); + + b.Property("Slot") + .HasColumnType("integer") + .HasColumnName("slot"); + + b.Property("SpawnPriority") + .HasColumnType("integer") + .HasColumnName("spawn_priority"); + + b.Property("Species") + .IsRequired() + .HasColumnType("text") + .HasColumnName("species"); + + b.HasKey("Id") + .HasName("PK_profile"); + + b.HasIndex("PreferenceId") + .HasDatabaseName("IX_profile_preference_id"); + + b.HasIndex("Slot", "PreferenceId") + .IsUnique(); + + b.ToTable("profile", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileLoadout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("profile_loadout_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("LoadoutName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("loadout_name"); + + b.Property("ProfileLoadoutGroupId") + .HasColumnType("integer") + .HasColumnName("profile_loadout_group_id"); + + b.HasKey("Id") + .HasName("PK_profile_loadout"); + + b.HasIndex("ProfileLoadoutGroupId"); + + b.ToTable("profile_loadout", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileLoadoutGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("profile_loadout_group_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GroupName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("group_name"); + + b.Property("ProfileRoleLoadoutId") + .HasColumnType("integer") + .HasColumnName("profile_role_loadout_id"); + + b.HasKey("Id") + .HasName("PK_profile_loadout_group"); + + b.HasIndex("ProfileRoleLoadoutId"); + + b.ToTable("profile_loadout_group", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileRoleLoadout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("profile_role_loadout_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EntityName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("entity_name"); + + b.Property("ProfileId") + .HasColumnType("integer") + .HasColumnName("profile_id"); + + b.Property("RoleName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role_name"); + + b.HasKey("Id") + .HasName("PK_profile_role_loadout"); + + b.HasIndex("ProfileId"); + + b.ToTable("profile_role_loadout", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.RoleWhitelist", b => + { + b.Property("PlayerUserId") + .HasColumnType("uuid") + .HasColumnName("player_user_id"); + + b.Property("RoleId") + .HasColumnType("text") + .HasColumnName("role_id"); + + b.HasKey("PlayerUserId", "RoleId") + .HasName("PK_role_whitelists"); + + b.ToTable("role_whitelists", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Round", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("round_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ServerId") + .HasColumnType("integer") + .HasColumnName("server_id"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_date"); + + b.HasKey("Id") + .HasName("PK_round"); + + b.HasIndex("ServerId") + .HasDatabaseName("IX_round_server_id"); + + b.HasIndex("StartDate"); + + b.ToTable("round", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Server", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("server_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("PK_server"); + + b.ToTable("server", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ServerBanExemption", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Flags") + .HasColumnType("integer") + .HasColumnName("flags"); + + b.HasKey("UserId") + .HasName("PK_server_ban_exemption"); + + b.ToTable("server_ban_exemption", null, t => + { + t.HasCheckConstraint("FlagsNotZero", "flags != 0"); + }); + }); + + modelBuilder.Entity("Content.Server.Database.ServerBanHit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("server_ban_hit_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BanId") + .HasColumnType("integer") + .HasColumnName("ban_id"); + + b.Property("ConnectionId") + .HasColumnType("integer") + .HasColumnName("connection_id"); + + b.HasKey("Id") + .HasName("PK_server_ban_hit"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_server_ban_hit_ban_id"); + + b.HasIndex("ConnectionId") + .HasDatabaseName("IX_server_ban_hit_connection_id"); + + b.ToTable("server_ban_hit", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Trait", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("trait_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ProfileId") + .HasColumnType("integer") + .HasColumnName("profile_id"); + + b.Property("TraitName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trait_name"); + + b.HasKey("Id") + .HasName("PK_trait"); + + b.HasIndex("ProfileId", "TraitName") + .IsUnique(); + + b.ToTable("trait", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Unban", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("unban_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BanId") + .HasColumnType("integer") + .HasColumnName("ban_id"); + + b.Property("UnbanTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("unban_time"); + + b.Property("UnbanningAdmin") + .HasColumnType("uuid") + .HasColumnName("unbanning_admin"); + + b.HasKey("Id") + .HasName("PK_unban"); + + b.HasIndex("BanId") + .IsUnique(); + + b.ToTable("unban", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.UploadedResourceLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("uploaded_resource_log_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Data") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("data"); + + b.Property("Date") + .HasColumnType("timestamp with time zone") + .HasColumnName("date"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_uploaded_resource_log"); + + b.ToTable("uploaded_resource_log", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Whitelist", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("UserId") + .HasName("PK_whitelist"); + + b.ToTable("whitelist", (string)null); + }); + + modelBuilder.Entity("PlayerRound", b => + { + b.Property("PlayersId") + .HasColumnType("integer") + .HasColumnName("players_id"); + + b.Property("RoundsId") + .HasColumnType("integer") + .HasColumnName("rounds_id"); + + b.HasKey("PlayersId", "RoundsId") + .HasName("PK_player_round"); + + b.HasIndex("RoundsId") + .HasDatabaseName("IX_player_round_rounds_id"); + + b.ToTable("player_round", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Admin", b => + { + b.HasOne("Content.Server.Database.AdminRank", "AdminRank") + .WithMany("Admins") + .HasForeignKey("AdminRankId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_admin_rank_admin_rank_id"); + + b.Navigation("AdminRank"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminFlag", b => + { + b.HasOne("Content.Server.Database.Admin", "Admin") + .WithMany("Flags") + .HasForeignKey("AdminId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_flag_admin_admin_id"); + + b.Navigation("Admin"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLog", b => + { + b.HasOne("Content.Server.Database.Round", "Round") + .WithMany("AdminLogs") + .HasForeignKey("RoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_log_round_round_id"); + + b.Navigation("Round"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLogPlayer", b => + { + b.HasOne("Content.Server.Database.Player", "Player") + .WithMany("AdminLogs") + .HasForeignKey("PlayerUserId") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_log_player_player_player_user_id"); + + b.HasOne("Content.Server.Database.AdminLog", "Log") + .WithMany("Players") + .HasForeignKey("RoundId", "LogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_log_player_admin_log_round_id_log_id"); + + b.Navigation("Log"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminMessage", b => + { + b.HasOne("Content.Server.Database.Player", "CreatedBy") + .WithMany("AdminMessagesCreated") + .HasForeignKey("CreatedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_messages_player_created_by_id"); + + b.HasOne("Content.Server.Database.Player", "DeletedBy") + .WithMany("AdminMessagesDeleted") + .HasForeignKey("DeletedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_messages_player_deleted_by_id"); + + b.HasOne("Content.Server.Database.Player", "LastEditedBy") + .WithMany("AdminMessagesLastEdited") + .HasForeignKey("LastEditedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_messages_player_last_edited_by_id"); + + b.HasOne("Content.Server.Database.Player", "Player") + .WithMany("AdminMessagesReceived") + .HasForeignKey("PlayerUserId") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_admin_messages_player_player_user_id"); + + b.HasOne("Content.Server.Database.Round", "Round") + .WithMany() + .HasForeignKey("RoundId") + .HasConstraintName("FK_admin_messages_round_round_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("LastEditedBy"); + + b.Navigation("Player"); + + b.Navigation("Round"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminNote", b => + { + b.HasOne("Content.Server.Database.Player", "CreatedBy") + .WithMany("AdminNotesCreated") + .HasForeignKey("CreatedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_notes_player_created_by_id"); + + b.HasOne("Content.Server.Database.Player", "DeletedBy") + .WithMany("AdminNotesDeleted") + .HasForeignKey("DeletedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_notes_player_deleted_by_id"); + + b.HasOne("Content.Server.Database.Player", "LastEditedBy") + .WithMany("AdminNotesLastEdited") + .HasForeignKey("LastEditedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_notes_player_last_edited_by_id"); + + b.HasOne("Content.Server.Database.Player", "Player") + .WithMany("AdminNotesReceived") + .HasForeignKey("PlayerUserId") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_admin_notes_player_player_user_id"); + + b.HasOne("Content.Server.Database.Round", "Round") + .WithMany() + .HasForeignKey("RoundId") + .HasConstraintName("FK_admin_notes_round_round_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("LastEditedBy"); + + b.Navigation("Player"); + + b.Navigation("Round"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminRankFlag", b => + { + b.HasOne("Content.Server.Database.AdminRank", "Rank") + .WithMany("Flags") + .HasForeignKey("AdminRankId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_rank_flag_admin_rank_admin_rank_id"); + + b.Navigation("Rank"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminWatchlist", b => + { + b.HasOne("Content.Server.Database.Player", "CreatedBy") + .WithMany("AdminWatchlistsCreated") + .HasForeignKey("CreatedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_watchlists_player_created_by_id"); + + b.HasOne("Content.Server.Database.Player", "DeletedBy") + .WithMany("AdminWatchlistsDeleted") + .HasForeignKey("DeletedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_watchlists_player_deleted_by_id"); + + b.HasOne("Content.Server.Database.Player", "LastEditedBy") + .WithMany("AdminWatchlistsLastEdited") + .HasForeignKey("LastEditedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_watchlists_player_last_edited_by_id"); + + b.HasOne("Content.Server.Database.Player", "Player") + .WithMany("AdminWatchlistsReceived") + .HasForeignKey("PlayerUserId") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_admin_watchlists_player_player_user_id"); + + b.HasOne("Content.Server.Database.Round", "Round") + .WithMany() + .HasForeignKey("RoundId") + .HasConstraintName("FK_admin_watchlists_round_round_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("LastEditedBy"); + + b.Navigation("Player"); + + b.Navigation("Round"); + }); + + modelBuilder.Entity("Content.Server.Database.Antag", b => + { + b.HasOne("Content.Server.Database.Profile", "Profile") + .WithMany("Antags") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_antag_profile_profile_id"); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Content.Server.Database.Ban", b => + { + b.HasOne("Content.Server.Database.Player", "CreatedBy") + .WithMany("AdminServerBansCreated") + .HasForeignKey("BanningAdmin") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_ban_player_banning_admin"); + + b.HasOne("Content.Server.Database.Player", "LastEditedBy") + .WithMany("AdminServerBansLastEdited") + .HasForeignKey("LastEditedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_ban_player_last_edited_by_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("LastEditedBy"); + }); + + modelBuilder.Entity("Content.Server.Database.BanAddress", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithMany("Addresses") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_address_ban_ban_id"); + + b.Navigation("Ban"); + }); + + modelBuilder.Entity("Content.Server.Database.BanHwid", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithMany("Hwids") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_hwid_ban_ban_id"); + + b.OwnsOne("Content.Server.Database.TypedHwid", "HWId", b1 => + { + b1.Property("BanHwidId") + .HasColumnType("integer") + .HasColumnName("ban_hwid_id"); + + b1.Property("Hwid") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("hwid"); + + b1.Property("Type") + .HasColumnType("integer") + .HasColumnName("hwid_type"); + + b1.HasKey("BanHwidId"); + + b1.ToTable("ban_hwid"); + + b1.WithOwner() + .HasForeignKey("BanHwidId") + .HasConstraintName("FK_ban_hwid_ban_hwid_ban_hwid_id"); + }); + + b.Navigation("Ban"); + + b.Navigation("HWId") + .IsRequired(); + }); + + modelBuilder.Entity("Content.Server.Database.BanPlayer", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithMany("Players") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_player_ban_ban_id"); + + b.Navigation("Ban"); + }); + + modelBuilder.Entity("Content.Server.Database.BanRole", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithMany("Roles") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_role_ban_ban_id"); + + b.Navigation("Ban"); + }); + + modelBuilder.Entity("Content.Server.Database.BanRound", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithMany("Rounds") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_round_ban_ban_id"); + + b.HasOne("Content.Server.Database.Round", "Round") + .WithMany() + .HasForeignKey("RoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_round_round_round_id"); + + b.Navigation("Ban"); + + b.Navigation("Round"); + }); + + modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => + { + b.HasOne("Content.Server.Database.Server", "Server") + .WithMany("ConnectionLogs") + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired() + .HasConstraintName("FK_connection_log_server_server_id"); + + b.OwnsOne("Content.Server.Database.TypedHwid", "HWId", b1 => + { + b1.Property("ConnectionLogId") + .HasColumnType("integer") + .HasColumnName("connection_log_id"); + + b1.Property("Hwid") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("hwid"); + + b1.Property("Type") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("hwid_type"); + + b1.HasKey("ConnectionLogId"); + + b1.ToTable("connection_log"); + + b1.WithOwner() + .HasForeignKey("ConnectionLogId") + .HasConstraintName("FK_connection_log_connection_log_connection_log_id"); + }); + + b.Navigation("HWId"); + + b.Navigation("Server"); + }); + + modelBuilder.Entity("Content.Server.Database.Job", b => + { + b.HasOne("Content.Server.Database.Profile", "Profile") + .WithMany("Jobs") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_job_profile_profile_id"); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Content.Server.Database.Player", b => + { + b.OwnsOne("Content.Server.Database.TypedHwid", "LastSeenHWId", b1 => + { + b1.Property("PlayerId") + .HasColumnType("integer") + .HasColumnName("player_id"); + + b1.Property("Hwid") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("last_seen_hwid"); + + b1.Property("Type") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("last_seen_hwid_type"); + + b1.HasKey("PlayerId"); + + b1.ToTable("player"); + + b1.WithOwner() + .HasForeignKey("PlayerId") + .HasConstraintName("FK_player_player_player_id"); + }); + + b.Navigation("LastSeenHWId"); + }); + + modelBuilder.Entity("Content.Server.Database.Profile", b => + { + b.HasOne("Content.Server.Database.Preference", "Preference") + .WithMany("Profiles") + .HasForeignKey("PreferenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_profile_preference_preference_id"); + + b.Navigation("Preference"); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileLoadout", b => + { + b.HasOne("Content.Server.Database.ProfileLoadoutGroup", "ProfileLoadoutGroup") + .WithMany("Loadouts") + .HasForeignKey("ProfileLoadoutGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_profile_loadout_profile_loadout_group_profile_loadout_group~"); + + b.Navigation("ProfileLoadoutGroup"); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileLoadoutGroup", b => + { + b.HasOne("Content.Server.Database.ProfileRoleLoadout", "ProfileRoleLoadout") + .WithMany("Groups") + .HasForeignKey("ProfileRoleLoadoutId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_profile_loadout_group_profile_role_loadout_profile_role_loa~"); + + b.Navigation("ProfileRoleLoadout"); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileRoleLoadout", b => + { + b.HasOne("Content.Server.Database.Profile", "Profile") + .WithMany("Loadouts") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_profile_role_loadout_profile_profile_id"); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Content.Server.Database.RoleWhitelist", b => + { + b.HasOne("Content.Server.Database.Player", "Player") + .WithMany("JobWhitelists") + .HasForeignKey("PlayerUserId") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_role_whitelists_player_player_user_id"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("Content.Server.Database.Round", b => + { + b.HasOne("Content.Server.Database.Server", "Server") + .WithMany("Rounds") + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_round_server_server_id"); + + b.Navigation("Server"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerBanHit", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithMany("BanHits") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_server_ban_hit_ban_ban_id"); + + b.HasOne("Content.Server.Database.ConnectionLog", "Connection") + .WithMany("BanHits") + .HasForeignKey("ConnectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_server_ban_hit_connection_log_connection_id"); + + b.Navigation("Ban"); + + b.Navigation("Connection"); + }); + + modelBuilder.Entity("Content.Server.Database.Trait", b => + { + b.HasOne("Content.Server.Database.Profile", "Profile") + .WithMany("Traits") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_trait_profile_profile_id"); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Content.Server.Database.Unban", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithOne("Unban") + .HasForeignKey("Content.Server.Database.Unban", "BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_unban_ban_ban_id"); + + b.Navigation("Ban"); + }); + + modelBuilder.Entity("PlayerRound", b => + { + b.HasOne("Content.Server.Database.Player", null) + .WithMany() + .HasForeignKey("PlayersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_player_round_player_players_id"); + + b.HasOne("Content.Server.Database.Round", null) + .WithMany() + .HasForeignKey("RoundsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_player_round_round_rounds_id"); + }); + + modelBuilder.Entity("Content.Server.Database.Admin", b => + { + b.Navigation("Flags"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLog", b => + { + b.Navigation("Players"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminRank", b => + { + b.Navigation("Admins"); + + b.Navigation("Flags"); + }); + + modelBuilder.Entity("Content.Server.Database.Ban", b => + { + b.Navigation("Addresses"); + + b.Navigation("BanHits"); + + b.Navigation("Hwids"); + + b.Navigation("Players"); + + b.Navigation("Roles"); + + b.Navigation("Rounds"); + + b.Navigation("Unban"); + }); + + modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => + { + b.Navigation("BanHits"); + }); + + modelBuilder.Entity("Content.Server.Database.Player", b => + { + b.Navigation("AdminLogs"); + + b.Navigation("AdminMessagesCreated"); + + b.Navigation("AdminMessagesDeleted"); + + b.Navigation("AdminMessagesLastEdited"); + + b.Navigation("AdminMessagesReceived"); + + b.Navigation("AdminNotesCreated"); + + b.Navigation("AdminNotesDeleted"); + + b.Navigation("AdminNotesLastEdited"); + + b.Navigation("AdminNotesReceived"); + + b.Navigation("AdminServerBansCreated"); + + b.Navigation("AdminServerBansLastEdited"); + + b.Navigation("AdminWatchlistsCreated"); + + b.Navigation("AdminWatchlistsDeleted"); + + b.Navigation("AdminWatchlistsLastEdited"); + + b.Navigation("AdminWatchlistsReceived"); + + b.Navigation("JobWhitelists"); + }); + + modelBuilder.Entity("Content.Server.Database.Preference", b => + { + b.Navigation("Profiles"); + }); + + modelBuilder.Entity("Content.Server.Database.Profile", b => + { + b.Navigation("Antags"); + + b.Navigation("Jobs"); + + b.Navigation("Loadouts"); + + b.Navigation("Traits"); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileLoadoutGroup", b => + { + b.Navigation("Loadouts"); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileRoleLoadout", b => + { + b.Navigation("Groups"); + }); + + modelBuilder.Entity("Content.Server.Database.Round", b => + { + b.Navigation("AdminLogs"); + }); + + modelBuilder.Entity("Content.Server.Database.Server", b => + { + b.Navigation("ConnectionLogs"); + + b.Navigation("Rounds"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Content.Server.Database/Migrations/Postgres/20260120200503_BanRefactor.cs b/Content.Server.Database/Migrations/Postgres/20260120200503_BanRefactor.cs new file mode 100644 index 0000000000..64a20c2933 --- /dev/null +++ b/Content.Server.Database/Migrations/Postgres/20260120200503_BanRefactor.cs @@ -0,0 +1,535 @@ +using System; +using Content.Shared.Database; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using NpgsqlTypes; + +#nullable disable + +namespace Content.Server.Database.Migrations.Postgres +{ + /// + public partial class BanRefactor : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ban", + columns: table => new + { + ban_id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + type = table.Column(type: "smallint", nullable: false), + playtime_at_note = table.Column(type: "interval", nullable: false), + ban_time = table.Column(type: "timestamp with time zone", nullable: false), + expiration_time = table.Column(type: "timestamp with time zone", nullable: true), + reason = table.Column(type: "text", nullable: false), + severity = table.Column(type: "integer", nullable: false), + banning_admin = table.Column(type: "uuid", nullable: true), + last_edited_by_id = table.Column(type: "uuid", nullable: true), + last_edited_at = table.Column(type: "timestamp with time zone", nullable: true), + exempt_flags = table.Column(type: "integer", nullable: false), + auto_delete = table.Column(type: "boolean", nullable: false), + hidden = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ban", x => x.ban_id); + table.CheckConstraint("NoExemptOnRoleBan", "type = 0 OR exempt_flags = 0"); + table.ForeignKey( + name: "FK_ban_player_banning_admin", + column: x => x.banning_admin, + principalTable: "player", + principalColumn: "user_id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_ban_player_last_edited_by_id", + column: x => x.last_edited_by_id, + principalTable: "player", + principalColumn: "user_id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "ban_address", + columns: table => new + { + ban_address_id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + address = table.Column(type: "inet", nullable: false), + ban_id = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ban_address", x => x.ban_address_id); + table.CheckConstraint("AddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= address"); + table.ForeignKey( + name: "FK_ban_address_ban_ban_id", + column: x => x.ban_id, + principalTable: "ban", + principalColumn: "ban_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ban_hwid", + columns: table => new + { + ban_hwid_id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + hwid = table.Column(type: "bytea", nullable: false), + hwid_type = table.Column(type: "integer", nullable: false), + ban_id = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ban_hwid", x => x.ban_hwid_id); + table.ForeignKey( + name: "FK_ban_hwid_ban_ban_id", + column: x => x.ban_id, + principalTable: "ban", + principalColumn: "ban_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ban_player", + columns: table => new + { + ban_player_id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + user_id = table.Column(type: "uuid", nullable: false), + ban_id = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ban_player", x => x.ban_player_id); + table.ForeignKey( + name: "FK_ban_player_ban_ban_id", + column: x => x.ban_id, + principalTable: "ban", + principalColumn: "ban_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ban_role", + columns: table => new + { + ban_role_id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + role_type = table.Column(type: "text", nullable: false), + role_id = table.Column(type: "text", nullable: false), + ban_id = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ban_role", x => x.ban_role_id); + table.ForeignKey( + name: "FK_ban_role_ban_ban_id", + column: x => x.ban_id, + principalTable: "ban", + principalColumn: "ban_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ban_round", + columns: table => new + { + ban_round_id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ban_id = table.Column(type: "integer", nullable: false), + round_id = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ban_round", x => x.ban_round_id); + table.ForeignKey( + name: "FK_ban_round_ban_ban_id", + column: x => x.ban_id, + principalTable: "ban", + principalColumn: "ban_id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ban_round_round_round_id", + column: x => x.round_id, + principalTable: "round", + principalColumn: "round_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "unban", + columns: table => new + { + unban_id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ban_id = table.Column(type: "integer", nullable: false), + unbanning_admin = table.Column(type: "uuid", nullable: true), + unban_time = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_unban", x => x.unban_id); + table.ForeignKey( + name: "FK_unban_ban_ban_id", + column: x => x.ban_id, + principalTable: "ban", + principalColumn: "ban_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ban_banning_admin", + table: "ban", + column: "banning_admin"); + + migrationBuilder.CreateIndex( + name: "IX_ban_last_edited_by_id", + table: "ban", + column: "last_edited_by_id"); + + migrationBuilder.CreateIndex( + name: "IX_ban_address_ban_id", + table: "ban_address", + column: "ban_id"); + + migrationBuilder.CreateIndex( + name: "IX_ban_hwid_ban_id", + table: "ban_hwid", + column: "ban_id"); + + migrationBuilder.CreateIndex( + name: "IX_ban_player_ban_id", + table: "ban_player", + column: "ban_id"); + + migrationBuilder.CreateIndex( + name: "IX_ban_player_user_id_ban_id", + table: "ban_player", + columns: new[] { "user_id", "ban_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ban_role_ban_id", + table: "ban_role", + column: "ban_id"); + + migrationBuilder.CreateIndex( + name: "IX_ban_role_role_type_role_id_ban_id", + table: "ban_role", + columns: new[] { "role_type", "role_id", "ban_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ban_round_ban_id", + table: "ban_round", + column: "ban_id"); + + migrationBuilder.CreateIndex( + name: "IX_ban_round_round_id_ban_id", + table: "ban_round", + columns: new[] { "round_id", "ban_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_unban_ban_id", + table: "unban", + column: "ban_id", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_server_ban_hit_ban_ban_id", + table: "server_ban_hit", + column: "ban_id", + principalTable: "ban", + principalColumn: "ban_id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.Sql(""" + CREATE INDEX "IX_ban_address_address" + ON ban_address + USING gist + (address inet_ops) + INCLUDE (ban_id); + + CREATE UNIQUE INDEX "IX_ban_hwid_hwid_ban_id" + ON ban_hwid + (hwid_type, hwid, ban_id); + + CREATE UNIQUE INDEX "IX_ban_address_address_ban_id" + ON ban_address + (address, ban_id); + """); + + migrationBuilder.Sql($""" + -- REMOVE: + -- TRUNCATE ban RESTART IDENTITY CASCADE; + + -- + -- Insert game bans + -- + INSERT INTO + ban (ban_id, type, playtime_at_note, ban_time, expiration_time, reason, severity, banning_admin, last_edited_by_id, last_edited_at, exempt_flags, auto_delete, hidden) + SELECT + server_ban_id, {(int)BanType.Server}, playtime_at_note, ban_time, expiration_time, reason, severity, banning_admin, last_edited_by_id, last_edited_at, exempt_flags, auto_delete, hidden + FROM + server_ban; + + -- Update ID sequence to be after newly inserted IDs. + SELECT setval('ban_ban_id_seq', (SELECT MAX(ban_id) FROM ban)); + + -- Insert ban player records. + INSERT INTO + ban_player (user_id, ban_id) + SELECT + player_user_id, server_ban_id + FROM + server_ban + WHERE + player_user_id IS NOT NULL; + + -- Insert ban address records. + INSERT INTO + ban_address (address, ban_id) + SELECT + address, server_ban_id + FROM + server_ban + WHERE + address IS NOT NULL; + + -- Insert ban HWID records. + INSERT INTO + ban_hwid (hwid, hwid_type, ban_id) + SELECT + hwid, hwid_type, server_ban_id + FROM + server_ban + WHERE + hwid IS NOT NULL; + + -- Insert ban unban records. + INSERT INTO + unban (ban_id, unbanning_admin, unban_time) + SELECT + ban_id, unbanning_admin, unban_time + FROM server_unban; + + + -- Insert ban round records. + INSERT INTO + ban_round (round_id, ban_id) + SELECT + round_id, server_ban_id + FROM + server_ban + WHERE + round_id IS NOT NULL; + + -- + -- Insert role bans + -- This shit is a pain in the ass + -- > Declarative language + -- > Has to write procedural code in it + -- + + -- Create mapping table from role ban -> server ban. + -- We have to manually calculate the new ban IDs by using the sequence. + -- We also want to merge role ban records because the game code previously did that in some UI, + -- and that code is now gone, expecting the DB to do it. + + -- Create a table to store IDs to merge. + CREATE TEMPORARY TABLE /*IF NOT EXISTS*/ _role_ban_import_merge_map (merge_id INTEGER, server_role_ban_id INTEGER UNIQUE) ON COMMIT DROP; + -- TRUNCATE _role_ban_import_merge_map; + + -- Create a table to store merged IDs -> new ban IDs + CREATE TEMPORARY TABLE /*IF NOT EXISTS*/ _role_ban_import_id_map (ban_id INTEGER UNIQUE, merge_id INTEGER UNIQUE) ON COMMIT DROP; + -- TRUNCATE _role_ban_import_id_map; + + -- Calculate merged role bans. + INSERT INTO + _role_ban_import_merge_map + SELECT + ( + SELECT + sub.server_role_ban_id + FROM + server_role_ban AS sub + LEFT JOIN server_role_unban AS sub_unban + ON sub_unban.ban_id = sub.server_role_ban_id + WHERE + main.reason IS NOT DISTINCT FROM sub.reason + AND main.player_user_id IS NOT DISTINCT FROM sub.player_user_id + AND main.address IS NOT DISTINCT FROM sub.address + AND main.hwid IS NOT DISTINCT FROM sub.hwid + AND main.hwid_type IS NOT DISTINCT FROM sub.hwid_type + AND date_trunc('second', main.ban_time, 'utc') = date_trunc('second', sub.ban_time, 'utc') + AND ( + (main.expiration_time IS NULL) = (sub.expiration_time IS NULL) + OR date_trunc('minute', main.expiration_time, 'utc') = date_trunc('minute', sub.expiration_time, 'utc') + ) + AND main.round_id IS NOT DISTINCT FROM sub.round_id + AND main.severity IS NOT DISTINCT FROM sub.severity + AND main.hidden IS NOT DISTINCT FROM sub.hidden + AND main.banning_admin IS NOT DISTINCT FROM sub.banning_admin + AND (sub_unban.ban_id IS NULL) = (main_unban.ban_id IS NULL) + ORDER BY + sub.server_role_ban_id ASC + LIMIT 1 + ), main.server_role_ban_id + FROM + server_role_ban AS main + LEFT JOIN server_role_unban AS main_unban + ON main_unban.ban_id = main.server_role_ban_id; + + -- Assign new ban IDs for merged IDs. + INSERT INTO + _role_ban_import_id_map + SELECT + DISTINCT ON (merge_id) + nextval('ban_ban_id_seq'), + merge_id + FROM + _role_ban_import_merge_map; + + -- I sure fucking wish CTEs could span multiple queries... + + -- Insert new ban records + INSERT INTO + ban (ban_id, type, playtime_at_note, ban_time, expiration_time, reason, severity, banning_admin, last_edited_by_id, last_edited_at, exempt_flags, auto_delete, hidden) + SELECT + im.ban_id, {(int)BanType.Role}, playtime_at_note, ban_time, expiration_time, reason, severity, banning_admin, last_edited_by_id, last_edited_at, 0, FALSE, hidden + FROM + _role_ban_import_id_map im + INNER JOIN _role_ban_import_merge_map mm + ON im.merge_id = mm.merge_id + INNER JOIN server_role_ban srb + ON srb.server_role_ban_id = im.merge_id + WHERE mm.merge_id = mm.server_role_ban_id; + + -- Insert role ban player records. + INSERT INTO + ban_player (user_id, ban_id) + SELECT + player_user_id, im.ban_id + FROM + _role_ban_import_id_map im + INNER JOIN _role_ban_import_merge_map mm + ON im.merge_id = mm.merge_id + INNER JOIN server_role_ban srb + ON srb.server_role_ban_id = im.merge_id + WHERE mm.merge_id = mm.server_role_ban_id + AND player_user_id IS NOT NULL; + + -- Insert role ban address records. + INSERT INTO + ban_address (address, ban_id) + SELECT + address, im.ban_id + FROM + _role_ban_import_id_map im + INNER JOIN _role_ban_import_merge_map mm + ON im.merge_id = mm.merge_id + INNER JOIN server_role_ban srb + ON srb.server_role_ban_id = im.merge_id + WHERE mm.merge_id = mm.server_role_ban_id + AND address IS NOT NULL; + + -- Insert role ban HWID records. + INSERT INTO + ban_hwid (hwid, hwid_type, ban_id) + SELECT + hwid, hwid_type, im.ban_id + FROM + _role_ban_import_id_map im + INNER JOIN _role_ban_import_merge_map mm + ON im.merge_id = mm.merge_id + INNER JOIN server_role_ban srb + ON srb.server_role_ban_id = im.merge_id + WHERE mm.merge_id = mm.server_role_ban_id + AND hwid IS NOT NULL; + + -- Insert role ban role records. + INSERT INTO + ban_role (role_type, role_id, ban_id) + SELECT + split_part(role_id, ':', 1), split_part(role_id, ':', 2), im.ban_id + FROM + _role_ban_import_id_map im + INNER JOIN _role_ban_import_merge_map mm + ON im.merge_id = mm.merge_id + INNER JOIN server_role_ban srb + ON srb.server_role_ban_id = mm.server_role_ban_id + -- Yes, we have some messy ban records which, after merging, end up with duplicate roles. + ON CONFLICT DO NOTHING; + + -- Insert role unban records. + INSERT INTO + unban (ban_id, unbanning_admin, unban_time) + SELECT + im.ban_id, unbanning_admin, unban_time + FROM server_role_unban sru + INNER JOIN _role_ban_import_id_map im + ON im.merge_id = sru.ban_id; + + -- Insert role rounds + INSERT INTO + ban_round (round_id, ban_id) + SELECT + round_id, im.ban_id + FROM + _role_ban_import_id_map im + INNER JOIN _role_ban_import_merge_map mm + ON im.merge_id = mm.merge_id + INNER JOIN server_role_ban srb + ON srb.server_role_ban_id = im.merge_id + WHERE mm.merge_id = mm.server_role_ban_id + AND round_id IS NOT NULL; + """); + + migrationBuilder.DropForeignKey( + name: "FK_server_ban_hit_server_ban_ban_id", + table: "server_ban_hit"); + + migrationBuilder.DropTable( + name: "server_role_unban"); + + migrationBuilder.DropTable( + name: "server_unban"); + + migrationBuilder.DropTable( + name: "server_role_ban"); + + migrationBuilder.DropTable( + name: "server_ban"); + + migrationBuilder.Sql($""" + CREATE OR REPLACE FUNCTION send_server_ban_notification() + RETURNS trigger AS $$ + BEGIN + PERFORM pg_notify( + 'ban_notification', + json_build_object('ban_id', NEW.ban_id)::text + ); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER notify_on_server_ban_insert + AFTER INSERT ON ban + FOR EACH ROW + WHEN (NEW.type = {(int)BanType.Server}) + EXECUTE FUNCTION send_server_ban_notification(); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + throw new NotSupportedException("This migration cannot be rolled back"); + } + } +} diff --git a/Content.Server.Database/Migrations/Postgres/PostgresServerDbContextModelSnapshot.cs b/Content.Server.Database/Migrations/Postgres/PostgresServerDbContextModelSnapshot.cs index 51ba56049e..602afe2fba 100644 --- a/Content.Server.Database/Migrations/Postgres/PostgresServerDbContextModelSnapshot.cs +++ b/Content.Server.Database/Migrations/Postgres/PostgresServerDbContextModelSnapshot.cs @@ -1,5 +1,6 @@ // using System; +using System.Collections.Generic; using System.Net; using System.Text.Json; using Content.Server.Database; @@ -20,7 +21,7 @@ namespace Content.Server.Database.Migrations.Postgres { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("ProductVersion", "10.0.0") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -518,6 +519,221 @@ namespace Content.Server.Database.Migrations.Postgres b.ToTable("assigned_user_id", (string)null); }); + modelBuilder.Entity("Content.Server.Database.Ban", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("ban_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AutoDelete") + .HasColumnType("boolean") + .HasColumnName("auto_delete"); + + b.Property("BanTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("ban_time"); + + b.Property("BanningAdmin") + .HasColumnType("uuid") + .HasColumnName("banning_admin"); + + b.Property("ExemptFlags") + .HasColumnType("integer") + .HasColumnName("exempt_flags"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_time"); + + b.Property("Hidden") + .HasColumnType("boolean") + .HasColumnName("hidden"); + + b.Property("LastEditedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_edited_at"); + + b.Property("LastEditedById") + .HasColumnType("uuid") + .HasColumnName("last_edited_by_id"); + + b.Property("PlaytimeAtNote") + .HasColumnType("interval") + .HasColumnName("playtime_at_note"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("Severity") + .HasColumnType("integer") + .HasColumnName("severity"); + + b.Property("Type") + .HasColumnType("smallint") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("PK_ban"); + + b.HasIndex("BanningAdmin"); + + b.HasIndex("LastEditedById"); + + b.ToTable("ban", null, t => + { + t.HasCheckConstraint("NoExemptOnRoleBan", "type = 0 OR exempt_flags = 0"); + }); + }); + + modelBuilder.Entity("Content.Server.Database.BanAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("ban_address_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("inet") + .HasColumnName("address"); + + b.Property("BanId") + .HasColumnType("integer") + .HasColumnName("ban_id"); + + b.HasKey("Id") + .HasName("PK_ban_address"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_ban_address_ban_id"); + + b.ToTable("ban_address", null, t => + { + t.HasCheckConstraint("AddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= address"); + }); + }); + + modelBuilder.Entity("Content.Server.Database.BanHwid", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("ban_hwid_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BanId") + .HasColumnType("integer") + .HasColumnName("ban_id"); + + b.HasKey("Id") + .HasName("PK_ban_hwid"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_ban_hwid_ban_id"); + + b.ToTable("ban_hwid", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.BanPlayer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("ban_player_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BanId") + .HasColumnType("integer") + .HasColumnName("ban_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_ban_player"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_ban_player_ban_id"); + + b.HasIndex("UserId", "BanId") + .IsUnique(); + + b.ToTable("ban_player", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.BanRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("ban_role_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BanId") + .HasColumnType("integer") + .HasColumnName("ban_id"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role_id"); + + b.Property("RoleType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role_type"); + + b.HasKey("Id") + .HasName("PK_ban_role"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_ban_role_ban_id"); + + b.HasIndex("RoleType", "RoleId", "BanId") + .IsUnique(); + + b.ToTable("ban_role", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.BanRound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("ban_round_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BanId") + .HasColumnType("integer") + .HasColumnName("ban_id"); + + b.Property("RoundId") + .HasColumnType("integer") + .HasColumnName("round_id"); + + b.HasKey("Id") + .HasName("PK_ban_round"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_ban_round_ban_id"); + + b.HasIndex("RoundId", "BanId") + .IsUnique(); + + b.ToTable("ban_round", (string)null); + }); + modelBuilder.Entity("Content.Server.Database.BanTemplate", b => { b.Property("Id") @@ -795,7 +1011,7 @@ namespace Content.Server.Database.Migrations.Postgres .HasColumnType("text") .HasColumnName("admin_ooc_color"); - b.PrimitiveCollection("ConstructionFavorites") + b.PrimitiveCollection>("ConstructionFavorites") .IsRequired() .HasColumnType("text[]") .HasColumnName("construction_favorites"); @@ -1064,95 +1280,6 @@ namespace Content.Server.Database.Migrations.Postgres b.ToTable("server", (string)null); }); - modelBuilder.Entity("Content.Server.Database.ServerBan", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("server_ban_id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Address") - .HasColumnType("inet") - .HasColumnName("address"); - - b.Property("AutoDelete") - .HasColumnType("boolean") - .HasColumnName("auto_delete"); - - b.Property("BanTime") - .HasColumnType("timestamp with time zone") - .HasColumnName("ban_time"); - - b.Property("BanningAdmin") - .HasColumnType("uuid") - .HasColumnName("banning_admin"); - - b.Property("ExemptFlags") - .HasColumnType("integer") - .HasColumnName("exempt_flags"); - - b.Property("ExpirationTime") - .HasColumnType("timestamp with time zone") - .HasColumnName("expiration_time"); - - b.Property("Hidden") - .HasColumnType("boolean") - .HasColumnName("hidden"); - - b.Property("LastEditedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("last_edited_at"); - - b.Property("LastEditedById") - .HasColumnType("uuid") - .HasColumnName("last_edited_by_id"); - - b.Property("PlayerUserId") - .HasColumnType("uuid") - .HasColumnName("player_user_id"); - - b.Property("PlaytimeAtNote") - .HasColumnType("interval") - .HasColumnName("playtime_at_note"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("text") - .HasColumnName("reason"); - - b.Property("RoundId") - .HasColumnType("integer") - .HasColumnName("round_id"); - - b.Property("Severity") - .HasColumnType("integer") - .HasColumnName("severity"); - - b.HasKey("Id") - .HasName("PK_server_ban"); - - b.HasIndex("Address"); - - b.HasIndex("BanningAdmin"); - - b.HasIndex("LastEditedById"); - - b.HasIndex("PlayerUserId") - .HasDatabaseName("IX_server_ban_player_user_id"); - - b.HasIndex("RoundId") - .HasDatabaseName("IX_server_ban_round_id"); - - b.ToTable("server_ban", null, t => - { - t.HasCheckConstraint("AddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= address"); - - t.HasCheckConstraint("HaveEitherAddressOrUserIdOrHWId", "address IS NOT NULL OR player_user_id IS NOT NULL OR hwid IS NOT NULL"); - }); - }); - modelBuilder.Entity("Content.Server.Database.ServerBanExemption", b => { b.Property("UserId") @@ -1202,152 +1329,6 @@ namespace Content.Server.Database.Migrations.Postgres b.ToTable("server_ban_hit", (string)null); }); - modelBuilder.Entity("Content.Server.Database.ServerRoleBan", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("server_role_ban_id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Address") - .HasColumnType("inet") - .HasColumnName("address"); - - b.Property("BanTime") - .HasColumnType("timestamp with time zone") - .HasColumnName("ban_time"); - - b.Property("BanningAdmin") - .HasColumnType("uuid") - .HasColumnName("banning_admin"); - - b.Property("ExpirationTime") - .HasColumnType("timestamp with time zone") - .HasColumnName("expiration_time"); - - b.Property("Hidden") - .HasColumnType("boolean") - .HasColumnName("hidden"); - - b.Property("LastEditedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("last_edited_at"); - - b.Property("LastEditedById") - .HasColumnType("uuid") - .HasColumnName("last_edited_by_id"); - - b.Property("PlayerUserId") - .HasColumnType("uuid") - .HasColumnName("player_user_id"); - - b.Property("PlaytimeAtNote") - .HasColumnType("interval") - .HasColumnName("playtime_at_note"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("text") - .HasColumnName("reason"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("text") - .HasColumnName("role_id"); - - b.Property("RoundId") - .HasColumnType("integer") - .HasColumnName("round_id"); - - b.Property("Severity") - .HasColumnType("integer") - .HasColumnName("severity"); - - b.HasKey("Id") - .HasName("PK_server_role_ban"); - - b.HasIndex("Address"); - - b.HasIndex("BanningAdmin"); - - b.HasIndex("LastEditedById"); - - b.HasIndex("PlayerUserId") - .HasDatabaseName("IX_server_role_ban_player_user_id"); - - b.HasIndex("RoundId") - .HasDatabaseName("IX_server_role_ban_round_id"); - - b.ToTable("server_role_ban", null, t => - { - t.HasCheckConstraint("AddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= address"); - - t.HasCheckConstraint("HaveEitherAddressOrUserIdOrHWId", "address IS NOT NULL OR player_user_id IS NOT NULL OR hwid IS NOT NULL"); - }); - }); - - modelBuilder.Entity("Content.Server.Database.ServerRoleUnban", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("role_unban_id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("BanId") - .HasColumnType("integer") - .HasColumnName("ban_id"); - - b.Property("UnbanTime") - .HasColumnType("timestamp with time zone") - .HasColumnName("unban_time"); - - b.Property("UnbanningAdmin") - .HasColumnType("uuid") - .HasColumnName("unbanning_admin"); - - b.HasKey("Id") - .HasName("PK_server_role_unban"); - - b.HasIndex("BanId") - .IsUnique(); - - b.ToTable("server_role_unban", (string)null); - }); - - modelBuilder.Entity("Content.Server.Database.ServerUnban", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("unban_id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("BanId") - .HasColumnType("integer") - .HasColumnName("ban_id"); - - b.Property("UnbanTime") - .HasColumnType("timestamp with time zone") - .HasColumnName("unban_time"); - - b.Property("UnbanningAdmin") - .HasColumnType("uuid") - .HasColumnName("unbanning_admin"); - - b.HasKey("Id") - .HasName("PK_server_unban"); - - b.HasIndex("BanId") - .IsUnique(); - - b.ToTable("server_unban", (string)null); - }); - modelBuilder.Entity("Content.Server.Database.Trait", b => { b.Property("Id") @@ -1375,6 +1356,36 @@ namespace Content.Server.Database.Migrations.Postgres b.ToTable("trait", (string)null); }); + modelBuilder.Entity("Content.Server.Database.Unban", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("unban_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BanId") + .HasColumnType("integer") + .HasColumnName("ban_id"); + + b.Property("UnbanTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("unban_time"); + + b.Property("UnbanningAdmin") + .HasColumnType("uuid") + .HasColumnName("unbanning_admin"); + + b.HasKey("Id") + .HasName("PK_unban"); + + b.HasIndex("BanId") + .IsUnique(); + + b.ToTable("unban", (string)null); + }); + modelBuilder.Entity("Content.Server.Database.UploadedResourceLog", b => { b.Property("Id") @@ -1659,6 +1670,123 @@ namespace Content.Server.Database.Migrations.Postgres b.Navigation("Profile"); }); + modelBuilder.Entity("Content.Server.Database.Ban", b => + { + b.HasOne("Content.Server.Database.Player", "CreatedBy") + .WithMany("AdminServerBansCreated") + .HasForeignKey("BanningAdmin") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_ban_player_banning_admin"); + + b.HasOne("Content.Server.Database.Player", "LastEditedBy") + .WithMany("AdminServerBansLastEdited") + .HasForeignKey("LastEditedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_ban_player_last_edited_by_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("LastEditedBy"); + }); + + modelBuilder.Entity("Content.Server.Database.BanAddress", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithMany("Addresses") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_address_ban_ban_id"); + + b.Navigation("Ban"); + }); + + modelBuilder.Entity("Content.Server.Database.BanHwid", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithMany("Hwids") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_hwid_ban_ban_id"); + + b.OwnsOne("Content.Server.Database.TypedHwid", "HWId", b1 => + { + b1.Property("BanHwidId") + .HasColumnType("integer") + .HasColumnName("ban_hwid_id"); + + b1.Property("Hwid") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("hwid"); + + b1.Property("Type") + .HasColumnType("integer") + .HasColumnName("hwid_type"); + + b1.HasKey("BanHwidId"); + + b1.ToTable("ban_hwid"); + + b1.WithOwner() + .HasForeignKey("BanHwidId") + .HasConstraintName("FK_ban_hwid_ban_hwid_ban_hwid_id"); + }); + + b.Navigation("Ban"); + + b.Navigation("HWId") + .IsRequired(); + }); + + modelBuilder.Entity("Content.Server.Database.BanPlayer", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithMany("Players") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_player_ban_ban_id"); + + b.Navigation("Ban"); + }); + + modelBuilder.Entity("Content.Server.Database.BanRole", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithMany("Roles") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_role_ban_ban_id"); + + b.Navigation("Ban"); + }); + + modelBuilder.Entity("Content.Server.Database.BanRound", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithMany("Rounds") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_round_ban_ban_id"); + + b.HasOne("Content.Server.Database.Round", "Round") + .WithMany() + .HasForeignKey("RoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_round_round_round_id"); + + b.Navigation("Ban"); + + b.Navigation("Round"); + }); + modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => { b.HasOne("Content.Server.Database.Server", "Server") @@ -1815,70 +1943,14 @@ namespace Content.Server.Database.Migrations.Postgres b.Navigation("Server"); }); - modelBuilder.Entity("Content.Server.Database.ServerBan", b => - { - b.HasOne("Content.Server.Database.Player", "CreatedBy") - .WithMany("AdminServerBansCreated") - .HasForeignKey("BanningAdmin") - .HasPrincipalKey("UserId") - .OnDelete(DeleteBehavior.SetNull) - .HasConstraintName("FK_server_ban_player_banning_admin"); - - b.HasOne("Content.Server.Database.Player", "LastEditedBy") - .WithMany("AdminServerBansLastEdited") - .HasForeignKey("LastEditedById") - .HasPrincipalKey("UserId") - .OnDelete(DeleteBehavior.SetNull) - .HasConstraintName("FK_server_ban_player_last_edited_by_id"); - - b.HasOne("Content.Server.Database.Round", "Round") - .WithMany() - .HasForeignKey("RoundId") - .HasConstraintName("FK_server_ban_round_round_id"); - - b.OwnsOne("Content.Server.Database.TypedHwid", "HWId", b1 => - { - b1.Property("ServerBanId") - .HasColumnType("integer") - .HasColumnName("server_ban_id"); - - b1.Property("Hwid") - .IsRequired() - .HasColumnType("bytea") - .HasColumnName("hwid"); - - b1.Property("Type") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasDefaultValue(0) - .HasColumnName("hwid_type"); - - b1.HasKey("ServerBanId"); - - b1.ToTable("server_ban"); - - b1.WithOwner() - .HasForeignKey("ServerBanId") - .HasConstraintName("FK_server_ban_server_ban_server_ban_id"); - }); - - b.Navigation("CreatedBy"); - - b.Navigation("HWId"); - - b.Navigation("LastEditedBy"); - - b.Navigation("Round"); - }); - modelBuilder.Entity("Content.Server.Database.ServerBanHit", b => { - b.HasOne("Content.Server.Database.ServerBan", "Ban") + b.HasOne("Content.Server.Database.Ban", "Ban") .WithMany("BanHits") .HasForeignKey("BanId") .OnDelete(DeleteBehavior.Cascade) .IsRequired() - .HasConstraintName("FK_server_ban_hit_server_ban_ban_id"); + .HasConstraintName("FK_server_ban_hit_ban_ban_id"); b.HasOne("Content.Server.Database.ConnectionLog", "Connection") .WithMany("BanHits") @@ -1892,86 +1964,6 @@ namespace Content.Server.Database.Migrations.Postgres b.Navigation("Connection"); }); - modelBuilder.Entity("Content.Server.Database.ServerRoleBan", b => - { - b.HasOne("Content.Server.Database.Player", "CreatedBy") - .WithMany("AdminServerRoleBansCreated") - .HasForeignKey("BanningAdmin") - .HasPrincipalKey("UserId") - .OnDelete(DeleteBehavior.SetNull) - .HasConstraintName("FK_server_role_ban_player_banning_admin"); - - b.HasOne("Content.Server.Database.Player", "LastEditedBy") - .WithMany("AdminServerRoleBansLastEdited") - .HasForeignKey("LastEditedById") - .HasPrincipalKey("UserId") - .OnDelete(DeleteBehavior.SetNull) - .HasConstraintName("FK_server_role_ban_player_last_edited_by_id"); - - b.HasOne("Content.Server.Database.Round", "Round") - .WithMany() - .HasForeignKey("RoundId") - .HasConstraintName("FK_server_role_ban_round_round_id"); - - b.OwnsOne("Content.Server.Database.TypedHwid", "HWId", b1 => - { - b1.Property("ServerRoleBanId") - .HasColumnType("integer") - .HasColumnName("server_role_ban_id"); - - b1.Property("Hwid") - .IsRequired() - .HasColumnType("bytea") - .HasColumnName("hwid"); - - b1.Property("Type") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasDefaultValue(0) - .HasColumnName("hwid_type"); - - b1.HasKey("ServerRoleBanId"); - - b1.ToTable("server_role_ban"); - - b1.WithOwner() - .HasForeignKey("ServerRoleBanId") - .HasConstraintName("FK_server_role_ban_server_role_ban_server_role_ban_id"); - }); - - b.Navigation("CreatedBy"); - - b.Navigation("HWId"); - - b.Navigation("LastEditedBy"); - - b.Navigation("Round"); - }); - - modelBuilder.Entity("Content.Server.Database.ServerRoleUnban", b => - { - b.HasOne("Content.Server.Database.ServerRoleBan", "Ban") - .WithOne("Unban") - .HasForeignKey("Content.Server.Database.ServerRoleUnban", "BanId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("FK_server_role_unban_server_role_ban_ban_id"); - - b.Navigation("Ban"); - }); - - modelBuilder.Entity("Content.Server.Database.ServerUnban", b => - { - b.HasOne("Content.Server.Database.ServerBan", "Ban") - .WithOne("Unban") - .HasForeignKey("Content.Server.Database.ServerUnban", "BanId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("FK_server_unban_server_ban_ban_id"); - - b.Navigation("Ban"); - }); - modelBuilder.Entity("Content.Server.Database.Trait", b => { b.HasOne("Content.Server.Database.Profile", "Profile") @@ -1984,6 +1976,18 @@ namespace Content.Server.Database.Migrations.Postgres b.Navigation("Profile"); }); + modelBuilder.Entity("Content.Server.Database.Unban", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithOne("Unban") + .HasForeignKey("Content.Server.Database.Unban", "BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_unban_ban_ban_id"); + + b.Navigation("Ban"); + }); + modelBuilder.Entity("PlayerRound", b => { b.HasOne("Content.Server.Database.Player", null) @@ -2018,6 +2022,23 @@ namespace Content.Server.Database.Migrations.Postgres b.Navigation("Flags"); }); + modelBuilder.Entity("Content.Server.Database.Ban", b => + { + b.Navigation("Addresses"); + + b.Navigation("BanHits"); + + b.Navigation("Hwids"); + + b.Navigation("Players"); + + b.Navigation("Roles"); + + b.Navigation("Rounds"); + + b.Navigation("Unban"); + }); + modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => { b.Navigation("BanHits"); @@ -2047,10 +2068,6 @@ namespace Content.Server.Database.Migrations.Postgres b.Navigation("AdminServerBansLastEdited"); - b.Navigation("AdminServerRoleBansCreated"); - - b.Navigation("AdminServerRoleBansLastEdited"); - b.Navigation("AdminWatchlistsCreated"); b.Navigation("AdminWatchlistsDeleted"); @@ -2099,18 +2116,6 @@ namespace Content.Server.Database.Migrations.Postgres b.Navigation("Rounds"); }); - - modelBuilder.Entity("Content.Server.Database.ServerBan", b => - { - b.Navigation("BanHits"); - - b.Navigation("Unban"); - }); - - modelBuilder.Entity("Content.Server.Database.ServerRoleBan", b => - { - b.Navigation("Unban"); - }); #pragma warning restore 612, 618 } } diff --git a/Content.Server.Database/Migrations/Sqlite/20260120200455_BanRefactor.Designer.cs b/Content.Server.Database/Migrations/Sqlite/20260120200455_BanRefactor.Designer.cs new file mode 100644 index 0000000000..804e3aae27 --- /dev/null +++ b/Content.Server.Database/Migrations/Sqlite/20260120200455_BanRefactor.Designer.cs @@ -0,0 +1,2044 @@ +// +using System; +using Content.Server.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Content.Server.Database.Migrations.Sqlite +{ + [DbContext(typeof(SqliteServerDbContext))] + [Migration("20260120200455_BanRefactor")] + partial class BanRefactor + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + + modelBuilder.Entity("Content.Server.Database.Admin", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("AdminRankId") + .HasColumnType("INTEGER") + .HasColumnName("admin_rank_id"); + + b.Property("Deadminned") + .HasColumnType("INTEGER") + .HasColumnName("deadminned"); + + b.Property("Suspended") + .HasColumnType("INTEGER") + .HasColumnName("suspended"); + + b.Property("Title") + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("UserId") + .HasName("PK_admin"); + + b.HasIndex("AdminRankId") + .HasDatabaseName("IX_admin_admin_rank_id"); + + b.ToTable("admin", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("admin_flag_id"); + + b.Property("AdminId") + .HasColumnType("TEXT") + .HasColumnName("admin_id"); + + b.Property("Flag") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("flag"); + + b.Property("Negative") + .HasColumnType("INTEGER") + .HasColumnName("negative"); + + b.HasKey("Id") + .HasName("PK_admin_flag"); + + b.HasIndex("AdminId") + .HasDatabaseName("IX_admin_flag_admin_id"); + + b.HasIndex("Flag", "AdminId") + .IsUnique(); + + b.ToTable("admin_flag", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLog", b => + { + b.Property("RoundId") + .HasColumnType("INTEGER") + .HasColumnName("round_id"); + + b.Property("Id") + .HasColumnType("INTEGER") + .HasColumnName("admin_log_id"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("date"); + + b.Property("Impact") + .HasColumnType("INTEGER") + .HasColumnName("impact"); + + b.Property("Json") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("json"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("message"); + + b.Property("Type") + .HasColumnType("INTEGER") + .HasColumnName("type"); + + b.HasKey("RoundId", "Id") + .HasName("PK_admin_log"); + + b.HasIndex("Date"); + + b.HasIndex("Type") + .HasDatabaseName("IX_admin_log_type"); + + b.ToTable("admin_log", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLogPlayer", b => + { + b.Property("RoundId") + .HasColumnType("INTEGER") + .HasColumnName("round_id"); + + b.Property("LogId") + .HasColumnType("INTEGER") + .HasColumnName("log_id"); + + b.Property("PlayerUserId") + .HasColumnType("TEXT") + .HasColumnName("player_user_id"); + + b.HasKey("RoundId", "LogId", "PlayerUserId") + .HasName("PK_admin_log_player"); + + b.HasIndex("PlayerUserId") + .HasDatabaseName("IX_admin_log_player_player_user_id"); + + b.ToTable("admin_log_player", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("admin_messages_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("TEXT") + .HasColumnName("created_by_id"); + + b.Property("Deleted") + .HasColumnType("INTEGER") + .HasColumnName("deleted"); + + b.Property("DeletedAt") + .HasColumnType("TEXT") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("TEXT") + .HasColumnName("deleted_by_id"); + + b.Property("Dismissed") + .HasColumnType("INTEGER") + .HasColumnName("dismissed"); + + b.Property("ExpirationTime") + .HasColumnType("TEXT") + .HasColumnName("expiration_time"); + + b.Property("LastEditedAt") + .HasColumnType("TEXT") + .HasColumnName("last_edited_at"); + + b.Property("LastEditedById") + .HasColumnType("TEXT") + .HasColumnName("last_edited_by_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT") + .HasColumnName("message"); + + b.Property("PlayerUserId") + .HasColumnType("TEXT") + .HasColumnName("player_user_id"); + + b.Property("PlaytimeAtNote") + .HasColumnType("TEXT") + .HasColumnName("playtime_at_note"); + + b.Property("RoundId") + .HasColumnType("INTEGER") + .HasColumnName("round_id"); + + b.Property("Seen") + .HasColumnType("INTEGER") + .HasColumnName("seen"); + + b.HasKey("Id") + .HasName("PK_admin_messages"); + + b.HasIndex("CreatedById"); + + b.HasIndex("DeletedById"); + + b.HasIndex("LastEditedById"); + + b.HasIndex("PlayerUserId") + .HasDatabaseName("IX_admin_messages_player_user_id"); + + b.HasIndex("RoundId") + .HasDatabaseName("IX_admin_messages_round_id"); + + b.ToTable("admin_messages", null, t => + { + t.HasCheckConstraint("NotDismissedAndSeen", "NOT dismissed OR seen"); + }); + }); + + modelBuilder.Entity("Content.Server.Database.AdminNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("admin_notes_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("TEXT") + .HasColumnName("created_by_id"); + + b.Property("Deleted") + .HasColumnType("INTEGER") + .HasColumnName("deleted"); + + b.Property("DeletedAt") + .HasColumnType("TEXT") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("TEXT") + .HasColumnName("deleted_by_id"); + + b.Property("ExpirationTime") + .HasColumnType("TEXT") + .HasColumnName("expiration_time"); + + b.Property("LastEditedAt") + .HasColumnType("TEXT") + .HasColumnName("last_edited_at"); + + b.Property("LastEditedById") + .HasColumnType("TEXT") + .HasColumnName("last_edited_by_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT") + .HasColumnName("message"); + + b.Property("PlayerUserId") + .HasColumnType("TEXT") + .HasColumnName("player_user_id"); + + b.Property("PlaytimeAtNote") + .HasColumnType("TEXT") + .HasColumnName("playtime_at_note"); + + b.Property("RoundId") + .HasColumnType("INTEGER") + .HasColumnName("round_id"); + + b.Property("Secret") + .HasColumnType("INTEGER") + .HasColumnName("secret"); + + b.Property("Severity") + .HasColumnType("INTEGER") + .HasColumnName("severity"); + + b.HasKey("Id") + .HasName("PK_admin_notes"); + + b.HasIndex("CreatedById"); + + b.HasIndex("DeletedById"); + + b.HasIndex("LastEditedById"); + + b.HasIndex("PlayerUserId") + .HasDatabaseName("IX_admin_notes_player_user_id"); + + b.HasIndex("RoundId") + .HasDatabaseName("IX_admin_notes_round_id"); + + b.ToTable("admin_notes", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminRank", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("admin_rank_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("PK_admin_rank"); + + b.ToTable("admin_rank", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminRankFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("admin_rank_flag_id"); + + b.Property("AdminRankId") + .HasColumnType("INTEGER") + .HasColumnName("admin_rank_id"); + + b.Property("Flag") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("flag"); + + b.HasKey("Id") + .HasName("PK_admin_rank_flag"); + + b.HasIndex("AdminRankId"); + + b.HasIndex("Flag", "AdminRankId") + .IsUnique(); + + b.ToTable("admin_rank_flag", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminWatchlist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("admin_watchlists_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("TEXT") + .HasColumnName("created_by_id"); + + b.Property("Deleted") + .HasColumnType("INTEGER") + .HasColumnName("deleted"); + + b.Property("DeletedAt") + .HasColumnType("TEXT") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("TEXT") + .HasColumnName("deleted_by_id"); + + b.Property("ExpirationTime") + .HasColumnType("TEXT") + .HasColumnName("expiration_time"); + + b.Property("LastEditedAt") + .HasColumnType("TEXT") + .HasColumnName("last_edited_at"); + + b.Property("LastEditedById") + .HasColumnType("TEXT") + .HasColumnName("last_edited_by_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT") + .HasColumnName("message"); + + b.Property("PlayerUserId") + .HasColumnType("TEXT") + .HasColumnName("player_user_id"); + + b.Property("PlaytimeAtNote") + .HasColumnType("TEXT") + .HasColumnName("playtime_at_note"); + + b.Property("RoundId") + .HasColumnType("INTEGER") + .HasColumnName("round_id"); + + b.HasKey("Id") + .HasName("PK_admin_watchlists"); + + b.HasIndex("CreatedById"); + + b.HasIndex("DeletedById"); + + b.HasIndex("LastEditedById"); + + b.HasIndex("PlayerUserId") + .HasDatabaseName("IX_admin_watchlists_player_user_id"); + + b.HasIndex("RoundId") + .HasDatabaseName("IX_admin_watchlists_round_id"); + + b.ToTable("admin_watchlists", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Antag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("antag_id"); + + b.Property("AntagName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("antag_name"); + + b.Property("ProfileId") + .HasColumnType("INTEGER") + .HasColumnName("profile_id"); + + b.HasKey("Id") + .HasName("PK_antag"); + + b.HasIndex("ProfileId", "AntagName") + .IsUnique(); + + b.ToTable("antag", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AssignedUserId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("assigned_user_id_id"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("PK_assigned_user_id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("assigned_user_id", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Ban", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("ban_id"); + + b.Property("AutoDelete") + .HasColumnType("INTEGER") + .HasColumnName("auto_delete"); + + b.Property("BanTime") + .HasColumnType("TEXT") + .HasColumnName("ban_time"); + + b.Property("BanningAdmin") + .HasColumnType("TEXT") + .HasColumnName("banning_admin"); + + b.Property("ExemptFlags") + .HasColumnType("INTEGER") + .HasColumnName("exempt_flags"); + + b.Property("ExpirationTime") + .HasColumnType("TEXT") + .HasColumnName("expiration_time"); + + b.Property("Hidden") + .HasColumnType("INTEGER") + .HasColumnName("hidden"); + + b.Property("LastEditedAt") + .HasColumnType("TEXT") + .HasColumnName("last_edited_at"); + + b.Property("LastEditedById") + .HasColumnType("TEXT") + .HasColumnName("last_edited_by_id"); + + b.Property("PlaytimeAtNote") + .HasColumnType("TEXT") + .HasColumnName("playtime_at_note"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("reason"); + + b.Property("Severity") + .HasColumnType("INTEGER") + .HasColumnName("severity"); + + b.Property("Type") + .HasColumnType("INTEGER") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("PK_ban"); + + b.HasIndex("BanningAdmin"); + + b.HasIndex("LastEditedById"); + + b.ToTable("ban", null, t => + { + t.HasCheckConstraint("NoExemptOnRoleBan", "type = 0 OR exempt_flags = 0"); + }); + }); + + modelBuilder.Entity("Content.Server.Database.BanAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("ban_address_id"); + + b.Property("Address") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("address"); + + b.Property("BanId") + .HasColumnType("INTEGER") + .HasColumnName("ban_id"); + + b.HasKey("Id") + .HasName("PK_ban_address"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_ban_address_ban_id"); + + b.ToTable("ban_address", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.BanHwid", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("ban_hwid_id"); + + b.Property("BanId") + .HasColumnType("INTEGER") + .HasColumnName("ban_id"); + + b.HasKey("Id") + .HasName("PK_ban_hwid"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_ban_hwid_ban_id"); + + b.ToTable("ban_hwid", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.BanPlayer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("ban_player_id"); + + b.Property("BanId") + .HasColumnType("INTEGER") + .HasColumnName("ban_id"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_ban_player"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_ban_player_ban_id"); + + b.HasIndex("UserId", "BanId") + .IsUnique(); + + b.ToTable("ban_player", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.BanRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("ban_role_id"); + + b.Property("BanId") + .HasColumnType("INTEGER") + .HasColumnName("ban_id"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("role_id"); + + b.Property("RoleType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("role_type"); + + b.HasKey("Id") + .HasName("PK_ban_role"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_ban_role_ban_id"); + + b.HasIndex("RoleType", "RoleId", "BanId") + .IsUnique(); + + b.ToTable("ban_role", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.BanRound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("ban_round_id"); + + b.Property("BanId") + .HasColumnType("INTEGER") + .HasColumnName("ban_id"); + + b.Property("RoundId") + .HasColumnType("INTEGER") + .HasColumnName("round_id"); + + b.HasKey("Id") + .HasName("PK_ban_round"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_ban_round_ban_id"); + + b.HasIndex("RoundId", "BanId") + .IsUnique(); + + b.ToTable("ban_round", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.BanTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("ban_template_id"); + + b.Property("AutoDelete") + .HasColumnType("INTEGER") + .HasColumnName("auto_delete"); + + b.Property("ExemptFlags") + .HasColumnType("INTEGER") + .HasColumnName("exempt_flags"); + + b.Property("Hidden") + .HasColumnType("INTEGER") + .HasColumnName("hidden"); + + b.Property("Length") + .HasColumnType("TEXT") + .HasColumnName("length"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("reason"); + + b.Property("Severity") + .HasColumnType("INTEGER") + .HasColumnName("severity"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("PK_ban_template"); + + b.ToTable("ban_template", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Blacklist", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("UserId") + .HasName("PK_blacklist"); + + b.ToTable("blacklist", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("connection_log_id"); + + b.Property("Address") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("address"); + + b.Property("Denied") + .HasColumnType("INTEGER") + .HasColumnName("denied"); + + b.Property("ServerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("server_id"); + + b.Property("Time") + .HasColumnType("TEXT") + .HasColumnName("time"); + + b.Property("Trust") + .HasColumnType("REAL") + .HasColumnName("trust"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("PK_connection_log"); + + b.HasIndex("ServerId") + .HasDatabaseName("IX_connection_log_server_id"); + + b.HasIndex("Time"); + + b.HasIndex("UserId"); + + b.ToTable("connection_log", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.IPIntelCache", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("ipintel_cache_id"); + + b.Property("Address") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("address"); + + b.Property("Score") + .HasColumnType("REAL") + .HasColumnName("score"); + + b.Property("Time") + .HasColumnType("TEXT") + .HasColumnName("time"); + + b.HasKey("Id") + .HasName("PK_ipintel_cache"); + + b.HasIndex("Address") + .IsUnique(); + + b.ToTable("ipintel_cache", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("job_id"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("ProfileId") + .HasColumnType("INTEGER") + .HasColumnName("profile_id"); + + b.HasKey("Id") + .HasName("PK_job"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "JobName") + .IsUnique(); + + b.HasIndex(new[] { "ProfileId" }, "IX_job_one_high_priority") + .IsUnique() + .HasFilter("priority = 3"); + + b.ToTable("job", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.PlayTime", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("play_time_id"); + + b.Property("PlayerId") + .HasColumnType("TEXT") + .HasColumnName("player_id"); + + b.Property("TimeSpent") + .HasColumnType("TEXT") + .HasColumnName("time_spent"); + + b.Property("Tracker") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker"); + + b.HasKey("Id") + .HasName("PK_play_time"); + + b.HasIndex("PlayerId", "Tracker") + .IsUnique(); + + b.ToTable("play_time", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("player_id"); + + b.Property("FirstSeenTime") + .HasColumnType("TEXT") + .HasColumnName("first_seen_time"); + + b.Property("LastReadRules") + .HasColumnType("TEXT") + .HasColumnName("last_read_rules"); + + b.Property("LastSeenAddress") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_seen_address"); + + b.Property("LastSeenTime") + .HasColumnType("TEXT") + .HasColumnName("last_seen_time"); + + b.Property("LastSeenUserName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_seen_user_name"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_player"); + + b.HasAlternateKey("UserId") + .HasName("ak_player_user_id"); + + b.HasIndex("LastSeenUserName"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("player", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("preference_id"); + + b.Property("AdminOOCColor") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("admin_ooc_color"); + + b.PrimitiveCollection("ConstructionFavorites") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("construction_favorites"); + + b.Property("SelectedCharacterSlot") + .HasColumnType("INTEGER") + .HasColumnName("selected_character_slot"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_preference"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("preference", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Profile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("profile_id"); + + b.Property("Age") + .HasColumnType("INTEGER") + .HasColumnName("age"); + + b.Property("CharacterName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("char_name"); + + b.Property("EyeColor") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("eye_color"); + + b.Property("FacialHairColor") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("facial_hair_color"); + + b.Property("FacialHairName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("facial_hair_name"); + + b.Property("FlavorText") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("flavor_text"); + + b.Property("Gender") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("gender"); + + b.Property("HairColor") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("hair_color"); + + b.Property("HairName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("hair_name"); + + b.Property("Markings") + .HasColumnType("jsonb") + .HasColumnName("markings"); + + b.Property("PreferenceId") + .HasColumnType("INTEGER") + .HasColumnName("preference_id"); + + b.Property("PreferenceUnavailable") + .HasColumnType("INTEGER") + .HasColumnName("pref_unavailable"); + + b.Property("Sex") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sex"); + + b.Property("SkinColor") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("skin_color"); + + b.Property("Slot") + .HasColumnType("INTEGER") + .HasColumnName("slot"); + + b.Property("SpawnPriority") + .HasColumnType("INTEGER") + .HasColumnName("spawn_priority"); + + b.Property("Species") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("species"); + + b.HasKey("Id") + .HasName("PK_profile"); + + b.HasIndex("PreferenceId") + .HasDatabaseName("IX_profile_preference_id"); + + b.HasIndex("Slot", "PreferenceId") + .IsUnique(); + + b.ToTable("profile", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileLoadout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("profile_loadout_id"); + + b.Property("LoadoutName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("loadout_name"); + + b.Property("ProfileLoadoutGroupId") + .HasColumnType("INTEGER") + .HasColumnName("profile_loadout_group_id"); + + b.HasKey("Id") + .HasName("PK_profile_loadout"); + + b.HasIndex("ProfileLoadoutGroupId"); + + b.ToTable("profile_loadout", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileLoadoutGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("profile_loadout_group_id"); + + b.Property("GroupName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("group_name"); + + b.Property("ProfileRoleLoadoutId") + .HasColumnType("INTEGER") + .HasColumnName("profile_role_loadout_id"); + + b.HasKey("Id") + .HasName("PK_profile_loadout_group"); + + b.HasIndex("ProfileRoleLoadoutId"); + + b.ToTable("profile_loadout_group", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileRoleLoadout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("profile_role_loadout_id"); + + b.Property("EntityName") + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("entity_name"); + + b.Property("ProfileId") + .HasColumnType("INTEGER") + .HasColumnName("profile_id"); + + b.Property("RoleName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("role_name"); + + b.HasKey("Id") + .HasName("PK_profile_role_loadout"); + + b.HasIndex("ProfileId"); + + b.ToTable("profile_role_loadout", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.RoleWhitelist", b => + { + b.Property("PlayerUserId") + .HasColumnType("TEXT") + .HasColumnName("player_user_id"); + + b.Property("RoleId") + .HasColumnType("TEXT") + .HasColumnName("role_id"); + + b.HasKey("PlayerUserId", "RoleId") + .HasName("PK_role_whitelists"); + + b.ToTable("role_whitelists", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Round", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("round_id"); + + b.Property("ServerId") + .HasColumnType("INTEGER") + .HasColumnName("server_id"); + + b.Property("StartDate") + .HasColumnType("TEXT") + .HasColumnName("start_date"); + + b.HasKey("Id") + .HasName("PK_round"); + + b.HasIndex("ServerId") + .HasDatabaseName("IX_round_server_id"); + + b.HasIndex("StartDate"); + + b.ToTable("round", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Server", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("server_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("PK_server"); + + b.ToTable("server", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ServerBanExemption", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("Flags") + .HasColumnType("INTEGER") + .HasColumnName("flags"); + + b.HasKey("UserId") + .HasName("PK_server_ban_exemption"); + + b.ToTable("server_ban_exemption", null, t => + { + t.HasCheckConstraint("FlagsNotZero", "flags != 0"); + }); + }); + + modelBuilder.Entity("Content.Server.Database.ServerBanHit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("server_ban_hit_id"); + + b.Property("BanId") + .HasColumnType("INTEGER") + .HasColumnName("ban_id"); + + b.Property("ConnectionId") + .HasColumnType("INTEGER") + .HasColumnName("connection_id"); + + b.HasKey("Id") + .HasName("PK_server_ban_hit"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_server_ban_hit_ban_id"); + + b.HasIndex("ConnectionId") + .HasDatabaseName("IX_server_ban_hit_connection_id"); + + b.ToTable("server_ban_hit", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Trait", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("trait_id"); + + b.Property("ProfileId") + .HasColumnType("INTEGER") + .HasColumnName("profile_id"); + + b.Property("TraitName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("trait_name"); + + b.HasKey("Id") + .HasName("PK_trait"); + + b.HasIndex("ProfileId", "TraitName") + .IsUnique(); + + b.ToTable("trait", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Unban", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("unban_id"); + + b.Property("BanId") + .HasColumnType("INTEGER") + .HasColumnName("ban_id"); + + b.Property("UnbanTime") + .HasColumnType("TEXT") + .HasColumnName("unban_time"); + + b.Property("UnbanningAdmin") + .HasColumnType("TEXT") + .HasColumnName("unbanning_admin"); + + b.HasKey("Id") + .HasName("PK_unban"); + + b.HasIndex("BanId") + .IsUnique(); + + b.ToTable("unban", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.UploadedResourceLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("uploaded_resource_log_id"); + + b.Property("Data") + .IsRequired() + .HasColumnType("BLOB") + .HasColumnName("data"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("date"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_uploaded_resource_log"); + + b.ToTable("uploaded_resource_log", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Whitelist", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("UserId") + .HasName("PK_whitelist"); + + b.ToTable("whitelist", (string)null); + }); + + modelBuilder.Entity("PlayerRound", b => + { + b.Property("PlayersId") + .HasColumnType("INTEGER") + .HasColumnName("players_id"); + + b.Property("RoundsId") + .HasColumnType("INTEGER") + .HasColumnName("rounds_id"); + + b.HasKey("PlayersId", "RoundsId") + .HasName("PK_player_round"); + + b.HasIndex("RoundsId") + .HasDatabaseName("IX_player_round_rounds_id"); + + b.ToTable("player_round", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Admin", b => + { + b.HasOne("Content.Server.Database.AdminRank", "AdminRank") + .WithMany("Admins") + .HasForeignKey("AdminRankId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_admin_rank_admin_rank_id"); + + b.Navigation("AdminRank"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminFlag", b => + { + b.HasOne("Content.Server.Database.Admin", "Admin") + .WithMany("Flags") + .HasForeignKey("AdminId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_flag_admin_admin_id"); + + b.Navigation("Admin"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLog", b => + { + b.HasOne("Content.Server.Database.Round", "Round") + .WithMany("AdminLogs") + .HasForeignKey("RoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_log_round_round_id"); + + b.Navigation("Round"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLogPlayer", b => + { + b.HasOne("Content.Server.Database.Player", "Player") + .WithMany("AdminLogs") + .HasForeignKey("PlayerUserId") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_log_player_player_player_user_id"); + + b.HasOne("Content.Server.Database.AdminLog", "Log") + .WithMany("Players") + .HasForeignKey("RoundId", "LogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_log_player_admin_log_round_id_log_id"); + + b.Navigation("Log"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminMessage", b => + { + b.HasOne("Content.Server.Database.Player", "CreatedBy") + .WithMany("AdminMessagesCreated") + .HasForeignKey("CreatedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_messages_player_created_by_id"); + + b.HasOne("Content.Server.Database.Player", "DeletedBy") + .WithMany("AdminMessagesDeleted") + .HasForeignKey("DeletedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_messages_player_deleted_by_id"); + + b.HasOne("Content.Server.Database.Player", "LastEditedBy") + .WithMany("AdminMessagesLastEdited") + .HasForeignKey("LastEditedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_messages_player_last_edited_by_id"); + + b.HasOne("Content.Server.Database.Player", "Player") + .WithMany("AdminMessagesReceived") + .HasForeignKey("PlayerUserId") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_admin_messages_player_player_user_id"); + + b.HasOne("Content.Server.Database.Round", "Round") + .WithMany() + .HasForeignKey("RoundId") + .HasConstraintName("FK_admin_messages_round_round_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("LastEditedBy"); + + b.Navigation("Player"); + + b.Navigation("Round"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminNote", b => + { + b.HasOne("Content.Server.Database.Player", "CreatedBy") + .WithMany("AdminNotesCreated") + .HasForeignKey("CreatedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_notes_player_created_by_id"); + + b.HasOne("Content.Server.Database.Player", "DeletedBy") + .WithMany("AdminNotesDeleted") + .HasForeignKey("DeletedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_notes_player_deleted_by_id"); + + b.HasOne("Content.Server.Database.Player", "LastEditedBy") + .WithMany("AdminNotesLastEdited") + .HasForeignKey("LastEditedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_notes_player_last_edited_by_id"); + + b.HasOne("Content.Server.Database.Player", "Player") + .WithMany("AdminNotesReceived") + .HasForeignKey("PlayerUserId") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_admin_notes_player_player_user_id"); + + b.HasOne("Content.Server.Database.Round", "Round") + .WithMany() + .HasForeignKey("RoundId") + .HasConstraintName("FK_admin_notes_round_round_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("LastEditedBy"); + + b.Navigation("Player"); + + b.Navigation("Round"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminRankFlag", b => + { + b.HasOne("Content.Server.Database.AdminRank", "Rank") + .WithMany("Flags") + .HasForeignKey("AdminRankId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_rank_flag_admin_rank_admin_rank_id"); + + b.Navigation("Rank"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminWatchlist", b => + { + b.HasOne("Content.Server.Database.Player", "CreatedBy") + .WithMany("AdminWatchlistsCreated") + .HasForeignKey("CreatedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_watchlists_player_created_by_id"); + + b.HasOne("Content.Server.Database.Player", "DeletedBy") + .WithMany("AdminWatchlistsDeleted") + .HasForeignKey("DeletedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_watchlists_player_deleted_by_id"); + + b.HasOne("Content.Server.Database.Player", "LastEditedBy") + .WithMany("AdminWatchlistsLastEdited") + .HasForeignKey("LastEditedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_watchlists_player_last_edited_by_id"); + + b.HasOne("Content.Server.Database.Player", "Player") + .WithMany("AdminWatchlistsReceived") + .HasForeignKey("PlayerUserId") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_admin_watchlists_player_player_user_id"); + + b.HasOne("Content.Server.Database.Round", "Round") + .WithMany() + .HasForeignKey("RoundId") + .HasConstraintName("FK_admin_watchlists_round_round_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("LastEditedBy"); + + b.Navigation("Player"); + + b.Navigation("Round"); + }); + + modelBuilder.Entity("Content.Server.Database.Antag", b => + { + b.HasOne("Content.Server.Database.Profile", "Profile") + .WithMany("Antags") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_antag_profile_profile_id"); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Content.Server.Database.Ban", b => + { + b.HasOne("Content.Server.Database.Player", "CreatedBy") + .WithMany("AdminServerBansCreated") + .HasForeignKey("BanningAdmin") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_ban_player_banning_admin"); + + b.HasOne("Content.Server.Database.Player", "LastEditedBy") + .WithMany("AdminServerBansLastEdited") + .HasForeignKey("LastEditedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_ban_player_last_edited_by_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("LastEditedBy"); + }); + + modelBuilder.Entity("Content.Server.Database.BanAddress", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithMany("Addresses") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_address_ban_ban_id"); + + b.Navigation("Ban"); + }); + + modelBuilder.Entity("Content.Server.Database.BanHwid", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithMany("Hwids") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_hwid_ban_ban_id"); + + b.OwnsOne("Content.Server.Database.TypedHwid", "HWId", b1 => + { + b1.Property("BanHwidId") + .HasColumnType("INTEGER") + .HasColumnName("ban_hwid_id"); + + b1.Property("Hwid") + .IsRequired() + .HasColumnType("BLOB") + .HasColumnName("hwid"); + + b1.Property("Type") + .HasColumnType("INTEGER") + .HasColumnName("hwid_type"); + + b1.HasKey("BanHwidId"); + + b1.ToTable("ban_hwid"); + + b1.WithOwner() + .HasForeignKey("BanHwidId") + .HasConstraintName("FK_ban_hwid_ban_hwid_ban_hwid_id"); + }); + + b.Navigation("Ban"); + + b.Navigation("HWId") + .IsRequired(); + }); + + modelBuilder.Entity("Content.Server.Database.BanPlayer", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithMany("Players") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_player_ban_ban_id"); + + b.Navigation("Ban"); + }); + + modelBuilder.Entity("Content.Server.Database.BanRole", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithMany("Roles") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_role_ban_ban_id"); + + b.Navigation("Ban"); + }); + + modelBuilder.Entity("Content.Server.Database.BanRound", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithMany("Rounds") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_round_ban_ban_id"); + + b.HasOne("Content.Server.Database.Round", "Round") + .WithMany() + .HasForeignKey("RoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_round_round_round_id"); + + b.Navigation("Ban"); + + b.Navigation("Round"); + }); + + modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => + { + b.HasOne("Content.Server.Database.Server", "Server") + .WithMany("ConnectionLogs") + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired() + .HasConstraintName("FK_connection_log_server_server_id"); + + b.OwnsOne("Content.Server.Database.TypedHwid", "HWId", b1 => + { + b1.Property("ConnectionLogId") + .HasColumnType("INTEGER") + .HasColumnName("connection_log_id"); + + b1.Property("Hwid") + .IsRequired() + .HasColumnType("BLOB") + .HasColumnName("hwid"); + + b1.Property("Type") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("hwid_type"); + + b1.HasKey("ConnectionLogId"); + + b1.ToTable("connection_log"); + + b1.WithOwner() + .HasForeignKey("ConnectionLogId") + .HasConstraintName("FK_connection_log_connection_log_connection_log_id"); + }); + + b.Navigation("HWId"); + + b.Navigation("Server"); + }); + + modelBuilder.Entity("Content.Server.Database.Job", b => + { + b.HasOne("Content.Server.Database.Profile", "Profile") + .WithMany("Jobs") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_job_profile_profile_id"); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Content.Server.Database.Player", b => + { + b.OwnsOne("Content.Server.Database.TypedHwid", "LastSeenHWId", b1 => + { + b1.Property("PlayerId") + .HasColumnType("INTEGER") + .HasColumnName("player_id"); + + b1.Property("Hwid") + .IsRequired() + .HasColumnType("BLOB") + .HasColumnName("last_seen_hwid"); + + b1.Property("Type") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("last_seen_hwid_type"); + + b1.HasKey("PlayerId"); + + b1.ToTable("player"); + + b1.WithOwner() + .HasForeignKey("PlayerId") + .HasConstraintName("FK_player_player_player_id"); + }); + + b.Navigation("LastSeenHWId"); + }); + + modelBuilder.Entity("Content.Server.Database.Profile", b => + { + b.HasOne("Content.Server.Database.Preference", "Preference") + .WithMany("Profiles") + .HasForeignKey("PreferenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_profile_preference_preference_id"); + + b.Navigation("Preference"); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileLoadout", b => + { + b.HasOne("Content.Server.Database.ProfileLoadoutGroup", "ProfileLoadoutGroup") + .WithMany("Loadouts") + .HasForeignKey("ProfileLoadoutGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_profile_loadout_profile_loadout_group_profile_loadout_group_id"); + + b.Navigation("ProfileLoadoutGroup"); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileLoadoutGroup", b => + { + b.HasOne("Content.Server.Database.ProfileRoleLoadout", "ProfileRoleLoadout") + .WithMany("Groups") + .HasForeignKey("ProfileRoleLoadoutId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_profile_loadout_group_profile_role_loadout_profile_role_loadout_id"); + + b.Navigation("ProfileRoleLoadout"); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileRoleLoadout", b => + { + b.HasOne("Content.Server.Database.Profile", "Profile") + .WithMany("Loadouts") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_profile_role_loadout_profile_profile_id"); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Content.Server.Database.RoleWhitelist", b => + { + b.HasOne("Content.Server.Database.Player", "Player") + .WithMany("JobWhitelists") + .HasForeignKey("PlayerUserId") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_role_whitelists_player_player_user_id"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("Content.Server.Database.Round", b => + { + b.HasOne("Content.Server.Database.Server", "Server") + .WithMany("Rounds") + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_round_server_server_id"); + + b.Navigation("Server"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerBanHit", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithMany("BanHits") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_server_ban_hit_ban_ban_id"); + + b.HasOne("Content.Server.Database.ConnectionLog", "Connection") + .WithMany("BanHits") + .HasForeignKey("ConnectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_server_ban_hit_connection_log_connection_id"); + + b.Navigation("Ban"); + + b.Navigation("Connection"); + }); + + modelBuilder.Entity("Content.Server.Database.Trait", b => + { + b.HasOne("Content.Server.Database.Profile", "Profile") + .WithMany("Traits") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_trait_profile_profile_id"); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Content.Server.Database.Unban", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithOne("Unban") + .HasForeignKey("Content.Server.Database.Unban", "BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_unban_ban_ban_id"); + + b.Navigation("Ban"); + }); + + modelBuilder.Entity("PlayerRound", b => + { + b.HasOne("Content.Server.Database.Player", null) + .WithMany() + .HasForeignKey("PlayersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_player_round_player_players_id"); + + b.HasOne("Content.Server.Database.Round", null) + .WithMany() + .HasForeignKey("RoundsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_player_round_round_rounds_id"); + }); + + modelBuilder.Entity("Content.Server.Database.Admin", b => + { + b.Navigation("Flags"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLog", b => + { + b.Navigation("Players"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminRank", b => + { + b.Navigation("Admins"); + + b.Navigation("Flags"); + }); + + modelBuilder.Entity("Content.Server.Database.Ban", b => + { + b.Navigation("Addresses"); + + b.Navigation("BanHits"); + + b.Navigation("Hwids"); + + b.Navigation("Players"); + + b.Navigation("Roles"); + + b.Navigation("Rounds"); + + b.Navigation("Unban"); + }); + + modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => + { + b.Navigation("BanHits"); + }); + + modelBuilder.Entity("Content.Server.Database.Player", b => + { + b.Navigation("AdminLogs"); + + b.Navigation("AdminMessagesCreated"); + + b.Navigation("AdminMessagesDeleted"); + + b.Navigation("AdminMessagesLastEdited"); + + b.Navigation("AdminMessagesReceived"); + + b.Navigation("AdminNotesCreated"); + + b.Navigation("AdminNotesDeleted"); + + b.Navigation("AdminNotesLastEdited"); + + b.Navigation("AdminNotesReceived"); + + b.Navigation("AdminServerBansCreated"); + + b.Navigation("AdminServerBansLastEdited"); + + b.Navigation("AdminWatchlistsCreated"); + + b.Navigation("AdminWatchlistsDeleted"); + + b.Navigation("AdminWatchlistsLastEdited"); + + b.Navigation("AdminWatchlistsReceived"); + + b.Navigation("JobWhitelists"); + }); + + modelBuilder.Entity("Content.Server.Database.Preference", b => + { + b.Navigation("Profiles"); + }); + + modelBuilder.Entity("Content.Server.Database.Profile", b => + { + b.Navigation("Antags"); + + b.Navigation("Jobs"); + + b.Navigation("Loadouts"); + + b.Navigation("Traits"); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileLoadoutGroup", b => + { + b.Navigation("Loadouts"); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileRoleLoadout", b => + { + b.Navigation("Groups"); + }); + + modelBuilder.Entity("Content.Server.Database.Round", b => + { + b.Navigation("AdminLogs"); + }); + + modelBuilder.Entity("Content.Server.Database.Server", b => + { + b.Navigation("ConnectionLogs"); + + b.Navigation("Rounds"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Content.Server.Database/Migrations/Sqlite/20260120200455_BanRefactor.cs b/Content.Server.Database/Migrations/Sqlite/20260120200455_BanRefactor.cs new file mode 100644 index 0000000000..f813d10945 --- /dev/null +++ b/Content.Server.Database/Migrations/Sqlite/20260120200455_BanRefactor.cs @@ -0,0 +1,498 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Content.Server.Database.Migrations.Sqlite +{ + /// + public partial class BanRefactor : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ban", + columns: table => new + { + ban_id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + type = table.Column(type: "INTEGER", nullable: false), + playtime_at_note = table.Column(type: "TEXT", nullable: false), + ban_time = table.Column(type: "TEXT", nullable: false), + expiration_time = table.Column(type: "TEXT", nullable: true), + reason = table.Column(type: "TEXT", nullable: false), + severity = table.Column(type: "INTEGER", nullable: false), + banning_admin = table.Column(type: "TEXT", nullable: true), + last_edited_by_id = table.Column(type: "TEXT", nullable: true), + last_edited_at = table.Column(type: "TEXT", nullable: true), + exempt_flags = table.Column(type: "INTEGER", nullable: false), + auto_delete = table.Column(type: "INTEGER", nullable: false), + hidden = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ban", x => x.ban_id); + table.CheckConstraint("NoExemptOnRoleBan", "type = 0 OR exempt_flags = 0"); + table.ForeignKey( + name: "FK_ban_player_banning_admin", + column: x => x.banning_admin, + principalTable: "player", + principalColumn: "user_id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_ban_player_last_edited_by_id", + column: x => x.last_edited_by_id, + principalTable: "player", + principalColumn: "user_id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "ban_address", + columns: table => new + { + ban_address_id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + address = table.Column(type: "TEXT", nullable: false), + ban_id = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ban_address", x => x.ban_address_id); + table.ForeignKey( + name: "FK_ban_address_ban_ban_id", + column: x => x.ban_id, + principalTable: "ban", + principalColumn: "ban_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ban_hwid", + columns: table => new + { + ban_hwid_id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + hwid = table.Column(type: "BLOB", nullable: false), + hwid_type = table.Column(type: "INTEGER", nullable: false), + ban_id = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ban_hwid", x => x.ban_hwid_id); + table.ForeignKey( + name: "FK_ban_hwid_ban_ban_id", + column: x => x.ban_id, + principalTable: "ban", + principalColumn: "ban_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ban_player", + columns: table => new + { + ban_player_id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + user_id = table.Column(type: "TEXT", nullable: false), + ban_id = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ban_player", x => x.ban_player_id); + table.ForeignKey( + name: "FK_ban_player_ban_ban_id", + column: x => x.ban_id, + principalTable: "ban", + principalColumn: "ban_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ban_role", + columns: table => new + { + ban_role_id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + role_type = table.Column(type: "TEXT", nullable: false), + role_id = table.Column(type: "TEXT", nullable: false), + ban_id = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ban_role", x => x.ban_role_id); + table.ForeignKey( + name: "FK_ban_role_ban_ban_id", + column: x => x.ban_id, + principalTable: "ban", + principalColumn: "ban_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ban_round", + columns: table => new + { + ban_round_id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ban_id = table.Column(type: "INTEGER", nullable: false), + round_id = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ban_round", x => x.ban_round_id); + table.ForeignKey( + name: "FK_ban_round_ban_ban_id", + column: x => x.ban_id, + principalTable: "ban", + principalColumn: "ban_id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ban_round_round_round_id", + column: x => x.round_id, + principalTable: "round", + principalColumn: "round_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "unban", + columns: table => new + { + unban_id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ban_id = table.Column(type: "INTEGER", nullable: false), + unbanning_admin = table.Column(type: "TEXT", nullable: true), + unban_time = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_unban", x => x.unban_id); + table.ForeignKey( + name: "FK_unban_ban_ban_id", + column: x => x.ban_id, + principalTable: "ban", + principalColumn: "ban_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ban_banning_admin", + table: "ban", + column: "banning_admin"); + + migrationBuilder.CreateIndex( + name: "IX_ban_last_edited_by_id", + table: "ban", + column: "last_edited_by_id"); + + migrationBuilder.CreateIndex( + name: "IX_ban_address_ban_id", + table: "ban_address", + column: "ban_id"); + + migrationBuilder.CreateIndex( + name: "IX_ban_hwid_ban_id", + table: "ban_hwid", + column: "ban_id"); + + migrationBuilder.CreateIndex( + name: "IX_ban_player_ban_id", + table: "ban_player", + column: "ban_id"); + + migrationBuilder.CreateIndex( + name: "IX_ban_player_user_id_ban_id", + table: "ban_player", + columns: new[] { "user_id", "ban_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ban_role_ban_id", + table: "ban_role", + column: "ban_id"); + + migrationBuilder.CreateIndex( + name: "IX_ban_role_role_type_role_id_ban_id", + table: "ban_role", + columns: new[] { "role_type", "role_id", "ban_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ban_round_ban_id", + table: "ban_round", + column: "ban_id"); + + migrationBuilder.CreateIndex( + name: "IX_ban_round_round_id_ban_id", + table: "ban_round", + columns: new[] { "round_id", "ban_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_unban_ban_id", + table: "unban", + column: "ban_id", + unique: true); + + migrationBuilder.Sql(""" + CREATE UNIQUE INDEX "IX_ban_hwid_hwid_ban_id" + ON ban_hwid + (hwid_type, hwid, ban_id); + + CREATE UNIQUE INDEX "IX_ban_address_address_ban_id" + ON ban_address + (address, ban_id); + """); + + migrationBuilder.Sql(""" + -- + -- Insert game bans + -- + INSERT INTO + ban (ban_id, type, playtime_at_note, ban_time, expiration_time, reason, severity, banning_admin, last_edited_by_id, last_edited_at, exempt_flags, auto_delete, hidden) + SELECT + server_ban_id, 0, playtime_at_note, ban_time, expiration_time, reason, severity, banning_admin, last_edited_by_id, last_edited_at, exempt_flags, auto_delete, hidden + FROM + server_ban; + + -- Insert ban player records. + INSERT INTO + ban_player (user_id, ban_id) + SELECT + player_user_id, server_ban_id + FROM + server_ban + WHERE + player_user_id IS NOT NULL; + + -- Insert ban address records. + INSERT INTO + ban_address (address, ban_id) + SELECT + address, server_ban_id + FROM + server_ban + WHERE + address IS NOT NULL; + + -- Insert ban HWID records. + INSERT INTO + ban_hwid (hwid, hwid_type, ban_id) + SELECT + hwid, hwid_type, server_ban_id + FROM + server_ban + WHERE + hwid IS NOT NULL; + + -- Insert ban unban records. + INSERT INTO + unban (ban_id, unbanning_admin, unban_time) + SELECT + ban_id, unbanning_admin, unban_time + FROM server_unban; + + -- Insert ban round records. + INSERT INTO + ban_round (round_id, ban_id) + SELECT + round_id, server_ban_id + FROM + server_ban + WHERE + round_id IS NOT NULL; + + -- + -- Insert role bans + -- This shit is a pain in the ass + -- > Declarative language + -- > Has to write procedural code in it + -- + + -- Create mapping table from role ban -> server ban. + -- We have to manually calculate the new ban IDs by using the sequence. + -- We also want to merge role ban records because the game code previously did that in some UI, + -- and that code is now gone, expecting the DB to do it. + + -- Create a table to store IDs to merge. + CREATE TEMPORARY TABLE _role_ban_import_merge_map (merge_id INTEGER, server_role_ban_id INTEGER UNIQUE); + + -- Create a table to store merged IDs -> new ban IDs + CREATE TEMPORARY TABLE _role_ban_import_id_map (ban_id INTEGER UNIQUE, merge_id INTEGER UNIQUE); + + -- Calculate merged role bans. + INSERT INTO + _role_ban_import_merge_map + SELECT + ( + SELECT + sub.server_role_ban_id + FROM + server_role_ban AS sub + LEFT JOIN server_role_unban AS sub_unban + ON sub_unban.ban_id = sub.server_role_ban_id + WHERE + main.reason IS NOT DISTINCT FROM sub.reason + AND main.player_user_id IS NOT DISTINCT FROM sub.player_user_id + AND main.address IS NOT DISTINCT FROM sub.address + AND main.hwid IS NOT DISTINCT FROM sub.hwid + AND main.hwid_type IS NOT DISTINCT FROM sub.hwid_type + AND main.ban_time = sub.ban_time + AND ( + (main.expiration_time IS NULL) = (sub.expiration_time IS NULL) + OR main.expiration_time = sub.expiration_time + ) + AND main.round_id IS NOT DISTINCT FROM sub.round_id + AND main.severity IS NOT DISTINCT FROM sub.severity + AND main.hidden IS NOT DISTINCT FROM sub.hidden + AND main.banning_admin IS NOT DISTINCT FROM sub.banning_admin + AND (sub_unban.ban_id IS NULL) = (main_unban.ban_id IS NULL) + ORDER BY + sub.server_role_ban_id ASC + LIMIT 1 + ), main.server_role_ban_id + FROM + server_role_ban AS main + LEFT JOIN server_role_unban AS main_unban + ON main_unban.ban_id = main.server_role_ban_id; + + -- Assign new ban IDs for merged IDs. + INSERT OR IGNORE INTO + _role_ban_import_id_map + SELECT + merge_id + (SELECT seq FROM sqlite_sequence WHERE name = 'ban'), + merge_id + FROM + _role_ban_import_merge_map; + + -- I sure fucking wish CTEs could span multiple queries... + + -- Insert new ban records + INSERT INTO + ban (ban_id, type, playtime_at_note, ban_time, expiration_time, reason, severity, banning_admin, last_edited_by_id, last_edited_at, exempt_flags, auto_delete, hidden) + SELECT + im.ban_id, 1, playtime_at_note, ban_time, expiration_time, reason, severity, banning_admin, last_edited_by_id, last_edited_at, 0, FALSE, hidden + FROM + _role_ban_import_id_map im + INNER JOIN _role_ban_import_merge_map mm + ON im.merge_id = mm.merge_id + INNER JOIN server_role_ban srb + ON srb.server_role_ban_id = im.merge_id + WHERE mm.merge_id = mm.server_role_ban_id; + + -- Insert role ban player records. + INSERT INTO + ban_player (user_id, ban_id) + SELECT + player_user_id, im.ban_id + FROM + _role_ban_import_id_map im + INNER JOIN _role_ban_import_merge_map mm + ON im.merge_id = mm.merge_id + INNER JOIN server_role_ban srb + ON srb.server_role_ban_id = im.merge_id + WHERE mm.merge_id = mm.server_role_ban_id + AND player_user_id IS NOT NULL; + + -- Insert role ban address records. + INSERT INTO + ban_address (address, ban_id) + SELECT + address, im.ban_id + FROM + _role_ban_import_id_map im + INNER JOIN _role_ban_import_merge_map mm + ON im.merge_id = mm.merge_id + INNER JOIN server_role_ban srb + ON srb.server_role_ban_id = im.merge_id + WHERE mm.merge_id = mm.server_role_ban_id + AND address IS NOT NULL; + + -- Insert role ban HWID records. + INSERT INTO + ban_hwid (hwid, hwid_type, ban_id) + SELECT + hwid, hwid_type, im.ban_id + FROM + _role_ban_import_id_map im + INNER JOIN _role_ban_import_merge_map mm + ON im.merge_id = mm.merge_id + INNER JOIN server_role_ban srb + ON srb.server_role_ban_id = im.merge_id + WHERE mm.merge_id = mm.server_role_ban_id + AND hwid IS NOT NULL; + + -- Insert role ban role records. + INSERT INTO + ban_role (role_type, role_id, ban_id) + SELECT + substr(role_id, 1, instr(role_id, ':')-1), + substr(role_id, instr(role_id, ':')+1), + im.ban_id + FROM + _role_ban_import_id_map im + INNER JOIN _role_ban_import_merge_map mm + ON im.merge_id = mm.merge_id + INNER JOIN server_role_ban srb + ON srb.server_role_ban_id = mm.server_role_ban_id + -- Yes, we have some messy ban records which, after merging, end up with duplicate roles. + ON CONFLICT DO NOTHING; + + -- Insert role unban records. + INSERT INTO + unban (ban_id, unbanning_admin, unban_time) + SELECT + im.ban_id, unbanning_admin, unban_time + FROM server_role_unban sru + INNER JOIN _role_ban_import_id_map im + ON im.merge_id = sru.ban_id; + + -- Insert role rounds + INSERT INTO + ban_round (round_id, ban_id) + SELECT + round_id, im.ban_id + FROM + _role_ban_import_id_map im + INNER JOIN _role_ban_import_merge_map mm + ON im.merge_id = mm.merge_id + INNER JOIN server_role_ban srb + ON srb.server_role_ban_id = im.merge_id + WHERE mm.merge_id = mm.server_role_ban_id + AND round_id IS NOT NULL; + """); + + migrationBuilder.AddForeignKey( + name: "FK_server_ban_hit_ban_ban_id", + table: "server_ban_hit", + column: "ban_id", + principalTable: "ban", + principalColumn: "ban_id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.DropForeignKey( + name: "FK_server_ban_hit_server_ban_ban_id", + table: "server_ban_hit"); + + migrationBuilder.DropTable( + name: "server_role_unban"); + + migrationBuilder.DropTable( + name: "server_unban"); + + migrationBuilder.DropTable( + name: "server_role_ban"); + + migrationBuilder.DropTable( + name: "server_ban"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + throw new NotSupportedException("This migration cannot be rolled back"); + } + } +} diff --git a/Content.Server.Database/Migrations/Sqlite/SqliteServerDbContextModelSnapshot.cs b/Content.Server.Database/Migrations/Sqlite/SqliteServerDbContextModelSnapshot.cs index 584c96efbc..2f5709790d 100644 --- a/Content.Server.Database/Migrations/Sqlite/SqliteServerDbContextModelSnapshot.cs +++ b/Content.Server.Database/Migrations/Sqlite/SqliteServerDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace Content.Server.Database.Migrations.Sqlite protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.1"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); modelBuilder.Entity("Content.Server.Database.Admin", b => { @@ -489,6 +489,207 @@ namespace Content.Server.Database.Migrations.Sqlite b.ToTable("assigned_user_id", (string)null); }); + modelBuilder.Entity("Content.Server.Database.Ban", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("ban_id"); + + b.Property("AutoDelete") + .HasColumnType("INTEGER") + .HasColumnName("auto_delete"); + + b.Property("BanTime") + .HasColumnType("TEXT") + .HasColumnName("ban_time"); + + b.Property("BanningAdmin") + .HasColumnType("TEXT") + .HasColumnName("banning_admin"); + + b.Property("ExemptFlags") + .HasColumnType("INTEGER") + .HasColumnName("exempt_flags"); + + b.Property("ExpirationTime") + .HasColumnType("TEXT") + .HasColumnName("expiration_time"); + + b.Property("Hidden") + .HasColumnType("INTEGER") + .HasColumnName("hidden"); + + b.Property("LastEditedAt") + .HasColumnType("TEXT") + .HasColumnName("last_edited_at"); + + b.Property("LastEditedById") + .HasColumnType("TEXT") + .HasColumnName("last_edited_by_id"); + + b.Property("PlaytimeAtNote") + .HasColumnType("TEXT") + .HasColumnName("playtime_at_note"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("reason"); + + b.Property("Severity") + .HasColumnType("INTEGER") + .HasColumnName("severity"); + + b.Property("Type") + .HasColumnType("INTEGER") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("PK_ban"); + + b.HasIndex("BanningAdmin"); + + b.HasIndex("LastEditedById"); + + b.ToTable("ban", null, t => + { + t.HasCheckConstraint("NoExemptOnRoleBan", "type = 0 OR exempt_flags = 0"); + }); + }); + + modelBuilder.Entity("Content.Server.Database.BanAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("ban_address_id"); + + b.Property("Address") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("address"); + + b.Property("BanId") + .HasColumnType("INTEGER") + .HasColumnName("ban_id"); + + b.HasKey("Id") + .HasName("PK_ban_address"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_ban_address_ban_id"); + + b.ToTable("ban_address", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.BanHwid", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("ban_hwid_id"); + + b.Property("BanId") + .HasColumnType("INTEGER") + .HasColumnName("ban_id"); + + b.HasKey("Id") + .HasName("PK_ban_hwid"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_ban_hwid_ban_id"); + + b.ToTable("ban_hwid", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.BanPlayer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("ban_player_id"); + + b.Property("BanId") + .HasColumnType("INTEGER") + .HasColumnName("ban_id"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_ban_player"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_ban_player_ban_id"); + + b.HasIndex("UserId", "BanId") + .IsUnique(); + + b.ToTable("ban_player", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.BanRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("ban_role_id"); + + b.Property("BanId") + .HasColumnType("INTEGER") + .HasColumnName("ban_id"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("role_id"); + + b.Property("RoleType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("role_type"); + + b.HasKey("Id") + .HasName("PK_ban_role"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_ban_role_ban_id"); + + b.HasIndex("RoleType", "RoleId", "BanId") + .IsUnique(); + + b.ToTable("ban_role", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.BanRound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("ban_round_id"); + + b.Property("BanId") + .HasColumnType("INTEGER") + .HasColumnName("ban_id"); + + b.Property("RoundId") + .HasColumnType("INTEGER") + .HasColumnName("round_id"); + + b.HasKey("Id") + .HasName("PK_ban_round"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_ban_round_ban_id"); + + b.HasIndex("RoundId", "BanId") + .IsUnique(); + + b.ToTable("ban_round", (string)null); + }); + modelBuilder.Entity("Content.Server.Database.BanTemplate", b => { b.Property("Id") @@ -1006,91 +1207,6 @@ namespace Content.Server.Database.Migrations.Sqlite b.ToTable("server", (string)null); }); - modelBuilder.Entity("Content.Server.Database.ServerBan", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasColumnName("server_ban_id"); - - b.Property("Address") - .HasColumnType("TEXT") - .HasColumnName("address"); - - b.Property("AutoDelete") - .HasColumnType("INTEGER") - .HasColumnName("auto_delete"); - - b.Property("BanTime") - .HasColumnType("TEXT") - .HasColumnName("ban_time"); - - b.Property("BanningAdmin") - .HasColumnType("TEXT") - .HasColumnName("banning_admin"); - - b.Property("ExemptFlags") - .HasColumnType("INTEGER") - .HasColumnName("exempt_flags"); - - b.Property("ExpirationTime") - .HasColumnType("TEXT") - .HasColumnName("expiration_time"); - - b.Property("Hidden") - .HasColumnType("INTEGER") - .HasColumnName("hidden"); - - b.Property("LastEditedAt") - .HasColumnType("TEXT") - .HasColumnName("last_edited_at"); - - b.Property("LastEditedById") - .HasColumnType("TEXT") - .HasColumnName("last_edited_by_id"); - - b.Property("PlayerUserId") - .HasColumnType("TEXT") - .HasColumnName("player_user_id"); - - b.Property("PlaytimeAtNote") - .HasColumnType("TEXT") - .HasColumnName("playtime_at_note"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("reason"); - - b.Property("RoundId") - .HasColumnType("INTEGER") - .HasColumnName("round_id"); - - b.Property("Severity") - .HasColumnType("INTEGER") - .HasColumnName("severity"); - - b.HasKey("Id") - .HasName("PK_server_ban"); - - b.HasIndex("Address"); - - b.HasIndex("BanningAdmin"); - - b.HasIndex("LastEditedById"); - - b.HasIndex("PlayerUserId") - .HasDatabaseName("IX_server_ban_player_user_id"); - - b.HasIndex("RoundId") - .HasDatabaseName("IX_server_ban_round_id"); - - b.ToTable("server_ban", null, t => - { - t.HasCheckConstraint("HaveEitherAddressOrUserIdOrHWId", "address IS NOT NULL OR player_user_id IS NOT NULL OR hwid IS NOT NULL"); - }); - }); - modelBuilder.Entity("Content.Server.Database.ServerBanExemption", b => { b.Property("UserId") @@ -1138,144 +1254,6 @@ namespace Content.Server.Database.Migrations.Sqlite b.ToTable("server_ban_hit", (string)null); }); - modelBuilder.Entity("Content.Server.Database.ServerRoleBan", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasColumnName("server_role_ban_id"); - - b.Property("Address") - .HasColumnType("TEXT") - .HasColumnName("address"); - - b.Property("BanTime") - .HasColumnType("TEXT") - .HasColumnName("ban_time"); - - b.Property("BanningAdmin") - .HasColumnType("TEXT") - .HasColumnName("banning_admin"); - - b.Property("ExpirationTime") - .HasColumnType("TEXT") - .HasColumnName("expiration_time"); - - b.Property("Hidden") - .HasColumnType("INTEGER") - .HasColumnName("hidden"); - - b.Property("LastEditedAt") - .HasColumnType("TEXT") - .HasColumnName("last_edited_at"); - - b.Property("LastEditedById") - .HasColumnType("TEXT") - .HasColumnName("last_edited_by_id"); - - b.Property("PlayerUserId") - .HasColumnType("TEXT") - .HasColumnName("player_user_id"); - - b.Property("PlaytimeAtNote") - .HasColumnType("TEXT") - .HasColumnName("playtime_at_note"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("reason"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("role_id"); - - b.Property("RoundId") - .HasColumnType("INTEGER") - .HasColumnName("round_id"); - - b.Property("Severity") - .HasColumnType("INTEGER") - .HasColumnName("severity"); - - b.HasKey("Id") - .HasName("PK_server_role_ban"); - - b.HasIndex("Address"); - - b.HasIndex("BanningAdmin"); - - b.HasIndex("LastEditedById"); - - b.HasIndex("PlayerUserId") - .HasDatabaseName("IX_server_role_ban_player_user_id"); - - b.HasIndex("RoundId") - .HasDatabaseName("IX_server_role_ban_round_id"); - - b.ToTable("server_role_ban", null, t => - { - t.HasCheckConstraint("HaveEitherAddressOrUserIdOrHWId", "address IS NOT NULL OR player_user_id IS NOT NULL OR hwid IS NOT NULL"); - }); - }); - - modelBuilder.Entity("Content.Server.Database.ServerRoleUnban", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasColumnName("role_unban_id"); - - b.Property("BanId") - .HasColumnType("INTEGER") - .HasColumnName("ban_id"); - - b.Property("UnbanTime") - .HasColumnType("TEXT") - .HasColumnName("unban_time"); - - b.Property("UnbanningAdmin") - .HasColumnType("TEXT") - .HasColumnName("unbanning_admin"); - - b.HasKey("Id") - .HasName("PK_server_role_unban"); - - b.HasIndex("BanId") - .IsUnique(); - - b.ToTable("server_role_unban", (string)null); - }); - - modelBuilder.Entity("Content.Server.Database.ServerUnban", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasColumnName("unban_id"); - - b.Property("BanId") - .HasColumnType("INTEGER") - .HasColumnName("ban_id"); - - b.Property("UnbanTime") - .HasColumnType("TEXT") - .HasColumnName("unban_time"); - - b.Property("UnbanningAdmin") - .HasColumnType("TEXT") - .HasColumnName("unbanning_admin"); - - b.HasKey("Id") - .HasName("PK_server_unban"); - - b.HasIndex("BanId") - .IsUnique(); - - b.ToTable("server_unban", (string)null); - }); - modelBuilder.Entity("Content.Server.Database.Trait", b => { b.Property("Id") @@ -1301,6 +1279,34 @@ namespace Content.Server.Database.Migrations.Sqlite b.ToTable("trait", (string)null); }); + modelBuilder.Entity("Content.Server.Database.Unban", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("unban_id"); + + b.Property("BanId") + .HasColumnType("INTEGER") + .HasColumnName("ban_id"); + + b.Property("UnbanTime") + .HasColumnType("TEXT") + .HasColumnName("unban_time"); + + b.Property("UnbanningAdmin") + .HasColumnType("TEXT") + .HasColumnName("unbanning_admin"); + + b.HasKey("Id") + .HasName("PK_unban"); + + b.HasIndex("BanId") + .IsUnique(); + + b.ToTable("unban", (string)null); + }); + modelBuilder.Entity("Content.Server.Database.UploadedResourceLog", b => { b.Property("Id") @@ -1583,6 +1589,123 @@ namespace Content.Server.Database.Migrations.Sqlite b.Navigation("Profile"); }); + modelBuilder.Entity("Content.Server.Database.Ban", b => + { + b.HasOne("Content.Server.Database.Player", "CreatedBy") + .WithMany("AdminServerBansCreated") + .HasForeignKey("BanningAdmin") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_ban_player_banning_admin"); + + b.HasOne("Content.Server.Database.Player", "LastEditedBy") + .WithMany("AdminServerBansLastEdited") + .HasForeignKey("LastEditedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_ban_player_last_edited_by_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("LastEditedBy"); + }); + + modelBuilder.Entity("Content.Server.Database.BanAddress", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithMany("Addresses") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_address_ban_ban_id"); + + b.Navigation("Ban"); + }); + + modelBuilder.Entity("Content.Server.Database.BanHwid", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithMany("Hwids") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_hwid_ban_ban_id"); + + b.OwnsOne("Content.Server.Database.TypedHwid", "HWId", b1 => + { + b1.Property("BanHwidId") + .HasColumnType("INTEGER") + .HasColumnName("ban_hwid_id"); + + b1.Property("Hwid") + .IsRequired() + .HasColumnType("BLOB") + .HasColumnName("hwid"); + + b1.Property("Type") + .HasColumnType("INTEGER") + .HasColumnName("hwid_type"); + + b1.HasKey("BanHwidId"); + + b1.ToTable("ban_hwid"); + + b1.WithOwner() + .HasForeignKey("BanHwidId") + .HasConstraintName("FK_ban_hwid_ban_hwid_ban_hwid_id"); + }); + + b.Navigation("Ban"); + + b.Navigation("HWId") + .IsRequired(); + }); + + modelBuilder.Entity("Content.Server.Database.BanPlayer", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithMany("Players") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_player_ban_ban_id"); + + b.Navigation("Ban"); + }); + + modelBuilder.Entity("Content.Server.Database.BanRole", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithMany("Roles") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_role_ban_ban_id"); + + b.Navigation("Ban"); + }); + + modelBuilder.Entity("Content.Server.Database.BanRound", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithMany("Rounds") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_round_ban_ban_id"); + + b.HasOne("Content.Server.Database.Round", "Round") + .WithMany() + .HasForeignKey("RoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_ban_round_round_round_id"); + + b.Navigation("Ban"); + + b.Navigation("Round"); + }); + modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => { b.HasOne("Content.Server.Database.Server", "Server") @@ -1739,70 +1862,14 @@ namespace Content.Server.Database.Migrations.Sqlite b.Navigation("Server"); }); - modelBuilder.Entity("Content.Server.Database.ServerBan", b => - { - b.HasOne("Content.Server.Database.Player", "CreatedBy") - .WithMany("AdminServerBansCreated") - .HasForeignKey("BanningAdmin") - .HasPrincipalKey("UserId") - .OnDelete(DeleteBehavior.SetNull) - .HasConstraintName("FK_server_ban_player_banning_admin"); - - b.HasOne("Content.Server.Database.Player", "LastEditedBy") - .WithMany("AdminServerBansLastEdited") - .HasForeignKey("LastEditedById") - .HasPrincipalKey("UserId") - .OnDelete(DeleteBehavior.SetNull) - .HasConstraintName("FK_server_ban_player_last_edited_by_id"); - - b.HasOne("Content.Server.Database.Round", "Round") - .WithMany() - .HasForeignKey("RoundId") - .HasConstraintName("FK_server_ban_round_round_id"); - - b.OwnsOne("Content.Server.Database.TypedHwid", "HWId", b1 => - { - b1.Property("ServerBanId") - .HasColumnType("INTEGER") - .HasColumnName("server_ban_id"); - - b1.Property("Hwid") - .IsRequired() - .HasColumnType("BLOB") - .HasColumnName("hwid"); - - b1.Property("Type") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(0) - .HasColumnName("hwid_type"); - - b1.HasKey("ServerBanId"); - - b1.ToTable("server_ban"); - - b1.WithOwner() - .HasForeignKey("ServerBanId") - .HasConstraintName("FK_server_ban_server_ban_server_ban_id"); - }); - - b.Navigation("CreatedBy"); - - b.Navigation("HWId"); - - b.Navigation("LastEditedBy"); - - b.Navigation("Round"); - }); - modelBuilder.Entity("Content.Server.Database.ServerBanHit", b => { - b.HasOne("Content.Server.Database.ServerBan", "Ban") + b.HasOne("Content.Server.Database.Ban", "Ban") .WithMany("BanHits") .HasForeignKey("BanId") .OnDelete(DeleteBehavior.Cascade) .IsRequired() - .HasConstraintName("FK_server_ban_hit_server_ban_ban_id"); + .HasConstraintName("FK_server_ban_hit_ban_ban_id"); b.HasOne("Content.Server.Database.ConnectionLog", "Connection") .WithMany("BanHits") @@ -1816,86 +1883,6 @@ namespace Content.Server.Database.Migrations.Sqlite b.Navigation("Connection"); }); - modelBuilder.Entity("Content.Server.Database.ServerRoleBan", b => - { - b.HasOne("Content.Server.Database.Player", "CreatedBy") - .WithMany("AdminServerRoleBansCreated") - .HasForeignKey("BanningAdmin") - .HasPrincipalKey("UserId") - .OnDelete(DeleteBehavior.SetNull) - .HasConstraintName("FK_server_role_ban_player_banning_admin"); - - b.HasOne("Content.Server.Database.Player", "LastEditedBy") - .WithMany("AdminServerRoleBansLastEdited") - .HasForeignKey("LastEditedById") - .HasPrincipalKey("UserId") - .OnDelete(DeleteBehavior.SetNull) - .HasConstraintName("FK_server_role_ban_player_last_edited_by_id"); - - b.HasOne("Content.Server.Database.Round", "Round") - .WithMany() - .HasForeignKey("RoundId") - .HasConstraintName("FK_server_role_ban_round_round_id"); - - b.OwnsOne("Content.Server.Database.TypedHwid", "HWId", b1 => - { - b1.Property("ServerRoleBanId") - .HasColumnType("INTEGER") - .HasColumnName("server_role_ban_id"); - - b1.Property("Hwid") - .IsRequired() - .HasColumnType("BLOB") - .HasColumnName("hwid"); - - b1.Property("Type") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(0) - .HasColumnName("hwid_type"); - - b1.HasKey("ServerRoleBanId"); - - b1.ToTable("server_role_ban"); - - b1.WithOwner() - .HasForeignKey("ServerRoleBanId") - .HasConstraintName("FK_server_role_ban_server_role_ban_server_role_ban_id"); - }); - - b.Navigation("CreatedBy"); - - b.Navigation("HWId"); - - b.Navigation("LastEditedBy"); - - b.Navigation("Round"); - }); - - modelBuilder.Entity("Content.Server.Database.ServerRoleUnban", b => - { - b.HasOne("Content.Server.Database.ServerRoleBan", "Ban") - .WithOne("Unban") - .HasForeignKey("Content.Server.Database.ServerRoleUnban", "BanId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("FK_server_role_unban_server_role_ban_ban_id"); - - b.Navigation("Ban"); - }); - - modelBuilder.Entity("Content.Server.Database.ServerUnban", b => - { - b.HasOne("Content.Server.Database.ServerBan", "Ban") - .WithOne("Unban") - .HasForeignKey("Content.Server.Database.ServerUnban", "BanId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("FK_server_unban_server_ban_ban_id"); - - b.Navigation("Ban"); - }); - modelBuilder.Entity("Content.Server.Database.Trait", b => { b.HasOne("Content.Server.Database.Profile", "Profile") @@ -1908,6 +1895,18 @@ namespace Content.Server.Database.Migrations.Sqlite b.Navigation("Profile"); }); + modelBuilder.Entity("Content.Server.Database.Unban", b => + { + b.HasOne("Content.Server.Database.Ban", "Ban") + .WithOne("Unban") + .HasForeignKey("Content.Server.Database.Unban", "BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_unban_ban_ban_id"); + + b.Navigation("Ban"); + }); + modelBuilder.Entity("PlayerRound", b => { b.HasOne("Content.Server.Database.Player", null) @@ -1942,6 +1941,23 @@ namespace Content.Server.Database.Migrations.Sqlite b.Navigation("Flags"); }); + modelBuilder.Entity("Content.Server.Database.Ban", b => + { + b.Navigation("Addresses"); + + b.Navigation("BanHits"); + + b.Navigation("Hwids"); + + b.Navigation("Players"); + + b.Navigation("Roles"); + + b.Navigation("Rounds"); + + b.Navigation("Unban"); + }); + modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => { b.Navigation("BanHits"); @@ -1971,10 +1987,6 @@ namespace Content.Server.Database.Migrations.Sqlite b.Navigation("AdminServerBansLastEdited"); - b.Navigation("AdminServerRoleBansCreated"); - - b.Navigation("AdminServerRoleBansLastEdited"); - b.Navigation("AdminWatchlistsCreated"); b.Navigation("AdminWatchlistsDeleted"); @@ -2023,18 +2035,6 @@ namespace Content.Server.Database.Migrations.Sqlite b.Navigation("Rounds"); }); - - modelBuilder.Entity("Content.Server.Database.ServerBan", b => - { - b.Navigation("BanHits"); - - b.Navigation("Unban"); - }); - - modelBuilder.Entity("Content.Server.Database.ServerRoleBan", b => - { - b.Navigation("Unban"); - }); #pragma warning restore 612, 618 } } diff --git a/Content.Server.Database/Model.Ban.cs b/Content.Server.Database/Model.Ban.cs new file mode 100644 index 0000000000..7d1ee2ab1a --- /dev/null +++ b/Content.Server.Database/Model.Ban.cs @@ -0,0 +1,328 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Content.Shared.Database; +using Microsoft.EntityFrameworkCore; +using NpgsqlTypes; + +// ReSharper disable EntityFramework.ModelValidation.UnlimitedStringLength + +namespace Content.Server.Database; + +// +// Contains model definitions primarily related to bans. +// + +internal static class ModelBan +{ + public static void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasOne(b => b.CreatedBy) + .WithMany(pl => pl.AdminServerBansCreated) + .HasForeignKey(b => b.BanningAdmin) + .HasPrincipalKey(pl => pl.UserId) + .OnDelete(DeleteBehavior.SetNull); + + modelBuilder.Entity() + .HasOne(b => b.LastEditedBy) + .WithMany(pl => pl.AdminServerBansLastEdited) + .HasForeignKey(b => b.LastEditedById) + .HasPrincipalKey(pl => pl.UserId) + .OnDelete(DeleteBehavior.SetNull); + + modelBuilder.Entity() + .HasIndex(bp => new { bp.UserId, bp.BanId }) + .IsUnique(); + + modelBuilder.Entity() + .OwnsOne(bp => bp.HWId) + .Property(hwid => hwid.Hwid) + .HasColumnName("hwid"); + + modelBuilder.Entity() + .HasIndex(bp => new { bp.RoleType, bp.RoleId, bp.BanId }) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(bp => new { bp.RoundId, bp.BanId }) + .IsUnique(); + + // Following indices have to be made manually by migration, due to limitations in EF Core: + // https://github.com/dotnet/efcore/issues/11336 + // https://github.com/npgsql/efcore.pg/issues/2567 + // modelBuilder.Entity() + // .HasIndex(bp => new { bp.Address, bp.BanId }) + // .IsUnique(); + // modelBuilder.Entity() + // .HasIndex(hwid => new { hwid.HWId.Type, hwid.HWId.Hwid, hwid.Hwid }) + // .IsUnique(); + // (postgres only) + // modelBuilder.Entity() + // .HasIndex(ba => ba.Address) + // .IncludeProperties(ba => ba.BanId) + // .IsUnique() + // .HasMethod("gist") + // .HasOperators("inet_ops"); + + modelBuilder.Entity() + .ToTable(t => t.HasCheckConstraint("NoExemptOnRoleBan", $"type = {(int)BanType.Server} OR exempt_flags = 0")); + } +} + +/// +/// Specifies a ban of some kind. +/// +/// +/// +/// Bans come in two types: and , +/// distinguished with . +/// +/// +/// Bans have one or more "matching data", these being , , +/// and entities. If a player's connection info matches any of these, +/// the ban's effects will apply to that player. +/// +/// +/// Bans can be set to expire after a certain point in time, or be permanent. They can also be removed manually +/// ("unbanned") by an admin, which is stored as an entity existing for this ban. +/// +/// +public sealed class Ban +{ + public int Id { get; set; } + + /// + /// Whether this is a role or server ban. + /// + public required BanType Type { get; set; } + + public TimeSpan PlaytimeAtNote { get; set; } + + /// + /// The time when the ban was applied by an administrator. + /// + public DateTime BanTime { get; set; } + + /// + /// The time the ban will expire. If null, the ban is permanent and will not expire naturally. + /// + public DateTime? ExpirationTime { get; set; } + + /// + /// The administrator-stated reason for applying the ban. + /// + public string Reason { get; set; } = null!; + + /// + /// The severity of the incident + /// + public NoteSeverity Severity { get; set; } + + /// + /// User ID of the admin that initially applied the ban. + /// + [ForeignKey(nameof(CreatedBy))] + public Guid? BanningAdmin { get; set; } + + public Player? CreatedBy { get; set; } + + /// + /// User ID of the admin that last edited the note + /// + [ForeignKey(nameof(LastEditedBy))] + public Guid? LastEditedById { get; set; } + + public Player? LastEditedBy { get; set; } + public DateTime? LastEditedAt { get; set; } + + /// + /// Optional flags that allow adding exemptions to the ban via . + /// + public ServerBanExemptFlags ExemptFlags { get; set; } + + /// + /// Whether this ban should be automatically deleted from the database when it expires. + /// + /// + /// This isn't done automatically by the game, + /// you will need to set up something like a cron job to clear this from your database, + /// using a command like this: + /// psql -d ss14 -c "DELETE FROM server_ban WHERE auto_delete AND expiration_time < NOW()" + /// + public bool AutoDelete { get; set; } + + /// + /// Whether to display this ban in the admin remarks (notes) panel + /// + public bool Hidden { get; set; } + + /// + /// If present, an administrator has manually repealed this ban. + /// + public Unban? Unban { get; set; } + + public List? Rounds { get; set; } + public List? Players { get; set; } + public List? Addresses { get; set; } + public List? Hwids { get; set; } + public List? Roles { get; set; } + public List? BanHits { get; set; } +} + +/// +/// Base type for entities that specify ban matching data. +/// +public interface IBanSelector +{ + int BanId { get; } + Ban? Ban { get; } +} + +/// +/// Indicates that a ban was related to a round (e.g. placed on that round). +/// +public sealed class BanRound +{ + public int Id { get; set; } + + /// + /// The ID of the ban to which this round was relevant. + /// + [ForeignKey(nameof(Ban))] + public int BanId { get; set; } + + public Ban? Ban { get; set; } + + /// + /// The ID of the round to which this ban was relevant to. + /// + [ForeignKey(nameof(Round))] + public int RoundId { get; set; } + + public Round? Round { get; set; } +} + +/// +/// Specifies a player that a matches. +/// +public sealed class BanPlayer : IBanSelector +{ + public int Id { get; set; } + + /// + /// The user ID of the banned player. + /// + public Guid UserId { get; set; } + + /// + /// The ID of the ban to which this applies. + /// + [ForeignKey(nameof(Ban))] + public int BanId { get; set; } + + public Ban? Ban { get; set; } +} + +/// +/// Specifies an IP address range that a matches. +/// +public sealed class BanAddress : IBanSelector +{ + public int Id { get; set; } + + /// + /// The address range being matched. + /// + public required NpgsqlInet Address { get; set; } + + /// + /// The ID of the ban to which this applies. + /// + [ForeignKey(nameof(Ban))] + public int BanId { get; set; } + + public Ban? Ban { get; set; } +} + +/// +/// Specifies a HWID that a matches. +/// +public sealed class BanHwid : IBanSelector +{ + public int Id { get; set; } + + /// + /// The HWID being matched. + /// + public required TypedHwid HWId { get; set; } + + /// + /// The ID of the ban to which this applies. + /// + [ForeignKey(nameof(Ban))] + public int BanId { get; set; } + + public Ban? Ban { get; set; } +} + +/// +/// A single role banned among a greater role ban record. +/// +/// +/// s of type should have one or more s +/// to store which roles are actually banned. +/// It is invalid for bans to have entities. +/// +public sealed class BanRole +{ + public int Id { get; set; } + + /// + /// What type of role is being banned. For example Job or Antag. + /// + public required string RoleType { get; set; } + + /// + /// The ID of the role being banned. This is probably something like a prototype. + /// + public required string RoleId { get; set; } + + /// + /// The ID of the ban to which this applies. + /// + [ForeignKey(nameof(Ban))] + public int BanId { get; set; } + + public Ban? Ban { get; set; } +} + +/// +/// An explicit repeal of a by an administrator. +/// Having an entry for a ban neutralizes it. +/// +public sealed class Unban +{ + public int Id { get; set; } + + /// + /// The ID of ban that is being repealed. + /// + [ForeignKey(nameof(Ban))] + public int BanId { get; set; } + + /// + /// The ban that is being repealed. + /// + public Ban? Ban { get; set; } + + /// + /// The admin that repealed the ban. + /// + public Guid? UnbanningAdmin { get; set; } + + /// + /// The time the ban was repealed. + /// + public DateTime UnbanTime { get; set; } +} diff --git a/Content.Server.Database/Model.cs b/Content.Server.Database/Model.cs index 8757b19680..a5fe866479 100644 --- a/Content.Server.Database/Model.cs +++ b/Content.Server.Database/Model.cs @@ -9,7 +9,6 @@ using System.Net; using System.Text.Json; using Content.Shared.Database; using Microsoft.EntityFrameworkCore; -using NpgsqlTypes; namespace Content.Server.Database { @@ -31,13 +30,17 @@ namespace Content.Server.Database public DbSet AdminLogPlayer { get; set; } = null!; public DbSet Whitelist { get; set; } = null!; public DbSet Blacklist { get; set; } = null!; - public DbSet Ban { get; set; } = default!; - public DbSet Unban { get; set; } = default!; + public DbSet Ban { get; set; } = default!; + public DbSet BanRound { get; set; } = default!; + public DbSet BanPlayer { get; set; } = default!; + public DbSet BanAddress { get; set; } = default!; + public DbSet BanHwid { get; set; } = default!; + public DbSet BanRole { get; set; } = default!; + public DbSet Unban { get; set; } = default!; public DbSet BanExemption { get; set; } = default!; public DbSet ConnectionLog { get; set; } = default!; public DbSet ServerBanHit { get; set; } = default!; - public DbSet RoleBan { get; set; } = default!; - public DbSet RoleUnban { get; set; } = default!; + public DbSet PlayTime { get; set; } = default!; public DbSet UploadedResourceLog { get; set; } = default!; public DbSet AdminNotes { get; set; } = null!; @@ -145,43 +148,11 @@ namespace Content.Server.Database modelBuilder.Entity() .HasKey(logPlayer => new {logPlayer.RoundId, logPlayer.LogId, logPlayer.PlayerUserId}); - modelBuilder.Entity() - .HasIndex(p => p.PlayerUserId); - - modelBuilder.Entity() - .HasIndex(p => p.Address); - - modelBuilder.Entity() - .HasIndex(p => p.PlayerUserId); - - modelBuilder.Entity() - .HasIndex(p => p.BanId) - .IsUnique(); - - modelBuilder.Entity().ToTable(t => - t.HasCheckConstraint("HaveEitherAddressOrUserIdOrHWId", "address IS NOT NULL OR player_user_id IS NOT NULL OR hwid IS NOT NULL")); - // Ban exemption can't have flags 0 since that wouldn't exempt anything. // The row should be removed if setting to 0. modelBuilder.Entity().ToTable(t => t.HasCheckConstraint("FlagsNotZero", "flags != 0")); - modelBuilder.Entity() - .HasIndex(p => p.PlayerUserId); - - modelBuilder.Entity() - .HasIndex(p => p.Address); - - modelBuilder.Entity() - .HasIndex(p => p.PlayerUserId); - - modelBuilder.Entity() - .HasIndex(p => p.BanId) - .IsUnique(); - - modelBuilder.Entity().ToTable(t => - t.HasCheckConstraint("HaveEitherAddressOrUserIdOrHWId", "address IS NOT NULL OR player_user_id IS NOT NULL OR hwid IS NOT NULL")); - modelBuilder.Entity() .HasIndex(p => p.UserId) .IsUnique(); @@ -296,34 +267,6 @@ namespace Content.Server.Database t.HasCheckConstraint("NotDismissedAndSeen", "NOT dismissed OR seen")); - modelBuilder.Entity() - .HasOne(ban => ban.CreatedBy) - .WithMany(author => author.AdminServerBansCreated) - .HasForeignKey(ban => ban.BanningAdmin) - .HasPrincipalKey(author => author.UserId) - .OnDelete(DeleteBehavior.SetNull); - - modelBuilder.Entity() - .HasOne(ban => ban.LastEditedBy) - .WithMany(author => author.AdminServerBansLastEdited) - .HasForeignKey(ban => ban.LastEditedById) - .HasPrincipalKey(author => author.UserId) - .OnDelete(DeleteBehavior.SetNull); - - modelBuilder.Entity() - .HasOne(ban => ban.CreatedBy) - .WithMany(author => author.AdminServerRoleBansCreated) - .HasForeignKey(ban => ban.BanningAdmin) - .HasPrincipalKey(author => author.UserId) - .OnDelete(DeleteBehavior.SetNull); - - modelBuilder.Entity() - .HasOne(ban => ban.LastEditedBy) - .WithMany(author => author.AdminServerRoleBansLastEdited) - .HasForeignKey(ban => ban.LastEditedById) - .HasPrincipalKey(author => author.UserId) - .OnDelete(DeleteBehavior.SetNull); - modelBuilder.Entity() .HasOne(w => w.Player) .WithMany(p => p.JobWhitelists) @@ -342,26 +285,6 @@ namespace Content.Server.Database .Property(p => p.Type) .HasDefaultValue(HwidType.Legacy); - modelBuilder.Entity() - .OwnsOne(p => p.HWId) - .Property(p => p.Hwid) - .HasColumnName("hwid"); - - modelBuilder.Entity() - .OwnsOne(p => p.HWId) - .Property(p => p.Type) - .HasDefaultValue(HwidType.Legacy); - - modelBuilder.Entity() - .OwnsOne(p => p.HWId) - .Property(p => p.Hwid) - .HasColumnName("hwid"); - - modelBuilder.Entity() - .OwnsOne(p => p.HWId) - .Property(p => p.Type) - .HasDefaultValue(HwidType.Legacy); - modelBuilder.Entity() .OwnsOne(p => p.HWId) .Property(p => p.Hwid) @@ -371,6 +294,8 @@ namespace Content.Server.Database .OwnsOne(p => p.HWId) .Property(p => p.Type) .HasDefaultValue(HwidType.Legacy); + + ModelBan.OnModelCreating(modelBuilder); } public virtual IQueryable SearchLogs(IQueryable query, string searchText) @@ -590,10 +515,8 @@ namespace Content.Server.Database public List AdminMessagesCreated { get; set; } = null!; public List AdminMessagesLastEdited { get; set; } = null!; public List AdminMessagesDeleted { get; set; } = null!; - public List AdminServerBansCreated { get; set; } = null!; - public List AdminServerBansLastEdited { get; set; } = null!; - public List AdminServerRoleBansCreated { get; set; } = null!; - public List AdminServerRoleBansLastEdited { get; set; } = null!; + public List AdminServerBansCreated { get; set; } = null!; + public List AdminServerBansLastEdited { get; set; } = null!; public List JobWhitelists { get; set; } = null!; } @@ -723,30 +646,6 @@ namespace Content.Server.Database [ForeignKey("RoundId,LogId")] public AdminLog Log { get; set; } = default!; } - // Used by SS14.Admin - public interface IBanCommon where TUnban : IUnbanCommon - { - int Id { get; set; } - Guid? PlayerUserId { get; set; } - NpgsqlInet? Address { get; set; } - TypedHwid? HWId { get; set; } - DateTime BanTime { get; set; } - DateTime? ExpirationTime { get; set; } - string Reason { get; set; } - NoteSeverity Severity { get; set; } - Guid? BanningAdmin { get; set; } - TUnban? Unban { get; set; } - } - - // Used by SS14.Admin - public interface IUnbanCommon - { - int Id { get; set; } - int BanId { get; set; } - Guid? UnbanningAdmin { get; set; } - DateTime UnbanTime { get; set; } - } - /// /// Flags for use with . /// @@ -784,138 +683,6 @@ namespace Content.Server.Database // @formatter:on } - /// - /// A ban from playing on the server. - /// If an incoming connection matches any of UserID, IP, or HWID, they will be blocked from joining the server. - /// - /// - /// At least one of UserID, IP, or HWID must be given (otherwise the ban would match nothing). - /// - [Table("server_ban"), Index(nameof(PlayerUserId))] - public class ServerBan : IBanCommon - { - public int Id { get; set; } - - [ForeignKey("Round")] - public int? RoundId { get; set; } - public Round? Round { get; set; } - - /// - /// The user ID of the banned player. - /// - public Guid? PlayerUserId { get; set; } - [Required] public TimeSpan PlaytimeAtNote { get; set; } - - /// - /// CIDR IP address range of the ban. The whole range can match the ban. - /// - public NpgsqlInet? Address { get; set; } - - /// - /// Hardware ID of the banned player. - /// - public TypedHwid? HWId { get; set; } - - /// - /// The time when the ban was applied by an administrator. - /// - public DateTime BanTime { get; set; } - - /// - /// The time the ban will expire. If null, the ban is permanent and will not expire naturally. - /// - public DateTime? ExpirationTime { get; set; } - - /// - /// The administrator-stated reason for applying the ban. - /// - public string Reason { get; set; } = null!; - - /// - /// The severity of the incident - /// - public NoteSeverity Severity { get; set; } - - /// - /// User ID of the admin that applied the ban. - /// - [ForeignKey("CreatedBy")] - public Guid? BanningAdmin { get; set; } - - public Player? CreatedBy { get; set; } - - /// - /// User ID of the admin that last edited the note - /// - [ForeignKey("LastEditedBy")] - public Guid? LastEditedById { get; set; } - - public Player? LastEditedBy { get; set; } - - /// - /// When the ban was last edited - /// - public DateTime? LastEditedAt { get; set; } - - /// - /// Optional flags that allow adding exemptions to the ban via . - /// - public ServerBanExemptFlags ExemptFlags { get; set; } - - /// - /// If present, an administrator has manually repealed this ban. - /// - public ServerUnban? Unban { get; set; } - - /// - /// Whether this ban should be automatically deleted from the database when it expires. - /// - /// - /// This isn't done automatically by the game, - /// you will need to set up something like a cron job to clear this from your database, - /// using a command like this: - /// psql -d ss14 -c "DELETE FROM server_ban WHERE auto_delete AND expiration_time < NOW()" - /// - public bool AutoDelete { get; set; } - - /// - /// Whether to display this ban in the admin remarks (notes) panel - /// - public bool Hidden { get; set; } - - public List BanHits { get; set; } = null!; - } - - /// - /// An explicit repeal of a by an administrator. - /// Having an entry for a ban neutralizes it. - /// - [Table("server_unban")] - public class ServerUnban : IUnbanCommon - { - [Column("unban_id")] public int Id { get; set; } - - /// - /// The ID of ban that is being repealed. - /// - public int BanId { get; set; } - - /// - /// The ban that is being repealed. - /// - public ServerBan Ban { get; set; } = null!; - - /// - /// The admin that repealed the ban. - /// - public Guid? UnbanningAdmin { get; set; } - - /// - /// The time the ban repealed. - /// - public DateTime UnbanTime { get; set; } - } - /// /// An exemption for a specific user to a certain type of . /// @@ -936,7 +703,7 @@ namespace Content.Server.Database /// /// The ban flags to exempt this player from. - /// If any bit overlaps , the ban is ignored. + /// If any bit overlaps , the ban is ignored. /// public ServerBanExemptFlags Flags { get; set; } } @@ -999,54 +766,10 @@ namespace Content.Server.Database public int BanId { get; set; } public int ConnectionId { get; set; } - public ServerBan Ban { get; set; } = null!; + public Ban Ban { get; set; } = null!; public ConnectionLog Connection { get; set; } = null!; } - [Table("server_role_ban"), Index(nameof(PlayerUserId))] - public sealed class ServerRoleBan : IBanCommon - { - public int Id { get; set; } - public int? RoundId { get; set; } - public Round? Round { get; set; } - public Guid? PlayerUserId { get; set; } - [Required] public TimeSpan PlaytimeAtNote { get; set; } - public NpgsqlInet? Address { get; set; } - public TypedHwid? HWId { get; set; } - - public DateTime BanTime { get; set; } - - public DateTime? ExpirationTime { get; set; } - - public string Reason { get; set; } = null!; - - public NoteSeverity Severity { get; set; } - [ForeignKey("CreatedBy")] public Guid? BanningAdmin { get; set; } - public Player? CreatedBy { get; set; } - - [ForeignKey("LastEditedBy")] public Guid? LastEditedById { get; set; } - public Player? LastEditedBy { get; set; } - public DateTime? LastEditedAt { get; set; } - - public ServerRoleUnban? Unban { get; set; } - public bool Hidden { get; set; } - - public string RoleId { get; set; } = null!; - } - - [Table("server_role_unban")] - public sealed class ServerRoleUnban : IUnbanCommon - { - [Column("role_unban_id")] public int Id { get; set; } - - public int BanId { get; set; } - public ServerRoleBan Ban { get; set; } = null!; - - public Guid? UnbanningAdmin { get; set; } - - public DateTime UnbanTime { get; set; } - } - [Table("play_time")] public sealed class PlayTime { @@ -1246,31 +969,31 @@ namespace Content.Server.Database /// /// The reason for the ban. /// - /// + /// public string Reason { get; set; } = ""; /// /// Exemptions granted to the ban. /// - /// + /// public ServerBanExemptFlags ExemptFlags { get; set; } /// /// Severity of the ban /// - /// + /// public NoteSeverity Severity { get; set; } /// /// Ban will be automatically deleted once expired. /// - /// + /// public bool AutoDelete { get; set; } /// /// Ban is not visible to players in the remarks menu. /// - /// + /// public bool Hidden { get; set; } } diff --git a/Content.Server.Database/ModelPostgres.cs b/Content.Server.Database/ModelPostgres.cs index 7499d0b0f5..c3f41b7058 100644 --- a/Content.Server.Database/ModelPostgres.cs +++ b/Content.Server.Database/ModelPostgres.cs @@ -39,10 +39,7 @@ namespace Content.Server.Database // ReSharper disable StringLiteralTypo // Enforce that an address cannot be IPv6-mapped IPv4. // So that IPv4 addresses are consistent between separate-socket and dual-stack socket modes. - modelBuilder.Entity().ToTable(t => - t.HasCheckConstraint("AddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= address")); - - modelBuilder.Entity().ToTable( t => + modelBuilder.Entity().ToTable(t => t.HasCheckConstraint("AddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= address")); modelBuilder.Entity().ToTable(t => diff --git a/Content.Server.Database/ModelSqlite.cs b/Content.Server.Database/ModelSqlite.cs index 5a993bdbfa..11882bfd13 100644 --- a/Content.Server.Database/ModelSqlite.cs +++ b/Content.Server.Database/ModelSqlite.cs @@ -58,13 +58,7 @@ namespace Content.Server.Database ); modelBuilder - .Entity() - .Property(e => e.Address) - .HasColumnType("TEXT") - .HasConversion(ipMaskConverter); - - modelBuilder - .Entity() + .Entity() .Property(e => e.Address) .HasColumnType("TEXT") .HasConversion(ipMaskConverter); diff --git a/Content.Server/Administration/BanList/BanListEui.cs b/Content.Server/Administration/BanList/BanListEui.cs index 2ca126bf16..549a14f673 100644 --- a/Content.Server/Administration/BanList/BanListEui.cs +++ b/Content.Server/Administration/BanList/BanListEui.cs @@ -1,9 +1,12 @@ -using System.Threading.Tasks; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; using Content.Server.Administration.Managers; using Content.Server.Database; using Content.Server.EUI; using Content.Shared.Administration; using Content.Shared.Administration.BanList; +using Content.Shared.Database; using Content.Shared.Eui; using Robust.Shared.Network; @@ -22,8 +25,8 @@ public sealed class BanListEui : BaseEui private Guid BanListPlayer { get; set; } private string BanListPlayerName { get; set; } = string.Empty; - private List Bans { get; } = new(); - private List RoleBans { get; } = new(); + private List Bans { get; } = new(); + private List RoleBans { get; } = new(); public override void Opened() { @@ -54,74 +57,38 @@ public sealed class BanListEui : BaseEui private async Task LoadBans(NetUserId userId) { - foreach (var ban in await _db.GetServerBansAsync(null, userId, null, null)) - { - SharedServerUnban? unban = null; - if (ban.Unban is { } unbanDef) - { - var unbanningAdmin = unbanDef.UnbanningAdmin == null - ? null - : (await _playerLocator.LookupIdAsync(unbanDef.UnbanningAdmin.Value))?.Username; - unban = new SharedServerUnban(unbanningAdmin, ban.Unban.UnbanTime.UtcDateTime); - } - - (string, int cidrMask)? ip = ("*Hidden*", 0); - var hwid = "*Hidden*"; - - if (_admins.HasAdminFlag(Player, AdminFlags.Pii)) - { - ip = ban.Address is { } address - ? (address.address.ToString(), address.cidrMask) - : null; - - hwid = ban.HWId?.ToString(); - } - - Bans.Add(new SharedServerBan( - ban.Id, - ban.UserId, - ip, - hwid, - ban.BanTime.UtcDateTime, - ban.ExpirationTime?.UtcDateTime, - ban.Reason, - ban.BanningAdmin == null - ? null - : (await _playerLocator.LookupIdAsync(ban.BanningAdmin.Value))?.Username, - unban - )); - } + await LoadBansCore(userId, BanType.Server, Bans); + await LoadBansCore(userId, BanType.Role, RoleBans); } - private async Task LoadRoleBans(NetUserId userId) + private async Task LoadBansCore(NetUserId userId, BanType banType, List list) { - foreach (var ban in await _db.GetServerRoleBansAsync(null, userId, null, null)) + foreach (var ban in await _db.GetBansAsync(null, userId, null, null, type: banType)) { - SharedServerUnban? unban = null; + SharedUnban? unban = null; if (ban.Unban is { } unbanDef) { var unbanningAdmin = unbanDef.UnbanningAdmin == null ? null : (await _playerLocator.LookupIdAsync(unbanDef.UnbanningAdmin.Value))?.Username; - unban = new SharedServerUnban(unbanningAdmin, ban.Unban.UnbanTime.UtcDateTime); + unban = new SharedUnban(unbanningAdmin, ban.Unban.UnbanTime.UtcDateTime); } - (string, int cidrMask)? ip = ("*Hidden*", 0); - var hwid = "*Hidden*"; + ImmutableArray<(string, int cidrMask)> ips = [("*Hidden*", 0)]; + ImmutableArray hwids = ["*Hidden*"]; if (_admins.HasAdminFlag(Player, AdminFlags.Pii)) { - ip = ban.Address is { } address - ? (address.address.ToString(), address.cidrMask) - : null; - - hwid = ban.HWId?.ToString(); + ips = [..ban.Addresses.Select(a => (a.address.ToString(), a.cidrMask))]; + hwids = [..ban.HWIds.Select(h => h.ToString())]; } - RoleBans.Add(new SharedServerRoleBan( + + list.Add(new SharedBan( ban.Id, - ban.UserId, - ip, - hwid, + ban.Type, + ban.UserIds, + ips, + hwids, ban.BanTime.UtcDateTime, ban.ExpirationTime?.UtcDateTime, ban.Reason, @@ -129,7 +96,7 @@ public sealed class BanListEui : BaseEui ? null : (await _playerLocator.LookupIdAsync(ban.BanningAdmin.Value))?.Username, unban, - ban.Role + ban.Roles )); } } @@ -144,7 +111,6 @@ public sealed class BanListEui : BaseEui string.Empty; await LoadBans(userId); - await LoadRoleBans(userId); StateDirty(); } diff --git a/Content.Server/Administration/BanPanelEui.cs b/Content.Server/Administration/BanPanelEui.cs index 4a4b721872..fceb2b5750 100644 --- a/Content.Server/Administration/BanPanelEui.cs +++ b/Content.Server/Administration/BanPanelEui.cs @@ -26,8 +26,8 @@ public sealed class BanPanelEui : BaseEui private string PlayerName { get; set; } = string.Empty; private IPAddress? LastAddress { get; set; } private ImmutableTypedHwid? LastHwid { get; set; } - private const int Ipv4_CIDR = 32; - private const int Ipv6_CIDR = 64; + private const int Ipv4_CIDR = CreateBanInfo.DefaultMaskIpv4; + private const int Ipv6_CIDR = CreateBanInfo.DefaultMaskIpv6; public BanPanelEui() { @@ -73,6 +73,15 @@ public sealed class BanPanelEui : BaseEui return; } + var isRoleBan = ban.BannedJobs?.Length > 0 || ban.BannedAntags?.Length > 0; + + CreateBanInfo banInfo = isRoleBan ? new CreateRoleBanInfo(ban.Reason) : new CreateServerBanInfo(ban.Reason); + + banInfo.WithBanningAdmin(Player.UserId); + banInfo.WithSeverity(ban.Severity); + if (ban.BanDurationMinutes > 0) + banInfo.WithMinutes(ban.BanDurationMinutes); + (IPAddress, int)? addressRange = null; if (ban.IpAddress is not null) { @@ -113,69 +122,46 @@ public sealed class BanPanelEui : BaseEui targetHWid = ban.UseLastHwid ? located.LastHWId : ban.Hwid; } - if (ban.BannedJobs?.Length > 0 || ban.BannedAntags?.Length > 0) + if (addressRange != null) + banInfo.AddAddressRange(addressRange.Value); + + if (targetUid != null) + banInfo.AddUser(targetUid.Value, ban.Target!); + + banInfo.AddHWId(targetHWid); + + if (isRoleBan) { - var now = DateTimeOffset.UtcNow; - foreach (var role in ban.BannedJobs ?? []) + var roleBanInfo = (CreateRoleBanInfo)banInfo; + foreach (var row in ban.BannedJobs ?? []) { - _banManager.CreateRoleBan( - targetUid, - ban.Target, - Player.UserId, - addressRange, - targetHWid, - role, - ban.BanDurationMinutes, - ban.Severity, - ban.Reason, - now - ); + roleBanInfo.AddJob(row); } - foreach (var role in ban.BannedAntags ?? []) + foreach (var row in ban.BannedAntags ?? []) { - _banManager.CreateRoleBan( - targetUid, - ban.Target, - Player.UserId, - addressRange, - targetHWid, - role, - ban.BanDurationMinutes, - ban.Severity, - ban.Reason, - now - ); + roleBanInfo.AddAntag(row); } - Close(); - - return; + _banManager.CreateRoleBan(roleBanInfo); } - - if (ban.Erase && targetUid is not null) + else { - try + if (ban.Erase && targetUid is not null) { - if (_entities.TrySystem(out AdminSystem? adminSystem)) - adminSystem.Erase(targetUid.Value); + try + { + if (_entities.TrySystem(out AdminSystem? adminSystem)) + adminSystem.Erase(targetUid.Value); + } + catch (Exception e) + { + _sawmill.Error($"Error while erasing banned player:\n{e}"); + } } - catch (Exception e) - { - _sawmill.Error($"Error while erasing banned player:\n{e}"); - } - } - _banManager.CreateServerBan( - targetUid, - ban.Target, - Player.UserId, - addressRange, - targetHWid, - ban.BanDurationMinutes, - ban.Severity, - ban.Reason - ); + _banManager.CreateServerBan((CreateServerBanInfo)banInfo); + } Close(); } diff --git a/Content.Server/Administration/Commands/BanCommand.cs b/Content.Server/Administration/Commands/BanCommand.cs index f76cfded81..a6d3b10646 100644 --- a/Content.Server/Administration/Commands/BanCommand.cs +++ b/Content.Server/Administration/Commands/BanCommand.cs @@ -90,7 +90,15 @@ public sealed class BanCommand : LocalizedCommands var targetUid = located.UserId; var targetHWid = located.LastHWId; - _bans.CreateServerBan(targetUid, target, player?.UserId, null, targetHWid, minutes, severity, reason); + var banInfo = new CreateServerBanInfo(reason); + banInfo.WithBanningAdmin(player?.UserId); + banInfo.AddUser(targetUid, target); + banInfo.AddHWId(targetHWid); + if (minutes > 0) + banInfo.WithMinutes(minutes); + banInfo.WithSeverity(severity); + + _bans.CreateServerBan(banInfo); } public override CompletionResult GetCompletion(IConsoleShell shell, string[] args) diff --git a/Content.Server/Administration/Commands/BanListCommand.cs b/Content.Server/Administration/Commands/BanListCommand.cs index ea68788deb..26a8647248 100644 --- a/Content.Server/Administration/Commands/BanListCommand.cs +++ b/Content.Server/Administration/Commands/BanListCommand.cs @@ -39,7 +39,7 @@ public sealed class BanListCommand : LocalizedCommands if (shell.Player is not { } player) { - var bans = await _dbManager.GetServerBansAsync(data.LastAddress, data.UserId, data.LastLegacyHWId, data.LastModernHWIds, false); + var bans = await _dbManager.GetBansAsync(data.LastAddress, data.UserId, data.LastLegacyHWId, data.LastModernHWIds, false); if (bans.Count == 0) { diff --git a/Content.Server/Administration/Commands/DepartmentBanCommand.cs b/Content.Server/Administration/Commands/DepartmentBanCommand.cs index 15f9859ca1..40faeb7404 100644 --- a/Content.Server/Administration/Commands/DepartmentBanCommand.cs +++ b/Content.Server/Administration/Commands/DepartmentBanCommand.cs @@ -96,13 +96,20 @@ public sealed class DepartmentBanCommand : IConsoleCommand var targetUid = located.UserId; var targetHWid = located.LastHWId; - // If you are trying to remove the following variable, please don't. It's there because the note system groups role bans by time, reason and banning admin. - // Without it the note list will get needlessly cluttered. - var now = DateTimeOffset.UtcNow; + var banInfo = new CreateRoleBanInfo(reason); + if (minutes > 0) + banInfo.WithMinutes(minutes); + banInfo.AddUser(targetUid, located.Username); + banInfo.WithBanningAdmin(shell.Player?.UserId); + banInfo.AddHWId(targetHWid); + banInfo.WithSeverity(severity); + foreach (var job in departmentProto.Roles) { - _banManager.CreateRoleBan(targetUid, located.Username, shell.Player?.UserId, null, targetHWid, job, minutes, severity, reason, now); + banInfo.AddJob(job); } + + _banManager.CreateRoleBan(banInfo); } public CompletionResult GetCompletion(IConsoleShell shell, string[] args) diff --git a/Content.Server/Administration/Commands/OpenAdminNotesCommand.cs b/Content.Server/Administration/Commands/OpenAdminNotesCommand.cs index 5577e13437..35a6d1096a 100644 --- a/Content.Server/Administration/Commands/OpenAdminNotesCommand.cs +++ b/Content.Server/Administration/Commands/OpenAdminNotesCommand.cs @@ -3,6 +3,7 @@ using Content.Server.Administration.Notes; using Content.Shared.Administration; using Robust.Server.Player; using Robust.Shared.Console; +using Robust.Shared.Network; namespace Content.Server.Administration.Commands; @@ -46,7 +47,7 @@ public sealed class OpenAdminNotesCommand : LocalizedCommands return; } - await _adminNotes.OpenEui(player, notedPlayer); + await _adminNotes.OpenEui(player, new NetUserId(notedPlayer)); } public override CompletionResult GetCompletion(IConsoleShell shell, string[] args) diff --git a/Content.Server/Administration/Commands/PardonCommand.cs b/Content.Server/Administration/Commands/PardonCommand.cs index 5c4417a966..eb292663e2 100644 --- a/Content.Server/Administration/Commands/PardonCommand.cs +++ b/Content.Server/Administration/Commands/PardonCommand.cs @@ -27,7 +27,7 @@ namespace Content.Server.Administration.Commands return; } - var ban = await _dbManager.GetServerBanAsync(banId); + var ban = await _dbManager.GetBanAsync(banId); if (ban == null) { @@ -50,7 +50,7 @@ namespace Content.Server.Administration.Commands return; } - await _dbManager.AddServerUnbanAsync(new ServerUnbanDef(banId, player?.UserId, DateTimeOffset.Now)); + await _dbManager.AddUnbanAsync(new UnbanDef(banId, player?.UserId, DateTimeOffset.Now)); shell.WriteLine(Loc.GetString($"cmd-pardon-success", ("id", banId))); } diff --git a/Content.Server/Administration/Commands/RoleBanCommand.cs b/Content.Server/Administration/Commands/RoleBanCommand.cs index c49af32881..f303f31b86 100644 --- a/Content.Server/Administration/Commands/RoleBanCommand.cs +++ b/Content.Server/Administration/Commands/RoleBanCommand.cs @@ -1,6 +1,4 @@ -using System.Linq; -using System.Text; -using Content.Server.Administration.Managers; +using Content.Server.Administration.Managers; using Content.Shared.Administration; using Content.Shared.CCVar; using Content.Shared.Database; @@ -99,12 +97,29 @@ public sealed class RoleBanCommand : IConsoleCommand var targetUid = located.UserId; var targetHWid = located.LastHWId; + var banInfo = new CreateRoleBanInfo(reason); + if (minutes > 0) + banInfo.WithMinutes(minutes); + banInfo.AddUser(targetUid, located.Username); + banInfo.WithBanningAdmin(shell.Player?.UserId); + banInfo.AddHWId(targetHWid); + banInfo.WithSeverity(severity); + if (_proto.HasIndex(role)) - _bans.CreateRoleBan(targetUid, located.Username, shell.Player?.UserId, null, targetHWid, role, minutes, severity, reason, DateTimeOffset.UtcNow); + { + banInfo.AddJob(new ProtoId(role)); + } else if (_proto.HasIndex(role)) - _bans.CreateRoleBan(targetUid, located.Username, shell.Player?.UserId, null, targetHWid, role, minutes, severity, reason, DateTimeOffset.UtcNow); + { + banInfo.AddAntag(new ProtoId(role)); + } else + { shell.WriteError(Loc.GetString("cmd-roleban-job-parse", ("job", role))); + return; + } + + _bans.CreateRoleBan(banInfo); } public CompletionResult GetCompletion(IConsoleShell shell, string[] args) diff --git a/Content.Server/Administration/Commands/RoleBanListCommand.cs b/Content.Server/Administration/Commands/RoleBanListCommand.cs index 8244ded3b2..4abd406cbc 100644 --- a/Content.Server/Administration/Commands/RoleBanListCommand.cs +++ b/Content.Server/Administration/Commands/RoleBanListCommand.cs @@ -1,10 +1,8 @@ -using System.Linq; -using System.Text; -using Content.Server.Administration.BanList; +using Content.Server.Administration.BanList; using Content.Server.EUI; using Content.Server.Database; using Content.Shared.Administration; -using Robust.Server.Player; +using Content.Shared.Database; using Robust.Shared.Console; namespace Content.Server.Administration.Commands; @@ -48,7 +46,7 @@ public sealed class RoleBanListCommand : IConsoleCommand if (shell.Player is not { } player) { - var bans = await _dbManager.GetServerRoleBansAsync(data.LastAddress, data.UserId, data.LastLegacyHWId, data.LastModernHWIds, includeUnbanned); + var bans = await _dbManager.GetBansAsync(data.LastAddress, data.UserId, data.LastLegacyHWId, data.LastModernHWIds, includeUnbanned, type: BanType.Role); if (bans.Count == 0) { @@ -58,7 +56,7 @@ public sealed class RoleBanListCommand : IConsoleCommand foreach (var ban in bans) { - var msg = $"ID: {ban.Id}: Role: {ban.Role} Reason: {ban.Reason}"; + var msg = $"ID: {ban.Id}: Role(s): {string.Join(",", ban.Roles ?? [])} Reason: {ban.Reason}"; shell.WriteLine(msg); } return; diff --git a/Content.Server/Administration/Managers/BanManager.Notification.cs b/Content.Server/Administration/Managers/BanManager.Notification.cs index ff84887f00..d627dc508f 100644 --- a/Content.Server/Administration/Managers/BanManager.Notification.cs +++ b/Content.Server/Administration/Managers/BanManager.Notification.cs @@ -41,14 +41,8 @@ public sealed partial class BanManager private async void ProcessBanNotification(BanNotificationData data) { - if ((await _entryManager.ServerEntity).Id == data.ServerId) - { - _sawmill.Verbose("Not processing ban notification: came from this server"); - return; - } - _sawmill.Verbose($"Processing ban notification for ban {data.BanId}"); - var ban = await _db.GetServerBanAsync(data.BanId); + var ban = await _db.GetBanAsync(data.BanId); if (ban == null) { _sawmill.Warning($"Ban in notification ({data.BanId}) didn't exist?"); @@ -86,15 +80,5 @@ public sealed partial class BanManager /// [JsonRequired, JsonPropertyName("ban_id")] public int BanId { get; init; } - - /// - /// The id of the server the ban was made on. - /// This is used to avoid double work checking the ban on the originating server. - /// - /// - /// This is optional in case the ban was made outside a server (SS14.Admin) - /// - [JsonPropertyName("server_id")] - public int? ServerId { get; init; } } } diff --git a/Content.Server/Administration/Managers/BanManager.cs b/Content.Server/Administration/Managers/BanManager.cs index 17f796e699..ccf76e3995 100644 --- a/Content.Server/Administration/Managers/BanManager.cs +++ b/Content.Server/Administration/Managers/BanManager.cs @@ -1,6 +1,5 @@ using System.Collections.Immutable; using System.Linq; -using System.Net; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -21,6 +20,7 @@ using Robust.Shared.Network; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Timing; +using Robust.Shared.Utility; namespace Content.Server.Administration.Managers; @@ -43,10 +43,10 @@ public sealed partial class BanManager : IBanManager, IPostInjectInit private ISawmill _sawmill = default!; public const string SawmillId = "admin.bans"; - public const string PrefixAntag = "Antag:"; - public const string PrefixJob = "Job:"; + public const string DbTypeAntag = "Antag"; + public const string DbTypeJob = "Job"; - private readonly Dictionary> _cachedRoleBans = new(); + private readonly Dictionary> _cachedRoleBans = new(); // Cached ban exemption flags are used to handle private readonly Dictionary _cachedBanExemptions = new(); @@ -72,9 +72,15 @@ public sealed partial class BanManager : IBanManager, IPostInjectInit var netChannel = player.Channel; ImmutableArray? hwId = netChannel.UserData.HWId.Length == 0 ? null : netChannel.UserData.HWId; var modernHwids = netChannel.UserData.ModernHWIds; - var roleBans = await _db.GetServerRoleBansAsync(netChannel.RemoteEndPoint.Address, player.UserId, hwId, modernHwids, false); + var roleBans = await _db.GetBansAsync( + netChannel.RemoteEndPoint.Address, + player.UserId, + hwId, + modernHwids, + false, + type: BanType.Role); - var userRoleBans = new List(); + var userRoleBans = new List(); foreach (var ban in roleBans) { userRoleBans.Add(ban); @@ -115,43 +121,37 @@ public sealed partial class BanManager : IBanManager, IPostInjectInit } #region Server Bans - public async void CreateServerBan(NetUserId? target, string? targetUsername, NetUserId? banningAdmin, (IPAddress, int)? addressRange, ImmutableTypedHwid? hwid, uint? minutes, NoteSeverity severity, string reason) + public async void CreateServerBan(CreateServerBanInfo banInfo) { - DateTimeOffset? expires = null; - if (minutes > 0) + var (banDef, expires) = await CreateBanDef(banInfo, BanType.Server, null); + + await _db.AddBanAsync(banDef); + + if (_cfg.GetCVar(CCVars.ServerBanResetLastReadRules)) { - expires = DateTimeOffset.Now + TimeSpan.FromMinutes(minutes.Value); + // Reset their last read rules. They probably need a refresher! + foreach (var (userId, _) in banInfo.Users) + { + await _db.SetLastReadRules(userId, null); + } } - _systems.TryGetEntitySystem(out var ticker); - int? roundId = ticker == null || ticker.RoundId == 0 ? null : ticker.RoundId; - var playtime = target == null ? TimeSpan.Zero : (await _db.GetPlayTimes(target.Value)).Find(p => p.Tracker == PlayTimeTrackingShared.TrackerOverall)?.TimeSpent ?? TimeSpan.Zero; - - var banDef = new ServerBanDef( - null, - target, - addressRange, - hwid, - DateTimeOffset.Now, - expires, - roundId, - playtime, - reason, - severity, - banningAdmin, - null); - - await _db.AddServerBanAsync(banDef); - if (_cfg.GetCVar(CCVars.ServerBanResetLastReadRules) && target != null) - await _db.SetLastReadRules(target.Value, null); // Reset their last read rules. They probably need a refresher! - var adminName = banningAdmin == null + var adminName = banInfo.BanningAdmin == null ? Loc.GetString("system-user") - : (await _db.GetPlayerRecordByUserId(banningAdmin.Value))?.LastSeenUserName ?? Loc.GetString("system-user"); - var targetName = target is null ? "null" : $"{targetUsername} ({target})"; - var addressRangeString = addressRange != null - ? $"{addressRange.Value.Item1}/{addressRange.Value.Item2}" - : "null"; - var hwidString = hwid?.ToString() ?? "null"; + : (await _db.GetPlayerRecordByUserId(banInfo.BanningAdmin.Value))?.LastSeenUserName ?? Loc.GetString("system-user"); + + var targetName = banInfo.Users.Count == 0 + ? "null" + : string.Join(", ", banInfo.Users.Select(u => $"{u.UserName} ({u.UserId})")); + + var addressRangeString = banInfo.AddressRanges.Count != 0 + ? "null" + : string.Join(", ", banInfo.AddressRanges.Select(a => $"{a.Address}/{a.Mask}")); + + var hwidString = banInfo.HWIds.Count == 0 + ? "null" + : string.Join(", ", banInfo.HWIds); + var expiresString = expires == null ? Loc.GetString("server-ban-string-never") : $"{expires}"; var key = _cfg.GetCVar(CCVars.AdminShowPIIOnBan) ? "server-ban-string" : "server-ban-string-no-pii"; @@ -159,12 +159,12 @@ public sealed partial class BanManager : IBanManager, IPostInjectInit var logMessage = Loc.GetString( key, ("admin", adminName), - ("severity", severity), + ("severity", banDef.Severity), ("expires", expiresString), ("name", targetName), ("ip", addressRangeString), ("hwid", hwidString), - ("reason", reason)); + ("reason", banInfo.Reason)); _sawmill.Info(logMessage); _chat.SendAdminAlert(logMessage); @@ -172,7 +172,19 @@ public sealed partial class BanManager : IBanManager, IPostInjectInit KickMatchingConnectedPlayers(banDef, "newly placed ban"); } - private void KickMatchingConnectedPlayers(ServerBanDef def, string source) + private NoteSeverity GetSeverityForServerBan(CreateBanInfo banInfo, CVarDef defaultCVar) + { + if (banInfo.Severity != null) + return banInfo.Severity.Value; + + if (Enum.TryParse(_cfg.GetCVar(defaultCVar), true, out NoteSeverity parsedSeverity)) + return parsedSeverity; + + _sawmill.Error($"CVar {defaultCVar.Name} has invalid ban severity!"); + return NoteSeverity.None; + } + + private void KickMatchingConnectedPlayers(BanDef def, string source) { foreach (var player in _playerManager.Sessions) { @@ -184,7 +196,7 @@ public sealed partial class BanManager : IBanManager, IPostInjectInit } } - private bool BanMatchesPlayer(ICommonSession player, ServerBanDef ban) + private bool BanMatchesPlayer(ICommonSession player, BanDef ban) { var playerInfo = new BanMatcher.PlayerInfo { @@ -201,7 +213,7 @@ public sealed partial class BanManager : IBanManager, IPostInjectInit return BanMatcher.BanMatches(ban, playerInfo); } - private void KickForBanDef(ICommonSession player, ServerBanDef def) + private void KickForBanDef(ICommonSession player, BanDef def) { var message = def.FormatBanMessage(_cfg, _localizationManager); player.Channel.Disconnect(message); @@ -211,108 +223,154 @@ public sealed partial class BanManager : IBanManager, IPostInjectInit #region Role Bans - // If you are trying to remove timeOfBan, please don't. It's there because the note system groups role bans by time, reason and banning admin. - // Removing it will clutter the note list. Please also make sure that department bans are applied to roles with the same DateTimeOffset. - public async void CreateRoleBan( - NetUserId? target, - string? targetUsername, - NetUserId? banningAdmin, - (IPAddress, int)? addressRange, - ImmutableTypedHwid? hwid, - ProtoId role, - uint? minutes, - NoteSeverity severity, - string reason, - DateTimeOffset timeOfBan - ) where T : class, IPrototype + public async void CreateRoleBan(CreateRoleBanInfo banInfo) { - string encodedRole; + ImmutableArray roleDefs = + [ + .. ToBanRoleDef(banInfo.JobPrototypes), + .. ToBanRoleDef(banInfo.AntagPrototypes), + ]; - // TODO: Note that it's possible to clash IDs here between a job and an antag. The refactor that introduced - // this check has consciously avoided refactoring Job and Antag prototype. - // Refactor Job- and Antag- Prototype to introduce a common RolePrototype, which will fix this possible clash. + if (roleDefs.Length == 0) + throw new ArgumentException("Must specify at least one role to ban!"); - //TODO remove this check as part of the above refactor - if (_prototypeManager.HasIndex(role) && _prototypeManager.HasIndex(role)) + var (banDef, expires) = await CreateBanDef(banInfo, BanType.Role, roleDefs); + + await AddRoleBan(banDef); + + var length = expires == null + ? Loc.GetString("cmd-roleban-inf") + : Loc.GetString("cmd-roleban-until", ("expires", expires)); + + var targetName = banInfo.Users.Count == 0 + ? "null" + : string.Join(", ", banInfo.Users.Select(u => $"{u.UserName} ({u.UserId})")); + + _chat.SendAdminAlert(Loc.GetString( + "cmd-roleban-success", + ("target", targetName), + ("role", string.Join(", ", roleDefs)), + ("reason", banInfo.Reason), + ("length", length))); + + foreach (var (userId, _) in banInfo.Users) { - _sawmill.Error($"Creating role ban for {role}: cannot create role ban, role is both JobPrototype and AntagPrototype."); - - return; + if (_playerManager.TryGetSessionById(userId, out var session)) + SendRoleBans(session); } - - // Don't trust the input: make sure the job or antag actually exists. - if (_prototypeManager.HasIndex(role)) - encodedRole = PrefixJob + role; - else if (_prototypeManager.HasIndex(role)) - encodedRole = PrefixAntag + role; - else - { - _sawmill.Error($"Creating role ban for {role}: cannot create role ban, role is not a JobPrototype or an AntagPrototype."); - - return; - } - - DateTimeOffset? expires = null; - - if (minutes > 0) - expires = DateTimeOffset.Now + TimeSpan.FromMinutes(minutes.Value); - - _systems.TryGetEntitySystem(out GameTicker? ticker); - int? roundId = ticker == null || ticker.RoundId == 0 ? null : ticker.RoundId; - var playtime = target == null ? TimeSpan.Zero : (await _db.GetPlayTimes(target.Value)).Find(p => p.Tracker == PlayTimeTrackingShared.TrackerOverall)?.TimeSpent ?? TimeSpan.Zero; - - var banDef = new ServerRoleBanDef( - null, - target, - addressRange, - hwid, - timeOfBan, - expires, - roundId, - playtime, - reason, - severity, - banningAdmin, - null, - encodedRole); - - if (!await AddRoleBan(banDef)) - { - _chat.SendAdminAlert(Loc.GetString("cmd-roleban-existing", ("target", targetUsername ?? "null"), ("role", role))); - - return; - } - - var length = expires == null ? Loc.GetString("cmd-roleban-inf") : Loc.GetString("cmd-roleban-until", ("expires", expires)); - _chat.SendAdminAlert(Loc.GetString("cmd-roleban-success", ("target", targetUsername ?? "null"), ("role", role), ("reason", reason), ("length", length))); - - if (target is not null && _playerManager.TryGetSessionById(target.Value, out var session)) - SendRoleBans(session); } - private async Task AddRoleBan(ServerRoleBanDef banDef) + private async Task<(BanDef Ban, DateTimeOffset? Expires)> CreateBanDef( + CreateBanInfo banInfo, + BanType type, + ImmutableArray? roleBans) { - banDef = await _db.AddServerRoleBanAsync(banDef); + if (banInfo.Users.Count == 0 && banInfo.HWIds.Count == 0 && banInfo.AddressRanges.Count == 0) + throw new ArgumentException("Must specify at least one user, HWID, or address range"); - if (banDef.UserId != null - && _playerManager.TryGetSessionById(banDef.UserId, out var player) - && _cachedRoleBans.TryGetValue(player, out var cachedBans)) + DateTimeOffset? expires = null; + if (banInfo.Duration is { } duration) + expires = DateTimeOffset.Now + duration; + + ImmutableArray roundIds; + if (banInfo.RoundIds.Count > 0) { - cachedBans.Add(banDef); + roundIds = [..banInfo.RoundIds]; + } + else if (_systems.TryGetEntitySystem(out var ticker) && ticker.RoundId != 0) + { + roundIds = [ticker.RoundId]; + } + else + { + roundIds = []; } - return true; + return (new BanDef( + null, + type, + [..banInfo.Users.Select(u => u.UserId)], + [..banInfo.AddressRanges], + [..banInfo.HWIds], + DateTimeOffset.Now, + expires, + roundIds, + await GetPlayTime(banInfo), + banInfo.Reason, + GetSeverityForServerBan(banInfo, CCVars.ServerBanDefaultSeverity), + banInfo.BanningAdmin, + null, + roles: roleBans), expires); + } + + private async Task GetPlayTime(CreateBanInfo banInfo) + { + var firstPlayer = banInfo.Users.FirstOrNull()?.UserId; + if (firstPlayer == null) + return TimeSpan.Zero; + + return (await _db.GetPlayTimes(firstPlayer.Value)) + .Find(p => p.Tracker == PlayTimeTrackingShared.TrackerOverall) + ?.TimeSpent ?? TimeSpan.Zero; + } + + private IEnumerable ToBanRoleDef(IEnumerable> protoIds) where T : class, IPrototype + { + return protoIds.Select(protoId => + { + // TODO: I have no idea if this check is necessary. The previous code was a complete mess, + // so out of safety I'm leaving this in. + if (_prototypeManager.HasIndex(protoId) && _prototypeManager.HasIndex(protoId)) + { + throw new InvalidOperationException( + $"Creating role ban for {protoId}: cannot create role ban, role is both JobPrototype and AntagPrototype."); + } + + // Don't trust the input: make sure the role actually exists. + if (!_prototypeManager.HasIndex(protoId)) + throw new UnknownPrototypeException(protoId, typeof(T)); + + return new BanRoleDef(PrototypeKindToDbType(), protoId); + }); + } + + private static string PrototypeKindToDbType() where T : class, IPrototype + { + if (typeof(T) == typeof(JobPrototype)) + return DbTypeJob; + + if (typeof(T) == typeof(AntagPrototype)) + return DbTypeAntag; + + throw new ArgumentException($"Unknown prototype kind for role bans: {typeof(T)}"); + } + + private async Task AddRoleBan(BanDef banDef) + { + banDef = await _db.AddBanAsync(banDef); + + foreach (var user in banDef.UserIds) + { + if (_playerManager.TryGetSessionById(user, out var player) + && _cachedRoleBans.TryGetValue(player, out var cachedBans)) + { + cachedBans.Add(banDef); + } + } } public async Task PardonRoleBan(int banId, NetUserId? unbanningAdmin, DateTimeOffset unbanTime) { - var ban = await _db.GetServerRoleBanAsync(banId); + var ban = await _db.GetBanAsync(banId); if (ban == null) { return $"No ban found with id {banId}"; } + if (ban.Type != BanType.Role) + throw new InvalidOperationException("Ban was not a role ban!"); + if (ban.Unban != null) { var response = new StringBuilder("This ban has already been pardoned"); @@ -326,14 +384,17 @@ public sealed partial class BanManager : IBanManager, IPostInjectInit return response.ToString(); } - await _db.AddServerRoleUnbanAsync(new ServerRoleUnbanDef(banId, unbanningAdmin, DateTimeOffset.Now)); + await _db.AddUnbanAsync(new UnbanDef(banId, unbanningAdmin, DateTimeOffset.Now)); - if (ban.UserId is { } player - && _playerManager.TryGetSessionById(player, out var session) - && _cachedRoleBans.TryGetValue(session, out var roleBans)) + foreach (var user in ban.UserIds) { - roleBans.RemoveAll(roleBan => roleBan.Id == ban.Id); - SendRoleBans(session); + if (_playerManager.TryGetSessionById(user, out var session) + && _cachedRoleBans.TryGetValue(session, out var roleBans)) + { + roleBans.RemoveAll(roleBan => roleBan.Id == ban.Id); + SendRoleBans(session); + } + } return $"Pardoned ban with id {banId}"; @@ -341,64 +402,69 @@ public sealed partial class BanManager : IBanManager, IPostInjectInit public HashSet>? GetJobBans(NetUserId playerUserId) { - return GetRoleBans(playerUserId, PrefixJob); + return GetRoleBans(playerUserId); } public HashSet>? GetAntagBans(NetUserId playerUserId) { - return GetRoleBans(playerUserId, PrefixAntag); + return GetRoleBans(playerUserId); } - private HashSet>? GetRoleBans(NetUserId playerUserId, string prefix) where T : class, IPrototype + private HashSet>? GetRoleBans(NetUserId playerUserId) where T : class, IPrototype { if (!_playerManager.TryGetSessionById(playerUserId, out var session)) return null; - return GetRoleBans(session, prefix); + return GetRoleBans(session); } - private HashSet>? GetRoleBans(ICommonSession playerSession, string prefix) where T : class, IPrototype + private HashSet>? GetRoleBans(ICommonSession playerSession) where T : class, IPrototype { if (!_cachedRoleBans.TryGetValue(playerSession, out var roleBans)) return null; + var dbType = PrototypeKindToDbType(); + return roleBans - .Where(ban => ban.Role.StartsWith(prefix, StringComparison.Ordinal)) - .Select(ban => new ProtoId(ban.Role[prefix.Length..])) + .SelectMany(ban => ban.Roles!.Value) + .Where(role => role.RoleType == dbType) + .Select(role => new ProtoId(role.RoleId)) .ToHashSet(); } - public HashSet? GetRoleBans(NetUserId playerUserId) + public HashSet? GetRoleBans(NetUserId playerUserId) { if (!_playerManager.TryGetSessionById(playerUserId, out var session)) return null; return _cachedRoleBans.TryGetValue(session, out var roleBans) - ? roleBans.Select(banDef => banDef.Role).ToHashSet() + ? roleBans.SelectMany(banDef => banDef.Roles ?? []).ToHashSet() : null; } public bool IsRoleBanned(ICommonSession player, List> jobs) { - return IsRoleBanned(player, jobs, PrefixJob); + return IsRoleBanned(player, jobs); } public bool IsRoleBanned(ICommonSession player, List> antags) { - return IsRoleBanned(player, antags, PrefixAntag); + return IsRoleBanned(player, antags); } - private bool IsRoleBanned(ICommonSession player, List> roles, string prefix) where T : class, IPrototype + private bool IsRoleBanned(ICommonSession player, List> roles) where T : class, IPrototype { var bans = GetRoleBans(player.UserId); if (bans is null || bans.Count == 0) return false; + var dbType = PrototypeKindToDbType(); + // ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator foreach (var role in roles) { - if (bans.Contains(prefix + role)) + if (bans.Contains(new BanRoleDef(dbType, role))) return true; } @@ -407,34 +473,10 @@ public sealed partial class BanManager : IBanManager, IPostInjectInit public void SendRoleBans(ICommonSession pSession) { - var jobBans = GetRoleBans(pSession, PrefixJob); - var jobBansList = new List(jobBans?.Count ?? 0); - - if (jobBans is not null) - { - // ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator - foreach (var encodedId in jobBans) - { - jobBansList.Add(encodedId.ToString().Replace(PrefixJob, "")); - } - } - - var antagBans = GetRoleBans(pSession, PrefixAntag); - var antagBansList = new List(antagBans?.Count ?? 0); - - if (antagBans is not null) - { - // ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator - foreach (var encodedId in antagBans) - { - antagBansList.Add(encodedId.ToString().Replace(PrefixAntag, "")); - } - } - var bans = new MsgRoleBans() { - JobBans = jobBansList, - AntagBans = antagBansList, + JobBans = (GetRoleBans(pSession) ?? []).ToList(), + AntagBans = (GetRoleBans(pSession) ?? []).ToList(), }; _sawmill.Debug($"Sent role bans to {pSession.Name}"); diff --git a/Content.Server/Administration/Managers/IBanManager.cs b/Content.Server/Administration/Managers/IBanManager.cs index 1912ebe9ec..633ae968db 100644 --- a/Content.Server/Administration/Managers/IBanManager.cs +++ b/Content.Server/Administration/Managers/IBanManager.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Sockets; using System.Threading.Tasks; using Content.Shared.Database; using Content.Shared.Roles; @@ -13,6 +14,11 @@ public interface IBanManager public void Initialize(); public void Restart(); + /// + /// Create a server ban in the database, blocking connection for matching players. + /// + void CreateServerBan(CreateServerBanInfo banInfo); + /// /// Bans the specified target, address range and / or HWID. One of them must be non-null /// @@ -23,12 +29,44 @@ public interface IBanManager /// Number of minutes to ban for. 0 and null mean permanent /// Severity of the resulting ban note /// Reason for the ban - public void CreateServerBan(NetUserId? target, string? targetUsername, NetUserId? banningAdmin, (IPAddress, int)? addressRange, ImmutableTypedHwid? hwid, uint? minutes, NoteSeverity severity, string reason); + [Obsolete("Use CreateServerBan(CreateBanInfo) instead")] + public void CreateServerBan(NetUserId? target, + string? targetUsername, + NetUserId? banningAdmin, + (IPAddress, int)? addressRange, + ImmutableTypedHwid? hwid, + uint? minutes, + NoteSeverity severity, + string reason) + { + var info = new CreateServerBanInfo(reason); + if (target != null) + { + ArgumentNullException.ThrowIfNull(targetUsername); + info.AddUser(target.Value, targetUsername); + } + + if (addressRange != null) + info.AddAddressRange(addressRange.Value); + + if (hwid != null) + info.AddHWId(hwid); + + if (minutes > 0) + info.WithMinutes(minutes.Value); + + if (banningAdmin != null) + info.WithBanningAdmin(banningAdmin.Value); + + info.WithSeverity(severity); + + CreateServerBan(info); + } /// /// Gets a list of prefixed prototype IDs with the player's role bans. /// - public HashSet? GetRoleBans(NetUserId playerUserId); + public HashSet? GetRoleBans(NetUserId playerUserId); /// /// Checks if the player is currently banned from any of the listed roles. @@ -57,33 +95,12 @@ public interface IBanManager public HashSet>? GetAntagBans(NetUserId playerUserId); /// - /// Creates a job ban for the specified target, username or GUID + /// Creates a role ban, preventing matching players from playing said roles. /// - /// Target user, username or GUID, null for none - /// The username of the target, if known - /// The responsible admin for the ban - /// The range of IPs that are to be banned, if known - /// The HWID to be banned, if known - /// The role ID to be banned from. Either an AntagPrototype or a JobPrototype - /// Number of minutes to ban for. 0 and null mean permanent - /// Severity of the resulting ban note - /// Reason for the ban - /// Time when the ban was applied, used for grouping role bans - public void CreateRoleBan( - NetUserId? target, - string? targetUsername, - NetUserId? banningAdmin, - (IPAddress, int)? addressRange, - ImmutableTypedHwid? hwid, - ProtoId role, - uint? minutes, - NoteSeverity severity, - string reason, - DateTimeOffset timeOfBan - ) where T : class, IPrototype; + public void CreateRoleBan(CreateRoleBanInfo banInfo); /// - /// Pardons a role ban for the specified target, username or GUID + /// Pardons a role ban by its ID. /// /// The id of the role ban to pardon. /// The admin, if any, that pardoned the role ban. @@ -96,3 +113,287 @@ public interface IBanManager /// Player's session public void SendRoleBans(ICommonSession pSession); } + +/// +/// Base info to fill out in created ban records. +/// +/// +/// +[Access(typeof(BanManager), Other = AccessPermissions.Execute)] +public abstract class CreateBanInfo +{ + [Access(Other = AccessPermissions.Read)] + public const int DefaultMaskIpv4 = 32; + [Access(Other = AccessPermissions.Read)] + public const int DefaultMaskIpv6 = 64; + + internal readonly HashSet<(NetUserId UserId, string UserName)> Users = []; + internal readonly HashSet<(IPAddress Address, int Mask)> AddressRanges = []; + internal readonly HashSet HWIds = []; + internal readonly HashSet RoundIds = []; + internal TimeSpan? Duration; + internal NoteSeverity? Severity; + internal string Reason; + internal NetUserId? BanningAdmin; + + protected CreateBanInfo(string reason) + { + Reason = reason; + } + + /// + /// Add a user to be matched by the ban. + /// + /// + /// Bans can target multiple users at once. + /// + /// The ID of the user. + /// The name of the user (used for logging purposes). + /// The current object, for easy chaining. + public CreateBanInfo AddUser(NetUserId userId, string username) + { + Users.Add((userId, username)); + return this; + } + + /// + /// Add an IP address to be matched by the ban. + /// + /// + /// Bans can target multiple addresses at once. + /// + /// + /// The IP address to add. If null, nothing is done. + /// + /// The current object, for easy chaining. + public CreateBanInfo AddAddress(IPAddress? address) + { + if (address == null) + return this; + + return AddAddressRange( + address, + address.AddressFamily == AddressFamily.InterNetwork ? DefaultMaskIpv4 : DefaultMaskIpv6); + } + + /// + /// Add an IP address range to be matched by the ban. + /// + /// + /// Bans can target multiple address ranges at once. + /// + /// The current object, for easy chaining. + public CreateBanInfo AddAddressRange((IPAddress Address, int Mask) addressRange) + { + return AddAddressRange(addressRange.Address, addressRange.Mask); + } + + /// + /// Add an IP address range to be matched by the ban. + /// + /// + /// Bans can target multiple address ranges at once. + /// + /// The current object, for easy chaining. + public CreateBanInfo AddAddressRange(IPAddress address, int mask) + { + AddressRanges.Add((address, mask)); + return this; + } + + /// + /// Add a hardware IP (HWID) to be matched by the ban. + /// + /// + /// Bans can target multiple HWIDs at once. + /// + /// + /// The HWID to add. If null, nothing is done. + /// + /// The current object, for easy chaining. + public CreateBanInfo AddHWId(ImmutableTypedHwid? hwId) + { + if (hwId != null) + HWIds.Add(hwId); + + return this; + } + + /// + /// Add a relevant round ID to this ban. + /// + /// + /// + /// If not specified, the current round ID is used for the ban. + /// Therefore, the first call to this function will replace the round ID, + /// and further calls will add additional round IDs. + /// + /// + /// Bans can target multiple round IDs at once. + /// + /// + /// The current object, for easy chaining. + public CreateBanInfo AddRoundId(int roundId) + { + RoundIds.Add(roundId); + return this; + } + + /// + /// Set how long the ban will last, in minutes. + /// + /// + /// If no duration is specified, the ban is permanent. + /// + /// The duration of the ban, in minutes. + /// The current object, for easy chaining. + /// + /// Thrown if is not a positive number. + /// + public CreateBanInfo WithMinutes(int minutes) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(minutes); + return WithMinutes((uint)minutes); + } + + /// + /// Set how long the ban will last, in minutes. + /// + /// + /// If no duration is specified, the ban is permanent. + /// + /// The duration of the ban, in minutes. + /// The current object, for easy chaining. + /// + /// Thrown if is not a positive number. + /// + public CreateBanInfo WithMinutes(uint minutes) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(minutes); + return WithDuration(TimeSpan.FromMinutes(minutes)); + } + + /// + /// Set how long the ban will last. + /// + /// + /// If no duration is specified, the ban is permanent. + /// + /// The duration of the ban. + /// The current object, for easy chaining. + /// + /// Thrown if is not a positive amount of time. + /// + public CreateBanInfo WithDuration(TimeSpan duration) + { + if (duration <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(duration), "Duration must be greater than zero."); + + Duration = duration; + return this; + } + + /// + /// Set the severity of the ban. + /// + /// + /// If no severity is specified, the default is specified through server configuration. + /// + /// + /// The current object, for easy chaining. + public CreateBanInfo WithSeverity(NoteSeverity severity) + { + Severity = severity; + return this; + } + + /// + /// Set the reason for the ban. + /// + /// + /// This replaces the value given via the object constructor. + /// + /// The current object, for easy chaining. + public CreateBanInfo WithReason(string reason) + { + Reason = reason; + return this; + } + + /// + /// Specify the admin responsible for placing the ban. + /// + /// The current object, for easy chaining. + public CreateBanInfo WithBanningAdmin(NetUserId? banningAdmin) + { + BanningAdmin = banningAdmin; + return this; + } +} + +/// +/// Stores info to create server ban records. +/// +/// +[Access(typeof(BanManager), Other = AccessPermissions.Execute)] +public sealed class CreateServerBanInfo : CreateBanInfo +{ + /// The reason for the server ban. + public CreateServerBanInfo(string reason) : base(reason) + { + } +} + +/// +/// Stores info to create role ban records. +/// +/// +[Access(typeof(BanManager), Other = AccessPermissions.Execute)] +public sealed class CreateRoleBanInfo : CreateBanInfo +{ + internal readonly HashSet> AntagPrototypes = []; + internal readonly HashSet> JobPrototypes = []; + + /// The reason for the role ban. + public CreateRoleBanInfo(string reason) : base(reason) + { + } + + /// + /// Add an antag role that will be unavailable for banned players. + /// + /// + /// + /// Bans can have multiple roles at once. + /// + /// + /// While not checked in this function, adding a ban with invalid role IDs will cause a + /// when actually creating the ban. + /// + /// + /// The current object, for easy chaining. + public CreateRoleBanInfo AddAntag(ProtoId protoId) + { + AntagPrototypes.Add(protoId); + return this; + } + + /// + /// Add a job role that will be unavailable for banned players. + /// + /// + /// + /// Bans can have multiple roles at once. + /// + /// + /// While not checked in this function, adding a ban with invalid role IDs will cause a + /// when actually creating the ban. + /// + /// + /// The current object, for easy chaining. + public CreateRoleBanInfo AddJob(ProtoId protoId) + { + JobPrototypes.Add(protoId); + return this; + } +} diff --git a/Content.Server/Administration/Notes/AdminNotesEui.cs b/Content.Server/Administration/Notes/AdminNotesEui.cs index d1297b251d..5ecb9c774d 100644 --- a/Content.Server/Administration/Notes/AdminNotesEui.cs +++ b/Content.Server/Administration/Notes/AdminNotesEui.cs @@ -22,7 +22,7 @@ public sealed class AdminNotesEui : BaseEui IoCManager.InjectDependencies(this); } - private Guid NotedPlayer { get; set; } + private NetUserId NotedPlayer { get; set; } private string NotedPlayerName { get; set; } = string.Empty; private bool HasConnectedBefore { get; set; } private Dictionary<(int, NoteType), SharedAdminNote> Notes { get; set; } = new(); @@ -112,7 +112,7 @@ public sealed class AdminNotesEui : BaseEui } } - public async Task ChangeNotedPlayer(Guid notedPlayer) + public async Task ChangeNotedPlayer(NetUserId notedPlayer) { NotedPlayer = notedPlayer; await LoadFromDb(); @@ -120,7 +120,7 @@ public sealed class AdminNotesEui : BaseEui private void NoteModified(SharedAdminNote note) { - if (note.Player != NotedPlayer) + if (!note.Players.Contains(NotedPlayer)) return; Notes[(note.Id, note.NoteType)] = note; @@ -129,7 +129,7 @@ public sealed class AdminNotesEui : BaseEui private void NoteDeleted(SharedAdminNote note) { - if (note.Player != NotedPlayer) + if (!note.Players.Contains(NotedPlayer)) return; Notes.Remove((note.Id, note.NoteType)); diff --git a/Content.Server/Administration/Notes/AdminNotesExtensions.cs b/Content.Server/Administration/Notes/AdminNotesExtensions.cs index 349c7ff3bd..e2ec62ed61 100644 --- a/Content.Server/Administration/Notes/AdminNotesExtensions.cs +++ b/Content.Server/Administration/Notes/AdminNotesExtensions.cs @@ -1,3 +1,5 @@ +using System.Collections.Immutable; +using System.Linq; using Content.Server.Database; using Content.Shared.Administration.Notes; using Content.Shared.Database; @@ -11,7 +13,7 @@ public static class AdminNotesExtensions NoteSeverity? severity = null; var secret = false; NoteType type; - string[]? bannedRoles = null; + ImmutableArray? bannedRoles = null; string? unbannedByName = null; DateTime? unbannedTime = null; bool? seen = null; @@ -30,13 +32,13 @@ public static class AdminNotesExtensions type = NoteType.Message; seen = adminMessage.Seen; break; - case ServerBanNoteRecord ban: + case BanNoteRecord { Type: BanType.Server } ban: type = NoteType.ServerBan; severity = ban.Severity; unbannedTime = ban.UnbanTime; unbannedByName = ban.UnbanningAdmin?.LastSeenUserName ?? Loc.GetString("system-user"); break; - case ServerRoleBanNoteRecord roleBan: + case BanNoteRecord { Type: BanType.Role } roleBan: type = NoteType.RoleBan; severity = roleBan.Severity; bannedRoles = roleBan.Roles; @@ -48,14 +50,14 @@ public static class AdminNotesExtensions } // There may be bans without a user, but why would we ever be converting them to shared notes? - if (note.Player is null) - throw new ArgumentNullException(nameof(note), "Player user ID cannot be null for a note"); + if (note.Players.Length == 0) + throw new ArgumentNullException(nameof(note), "Player user ID cannot be empty for a note"); return new SharedAdminNote( note.Id, - note.Player!.UserId, - note.Round?.Id, - note.Round?.Server.Name, + [..note.Players.Select(p => p.UserId)], + [..note.Rounds.Select(r => r.Id)], + note.Rounds.SingleOrDefault()?.Server.Name, // TODO: Show all server names? note.PlaytimeAtNote, type, note.Message, diff --git a/Content.Server/Administration/Notes/AdminNotesManager.cs b/Content.Server/Administration/Notes/AdminNotesManager.cs index 412b191171..f10cd4e3f9 100644 --- a/Content.Server/Administration/Notes/AdminNotesManager.cs +++ b/Content.Server/Administration/Notes/AdminNotesManager.cs @@ -52,7 +52,7 @@ public sealed class AdminNotesManager : IAdminNotesManager, IPostInjectInit return _admins.HasAdminFlag(admin, AdminFlags.ViewNotes); } - public async Task OpenEui(ICommonSession admin, Guid notedPlayer) + public async Task OpenEui(ICommonSession admin, NetUserId notedPlayer) { var ui = new AdminNotesEui(); _euis.OpenEui(ui, admin); @@ -144,8 +144,8 @@ public sealed class AdminNotesManager : IAdminNotesManager, IPostInjectInit var note = new SharedAdminNote( noteId, - (NetUserId) player, - roundId, + [(NetUserId) player], + roundId.HasValue ? [roundId.Value] : [], serverName, playtime, type, @@ -172,8 +172,7 @@ public sealed class AdminNotesManager : IAdminNotesManager, IPostInjectInit NoteType.Note => (await _db.GetAdminNote(id))?.ToShared(), NoteType.Watchlist => (await _db.GetAdminWatchlist(id))?.ToShared(), NoteType.Message => (await _db.GetAdminMessage(id))?.ToShared(), - NoteType.ServerBan => (await _db.GetServerBanAsNoteAsync(id))?.ToShared(), - NoteType.RoleBan => (await _db.GetServerRoleBanAsNoteAsync(id))?.ToShared(), + NoteType.ServerBan or NoteType.RoleBan => (await _db.GetBanAsNoteAsync(id))?.ToShared(), _ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown note type") }; } @@ -200,11 +199,8 @@ public sealed class AdminNotesManager : IAdminNotesManager, IPostInjectInit case NoteType.Message: await _db.DeleteAdminMessage(noteId, deletedBy.UserId, deletedAt); break; - case NoteType.ServerBan: - await _db.HideServerBanFromNotes(noteId, deletedBy.UserId, deletedAt); - break; - case NoteType.RoleBan: - await _db.HideServerRoleBanFromNotes(noteId, deletedBy.UserId, deletedAt); + case NoteType.ServerBan or NoteType.RoleBan: + await _db.HideBanFromNotes(noteId, deletedBy.UserId, deletedAt); break; default: throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown note type"); @@ -280,15 +276,10 @@ public sealed class AdminNotesManager : IAdminNotesManager, IPostInjectInit case NoteType.Message: await _db.EditAdminMessage(noteId, message, editedBy.UserId, editedAt, expiryTime); break; - case NoteType.ServerBan: + case NoteType.ServerBan or NoteType.RoleBan: if (severity is null) throw new ArgumentException("Severity cannot be null for a ban", nameof(severity)); - await _db.EditServerBan(noteId, message, severity.Value, expiryTime, editedBy.UserId, editedAt); - break; - case NoteType.RoleBan: - if (severity is null) - throw new ArgumentException("Severity cannot be null for a role ban", nameof(severity)); - await _db.EditServerRoleBan(noteId, message, severity.Value, expiryTime, editedBy.UserId, editedAt); + await _db.EditBan(noteId, message, severity.Value, expiryTime, editedBy.UserId, editedAt); break; default: throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown note type"); diff --git a/Content.Server/Administration/Notes/IAdminNotesManager.cs b/Content.Server/Administration/Notes/IAdminNotesManager.cs index f54f8a21bd..4e992ba30b 100644 --- a/Content.Server/Administration/Notes/IAdminNotesManager.cs +++ b/Content.Server/Administration/Notes/IAdminNotesManager.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using Content.Server.Database; using Content.Shared.Administration.Notes; using Content.Shared.Database; +using Robust.Shared.Network; using Robust.Shared.Player; namespace Content.Server.Administration.Notes; @@ -16,7 +17,7 @@ public interface IAdminNotesManager bool CanDelete(ICommonSession admin); bool CanEdit(ICommonSession admin); bool CanView(ICommonSession admin); - Task OpenEui(ICommonSession admin, Guid notedPlayer); + Task OpenEui(ICommonSession admin, NetUserId notedPlayer); Task OpenUserNotesEui(ICommonSession player); Task AddAdminRemark(ICommonSession createdBy, Guid player, NoteType type, string message, NoteSeverity? severity, bool secret, DateTime? expiryTime); Task DeleteAdminRemark(int noteId, NoteType type, ICommonSession deletedBy); diff --git a/Content.Server/Administration/PlayerPanelEui.cs b/Content.Server/Administration/PlayerPanelEui.cs index 7de62ac743..13a5d42a4e 100644 --- a/Content.Server/Administration/PlayerPanelEui.cs +++ b/Content.Server/Administration/PlayerPanelEui.cs @@ -186,11 +186,8 @@ public sealed class PlayerPanelEui : BaseEui { _whitelisted = await _db.GetWhitelistStatusAsync(_targetPlayer.UserId); // This won't get associated ip or hwid bans but they were not placed on this account anyways - _bans = (await _db.GetServerBansAsync(null, _targetPlayer.UserId, null, null)).Count; - // Unfortunately role bans for departments and stuff are issued individually. This means that a single role ban can have many individual role bans internally - // The only way to distinguish whether a role ban is the same is to compare the ban time. - // This is horrible and I would love to just erase the database and start from scratch instead but that's what I can do for now. - _roleBans = (await _db.GetServerRoleBansAsync(null, _targetPlayer.UserId, null, null)).DistinctBy(rb => rb.BanTime).Count(); + _bans = (await _db.GetBansAsync(null, _targetPlayer.UserId, null, null)).Count; + _roleBans = (await _db.GetBansAsync(null, _targetPlayer.UserId, null, null, type: BanType.Role)).Count(); } else { diff --git a/Content.Server/Administration/Systems/BwoinkSystem.cs b/Content.Server/Administration/Systems/BwoinkSystem.cs index 91211716b5..172e27ee80 100644 --- a/Content.Server/Administration/Systems/BwoinkSystem.cs +++ b/Content.Server/Administration/Systems/BwoinkSystem.cs @@ -172,7 +172,7 @@ namespace Content.Server.Administration.Systems } // Check if the user has been banned - var ban = await _dbManager.GetServerBanAsync(null, e.Session.UserId, null, null); + var ban = await _dbManager.GetBanAsync(null, e.Session.UserId, null, null); if (ban != null) { var banMessage = Loc.GetString("bwoink-system-player-banned", ("banReason", ban.Reason)); diff --git a/Content.Server/Connection/ConnectionManager.cs b/Content.Server/Connection/ConnectionManager.cs index 9e6ba89d91..c3a389621c 100644 --- a/Content.Server/Connection/ConnectionManager.cs +++ b/Content.Server/Connection/ConnectionManager.cs @@ -207,7 +207,7 @@ namespace Content.Server.Connection * TODO: Jesus H Christ what is this utter mess of a function * TODO: Break this apart into is constituent steps. */ - private async Task<(ConnectionDenyReason, string, List? bansHit)?> ShouldDeny( + private async Task<(ConnectionDenyReason, string, List? bansHit)?> ShouldDeny( NetConnectingArgs e) { // Check if banned. @@ -228,7 +228,7 @@ namespace Content.Server.Connection return (ConnectionDenyReason.NoHwid, Loc.GetString("hwid-required"), null); } - var bans = await _db.GetServerBansAsync(addr, userId, hwId, modernHwid, includeUnbanned: false); + var bans = await _db.GetBansAsync(addr, userId, hwId, modernHwid, includeUnbanned: false); if (bans.Count > 0) { var firstBan = bans[0]; diff --git a/Content.Server/Database/BanDef.cs b/Content.Server/Database/BanDef.cs new file mode 100644 index 0000000000..d459b47579 --- /dev/null +++ b/Content.Server/Database/BanDef.cs @@ -0,0 +1,128 @@ +using System.Collections.Immutable; +using System.Linq; +using System.Net; +using Content.Shared.CCVar; +using Content.Shared.Database; +using Robust.Shared.Configuration; +using Robust.Shared.Network; + + +namespace Content.Server.Database +{ + public sealed class BanDef + { + public int? Id { get; } + public BanType Type { get; } + public ImmutableArray UserIds { get; } + public ImmutableArray<(IPAddress address, int cidrMask)> Addresses { get; } + public ImmutableArray HWIds { get; } + + public DateTimeOffset BanTime { get; } + public DateTimeOffset? ExpirationTime { get; } + public ImmutableArray RoundIds { get; } + public TimeSpan PlaytimeAtNote { get; } + public string Reason { get; } + public NoteSeverity Severity { get; set; } + public NetUserId? BanningAdmin { get; } + public UnbanDef? Unban { get; } + public ServerBanExemptFlags ExemptFlags { get; } + + public ImmutableArray? Roles { get; } + + public BanDef( + int? id, + BanType type, + ImmutableArray userIds, + ImmutableArray<(IPAddress address, int cidrMask)> addresses, + ImmutableArray hwIds, + DateTimeOffset banTime, + DateTimeOffset? expirationTime, + ImmutableArray roundIds, + TimeSpan playtimeAtNote, + string reason, + NoteSeverity severity, + NetUserId? banningAdmin, + UnbanDef? unban, + ServerBanExemptFlags exemptFlags = default, + ImmutableArray? roles = null) + { + if (userIds.Length == 0 && addresses.Length == 0 && hwIds.Length == 0) + { + throw new ArgumentException("Must have at least one of banned user, banned address or hardware ID"); + } + + addresses = addresses.Select(address => + { + if (address is { address.IsIPv4MappedToIPv6: true } addr) + { + // Fix IPv6-mapped IPv4 addresses + // So that IPv4 addresses are consistent between separate-socket and dual-stack socket modes. + address = (addr.address.MapToIPv4(), addr.cidrMask - 96); + } + + return address; + }) + .ToImmutableArray(); + + Id = id; + Type = type; + UserIds = userIds; + Addresses = addresses; + HWIds = hwIds; + BanTime = banTime; + ExpirationTime = expirationTime; + RoundIds = roundIds; + PlaytimeAtNote = playtimeAtNote; + Reason = reason; + Severity = severity; + BanningAdmin = banningAdmin; + Unban = unban; + ExemptFlags = exemptFlags; + + switch (Type) + { + case BanType.Server: + if (roles != null) + throw new ArgumentException("Cannot specify roles for server ban types", nameof(roles)); + break; + + case BanType.Role: + if (roles is not { Length: > 0 }) + throw new ArgumentException("Must specify roles for server ban types", nameof(roles)); + if (exemptFlags != 0) + throw new ArgumentException("Role bans cannot have exempt flags", nameof(exemptFlags)); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(type)); + } + + Roles = roles; + } + + public string FormatBanMessage(IConfigurationManager cfg, ILocalizationManager loc) + { + string expires; + if (ExpirationTime is { } expireTime) + { + var duration = expireTime - BanTime; + var utc = expireTime.ToUniversalTime(); + expires = loc.GetString("ban-expires", ("duration", duration.TotalMinutes.ToString("N0")), ("time", utc.ToString("f"))); + } + else + { + var appeal = cfg.GetCVar(CCVars.InfoLinksAppeal); + expires = !string.IsNullOrWhiteSpace(appeal) + ? loc.GetString("ban-banned-permanent-appeal", ("link", appeal)) + : loc.GetString("ban-banned-permanent"); + } + + return $""" + {loc.GetString("ban-banned-1")} + {loc.GetString("ban-banned-2", ("reason", Reason))} + {expires} + {loc.GetString("ban-banned-3")} + """; + } + } +} diff --git a/Content.Server/Database/BanMatcher.cs b/Content.Server/Database/BanMatcher.cs index f477ccd822..0302c0dc13 100644 --- a/Content.Server/Database/BanMatcher.cs +++ b/Content.Server/Database/BanMatcher.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Linq; using System.Net; using Content.Server.IP; using Content.Shared.Database; @@ -7,7 +8,7 @@ using Robust.Shared.Network; namespace Content.Server.Database; /// -/// Implements logic to match a against a player query. +/// Implements logic to match a against a player query. /// /// /// @@ -29,7 +30,7 @@ public static class BanMatcher /// The ban information. /// Information about the player to match against. /// True if the ban matches the provided player info. - public static bool BanMatches(ServerBanDef ban, in PlayerInfo player) + public static bool BanMatches(BanDef ban, in PlayerInfo player) { var exemptFlags = player.ExemptFlags; // Any flag to bypass BlacklistedRange bans. @@ -39,39 +40,44 @@ public static class BanMatcher if ((ban.ExemptFlags & exemptFlags) != 0) return false; + var playerAddr = player.Address; if (!player.ExemptFlags.HasFlag(ServerBanExemptFlags.IP) - && player.Address != null - && ban.Address is not null - && player.Address.IsInSubnet(ban.Address.Value) + && playerAddr != null + && ban.Addresses.Any(addr => playerAddr.IsInSubnet(addr)) && (!ban.ExemptFlags.HasFlag(ServerBanExemptFlags.BlacklistedRange) || player.IsNewPlayer)) { return true; } - if (player.UserId is { } id && ban.UserId == id.UserId) + if (player.UserId is { } id && ban.UserIds.Contains(id)) { return true; } - switch (ban.HWId?.Type) + foreach (var banHwid in ban.HWIds) { - case HwidType.Legacy: - if (player.HWId is { Length: > 0 } hwIdVar - && hwIdVar.AsSpan().SequenceEqual(ban.HWId.Hwid.AsSpan())) - { - return true; - } - break; - case HwidType.Modern: - if (player.ModernHWIds is { Length: > 0 } modernHwIdVar) - { - foreach (var hwid in modernHwIdVar) + switch (banHwid.Type) + { + case HwidType.Legacy: + if (player.HWId is { Length: > 0 } hwIdVar + && hwIdVar.AsSpan().SequenceEqual(banHwid.Hwid.AsSpan())) { - if (hwid.AsSpan().SequenceEqual(ban.HWId.Hwid.AsSpan())) - return true; + return true; } - } - break; + + break; + case HwidType.Modern: + if (player.ModernHWIds is { Length: > 0 } modernHwIdVar) + { + foreach (var hwid in modernHwIdVar) + { + if (hwid.AsSpan().SequenceEqual(banHwid.Hwid.AsSpan())) + return true; + } + } + + break; + } } return false; diff --git a/Content.Server/Database/DatabaseRecords.cs b/Content.Server/Database/DatabaseRecords.cs index 30fba3434b..63ab45a726 100644 --- a/Content.Server/Database/DatabaseRecords.cs +++ b/Content.Server/Database/DatabaseRecords.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Net; using Content.Shared.Database; using Robust.Shared.Network; @@ -12,9 +13,9 @@ public interface IAdminRemarksRecord { public int Id { get; } - public RoundRecord? Round { get; } + public ImmutableArray Rounds { get; } - public PlayerRecord? Player { get; } + public ImmutableArray Players { get; } public TimeSpan PlaytimeAtNote { get; } public string Message { get; } @@ -31,27 +32,11 @@ public interface IAdminRemarksRecord public bool Deleted { get; } } -public sealed record ServerRoleBanNoteRecord( +public sealed record BanNoteRecord( int Id, - RoundRecord? Round, - PlayerRecord? Player, - TimeSpan PlaytimeAtNote, - string Message, - NoteSeverity Severity, - PlayerRecord? CreatedBy, - DateTimeOffset CreatedAt, - PlayerRecord? LastEditedBy, - DateTimeOffset? LastEditedAt, - DateTimeOffset? ExpirationTime, - bool Deleted, - string[] Roles, - PlayerRecord? UnbanningAdmin, - DateTime? UnbanTime) : IAdminRemarksRecord; - -public sealed record ServerBanNoteRecord( - int Id, - RoundRecord? Round, - PlayerRecord? Player, + BanType Type, + ImmutableArray Rounds, + ImmutableArray Players, TimeSpan PlaytimeAtNote, string Message, NoteSeverity Severity, @@ -62,7 +47,8 @@ public sealed record ServerBanNoteRecord( DateTimeOffset? ExpirationTime, bool Deleted, PlayerRecord? UnbanningAdmin, - DateTime? UnbanTime) : IAdminRemarksRecord; + DateTime? UnbanTime, + ImmutableArray Roles) : IAdminRemarksRecord; public sealed record AdminNoteRecord( int Id, @@ -79,7 +65,11 @@ public sealed record AdminNoteRecord( bool Deleted, PlayerRecord? DeletedBy, DateTimeOffset? DeletedAt, - bool Secret) : IAdminRemarksRecord; + bool Secret) : IAdminRemarksRecord +{ + ImmutableArray IAdminRemarksRecord.Rounds => Round != null ? [Round] : []; + ImmutableArray IAdminRemarksRecord.Players => Player != null ? [Player] : []; +} public sealed record AdminWatchlistRecord( int Id, @@ -94,7 +84,11 @@ public sealed record AdminWatchlistRecord( DateTimeOffset? ExpirationTime, bool Deleted, PlayerRecord? DeletedBy, - DateTimeOffset? DeletedAt) : IAdminRemarksRecord; + DateTimeOffset? DeletedAt) : IAdminRemarksRecord +{ + ImmutableArray IAdminRemarksRecord.Rounds => Round != null ? [Round] : []; + ImmutableArray IAdminRemarksRecord.Players => Player != null ? [Player] : []; +} public sealed record AdminMessageRecord( int Id, @@ -111,15 +105,18 @@ public sealed record AdminMessageRecord( PlayerRecord? DeletedBy, DateTimeOffset? DeletedAt, bool Seen, - bool Dismissed) : IAdminRemarksRecord; - + bool Dismissed) : IAdminRemarksRecord +{ + ImmutableArray IAdminRemarksRecord.Rounds => Round != null ? [Round] : []; + ImmutableArray IAdminRemarksRecord.Players => Player != null ? [Player] : []; +} public sealed record PlayerRecord( NetUserId UserId, DateTimeOffset FirstSeenTime, string LastSeenUserName, DateTimeOffset LastSeenTime, - IPAddress LastSeenAddress, + IPAddress? LastSeenAddress, ImmutableTypedHwid? HWId); public sealed record RoundRecord(int Id, DateTimeOffset? StartDate, ServerRecord Server); diff --git a/Content.Server/Database/EFCoreExtensions.cs b/Content.Server/Database/EFCoreExtensions.cs new file mode 100644 index 0000000000..58dbc4639b --- /dev/null +++ b/Content.Server/Database/EFCoreExtensions.cs @@ -0,0 +1,37 @@ +using System.Linq; +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; + +namespace Content.Server.Database; + +internal static class EFCoreExtensions +{ + extension(IQueryable query) where TEntity : class + { + public IQueryable ApplyIncludes( + IEnumerable>> properties) + { + var q = query; + foreach (var property in properties) + { + q = q.Include(property); + } + + return q; + } + + public IQueryable ApplyIncludes( + IEnumerable>> properties, + Expression> getDerived) + where TDerived : class + { + var q = query; + foreach (var property in properties) + { + q = q.Include(getDerived).ThenInclude(property); + } + + return q; + } + } +} diff --git a/Content.Server/Database/ServerBanDef.cs b/Content.Server/Database/ServerBanDef.cs deleted file mode 100644 index a09f9e959c..0000000000 --- a/Content.Server/Database/ServerBanDef.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System.Net; -using Content.Shared.CCVar; -using Content.Shared.Database; -using Robust.Shared.Configuration; -using Robust.Shared.Network; - - -namespace Content.Server.Database -{ - public sealed class ServerBanDef - { - public int? Id { get; } - public NetUserId? UserId { get; } - public (IPAddress address, int cidrMask)? Address { get; } - public ImmutableTypedHwid? HWId { get; } - - public DateTimeOffset BanTime { get; } - public DateTimeOffset? ExpirationTime { get; } - public int? RoundId { get; } - public TimeSpan PlaytimeAtNote { get; } - public string Reason { get; } - public NoteSeverity Severity { get; set; } - public NetUserId? BanningAdmin { get; } - public ServerUnbanDef? Unban { get; } - public ServerBanExemptFlags ExemptFlags { get; } - - public ServerBanDef(int? id, - NetUserId? userId, - (IPAddress, int)? address, - TypedHwid? hwId, - DateTimeOffset banTime, - DateTimeOffset? expirationTime, - int? roundId, - TimeSpan playtimeAtNote, - string reason, - NoteSeverity severity, - NetUserId? banningAdmin, - ServerUnbanDef? unban, - ServerBanExemptFlags exemptFlags = default) - { - if (userId == null && address == null && hwId == null) - { - throw new ArgumentException("Must have at least one of banned user, banned address or hardware ID"); - } - - if (address is {} addr && addr.Item1.IsIPv4MappedToIPv6) - { - // Fix IPv6-mapped IPv4 addresses - // So that IPv4 addresses are consistent between separate-socket and dual-stack socket modes. - address = (addr.Item1.MapToIPv4(), addr.Item2 - 96); - } - - Id = id; - UserId = userId; - Address = address; - HWId = hwId; - BanTime = banTime; - ExpirationTime = expirationTime; - RoundId = roundId; - PlaytimeAtNote = playtimeAtNote; - Reason = reason; - Severity = severity; - BanningAdmin = banningAdmin; - Unban = unban; - ExemptFlags = exemptFlags; - } - - public string FormatBanMessage(IConfigurationManager cfg, ILocalizationManager loc) - { - string expires; - if (ExpirationTime is { } expireTime) - { - var duration = expireTime - BanTime; - var utc = expireTime.ToUniversalTime(); - expires = loc.GetString("ban-expires", ("duration", duration.TotalMinutes.ToString("N0")), ("time", utc.ToString("f"))); - } - else - { - var appeal = cfg.GetCVar(CCVars.InfoLinksAppeal); - expires = !string.IsNullOrWhiteSpace(appeal) - ? loc.GetString("ban-banned-permanent-appeal", ("link", appeal)) - : loc.GetString("ban-banned-permanent"); - } - - return $""" - {loc.GetString("ban-banned-1")} - {loc.GetString("ban-banned-2", ("reason", Reason))} - {expires} - {loc.GetString("ban-banned-3")} - """; - } - } -} diff --git a/Content.Server/Database/ServerDbBase.cs b/Content.Server/Database/ServerDbBase.cs index b1e5597894..cf76ab8f68 100644 --- a/Content.Server/Database/ServerDbBase.cs +++ b/Content.Server/Database/ServerDbBase.cs @@ -1,13 +1,13 @@ using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Linq.Expressions; using System.Net; using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Content.Server.Administration.Logs; -using Content.Server.Administration.Managers; using Content.Shared.Administration.Logs; using Content.Shared.Construction.Prototypes; using Content.Shared.Database; @@ -399,7 +399,7 @@ namespace Content.Server.Database /// /// The ban id to look for. /// The ban with the given id or null if none exist. - public abstract Task GetServerBanAsync(int id); + public abstract Task GetBanAsync(int id); /// /// Looks up an user's most recent received un-pardoned ban. @@ -411,11 +411,12 @@ namespace Content.Server.Database /// The legacy HWId of the user. /// The modern HWIDs of the user. /// The user's latest received un-pardoned ban, or null if none exist. - public abstract Task GetServerBanAsync( + public abstract Task GetBanAsync( IPAddress? address, NetUserId? userId, ImmutableArray? hwId, - ImmutableArray>? modernHWIds); + ImmutableArray>? modernHWIds, + BanType type); /// /// Looks up an user's ban history. @@ -428,17 +429,18 @@ namespace Content.Server.Database /// The modern HWIDs of the user. /// Include pardoned and expired bans. /// The user's ban history. - public abstract Task> GetServerBansAsync( + public abstract Task> GetBansAsync( IPAddress? address, NetUserId? userId, ImmutableArray? hwId, ImmutableArray>? modernHWIds, - bool includeUnbanned); + bool includeUnbanned, + BanType type); - public abstract Task AddServerBanAsync(ServerBanDef serverBan); - public abstract Task AddServerUnbanAsync(ServerUnbanDef serverUnban); + public abstract Task AddBanAsync(BanDef ban); + public abstract Task AddUnbanAsync(UnbanDef unban); - public async Task EditServerBan(int id, string reason, NoteSeverity severity, DateTimeOffset? expiration, Guid editedBy, DateTimeOffset editedAt) + public async Task EditBan(int id, string reason, NoteSeverity severity, DateTimeOffset? expiration, Guid editedBy, DateTimeOffset editedAt) { await using var db = await GetDb(); @@ -501,61 +503,23 @@ namespace Content.Server.Database return flags ?? ServerBanExemptFlags.None; } - #endregion - - #region Role Bans - /* - * ROLE BANS - */ - /// - /// Looks up a role ban by id. - /// This will return a pardoned role ban as well. - /// - /// The role ban id to look for. - /// The role ban with the given id or null if none exist. - public abstract Task GetServerRoleBanAsync(int id); - - /// - /// Looks up an user's role ban history. - /// This will return pardoned role bans based on the bool. - /// Requires one of , , or to not be null. - /// - /// The IP address of the user. - /// The NetUserId of the user. - /// The Hardware Id of the user. - /// The modern HWIDs of the user. - /// Whether expired and pardoned bans are included. - /// The user's role ban history. - public abstract Task> GetServerRoleBansAsync(IPAddress? address, - NetUserId? userId, - ImmutableArray? hwId, - ImmutableArray>? modernHWIds, - bool includeUnbanned); - - public abstract Task AddServerRoleBanAsync(ServerRoleBanDef serverRoleBan); - public abstract Task AddServerRoleUnbanAsync(ServerRoleUnbanDef serverRoleUnban); - - public async Task EditServerRoleBan(int id, string reason, NoteSeverity severity, DateTimeOffset? expiration, Guid editedBy, DateTimeOffset editedAt) + protected static List>> GetBanDefIncludes(BanType? type = null) { - await using var db = await GetDb(); - var roleBanDetails = await db.DbContext.RoleBan - .Where(b => b.Id == id) - .Select(b => new { b.BanTime, b.PlayerUserId }) - .SingleOrDefaultAsync(); + List>> list = + [ + b => b.Players!, + b => b.Rounds!, + b => b.Hwids!, + b => b.Unban!, + b => b.Addresses!, + ]; - if (roleBanDetails == default) - return; + if (type != BanType.Server) + list.Add(b => b.Roles!); - await db.DbContext.RoleBan - .Where(b => b.BanTime == roleBanDetails.BanTime && b.PlayerUserId == roleBanDetails.PlayerUserId) - .ExecuteUpdateAsync(setters => setters - .SetProperty(b => b.Severity, severity) - .SetProperty(b => b.Reason, reason) - .SetProperty(b => b.ExpirationTime, expiration.HasValue ? expiration.Value.UtcDateTime : (DateTime?)null) - .SetProperty(b => b.LastEditedById, editedBy) - .SetProperty(b => b.LastEditedAt, editedAt.UtcDateTime) - ); + return list; } + #endregion #region Playtime @@ -676,6 +640,19 @@ namespace Content.Server.Database if (player == null) return null; + return MakePlayerRecord(player.UserId, player); + } + + protected PlayerRecord MakePlayerRecord(Guid userId, Player? player) + { + if (player == null) + { + // We don't have a record for this player in the database. + // This is possible, for example, when banning people that never connected to the server. + // Just return fallback data here, I guess. + return new PlayerRecord(new NetUserId(userId), default, userId.ToString(), default, null, null); + } + return new PlayerRecord( new NetUserId(player.UserId), new DateTimeOffset(NormalizeDatabaseTime(player.FirstSeenTime)), @@ -699,7 +676,7 @@ namespace Content.Server.Database ConnectionDenyReason? denied, int serverId); - public async Task AddServerBanHitsAsync(int connection, IEnumerable bans) + public async Task AddServerBanHitsAsync(int connection, IEnumerable bans) { await using var db = await GetDb(); @@ -1313,81 +1290,17 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id} entity.Dismissed); } - public async Task GetServerBanAsNoteAsync(int id) + public async Task GetBanAsNoteAsync(int id) { await using var db = await GetDb(); - var ban = await db.DbContext.Ban - .Include(ban => ban.Unban) - .Include(ban => ban.Round) - .ThenInclude(r => r!.Server) - .Include(ban => ban.CreatedBy) - .Include(ban => ban.LastEditedBy) - .Include(ban => ban.Unban) + var ban = await BanRecordQuery(db.DbContext) .SingleOrDefaultAsync(b => b.Id == id); if (ban is null) return null; - var player = await db.DbContext.Player.SingleOrDefaultAsync(p => p.UserId == ban.PlayerUserId); - return new ServerBanNoteRecord( - ban.Id, - MakeRoundRecord(ban.Round), - MakePlayerRecord(player), - ban.PlaytimeAtNote, - ban.Reason, - ban.Severity, - MakePlayerRecord(ban.CreatedBy), - ban.BanTime, - MakePlayerRecord(ban.LastEditedBy), - ban.LastEditedAt, - ban.ExpirationTime, - ban.Hidden, - MakePlayerRecord(ban.Unban?.UnbanningAdmin == null - ? null - : await db.DbContext.Player.SingleOrDefaultAsync(p => - p.UserId == ban.Unban.UnbanningAdmin.Value)), - ban.Unban?.UnbanTime); - } - - public async Task GetServerRoleBanAsNoteAsync(int id) - { - await using var db = await GetDb(); - - var ban = await db.DbContext.RoleBan - .Include(ban => ban.Unban) - .Include(ban => ban.Round) - .ThenInclude(r => r!.Server) - .Include(ban => ban.CreatedBy) - .Include(ban => ban.LastEditedBy) - .Include(ban => ban.Unban) - .SingleOrDefaultAsync(b => b.Id == id); - - if (ban is null) - return null; - - var player = await db.DbContext.Player.SingleOrDefaultAsync(p => p.UserId == ban.PlayerUserId); - var unbanningAdmin = - ban.Unban is null - ? null - : await db.DbContext.Player.SingleOrDefaultAsync(b => b.UserId == ban.Unban.UnbanningAdmin); - - return new ServerRoleBanNoteRecord( - ban.Id, - MakeRoundRecord(ban.Round), - MakePlayerRecord(player), - ban.PlaytimeAtNote, - ban.Reason, - ban.Severity, - MakePlayerRecord(ban.CreatedBy), - ban.BanTime, - MakePlayerRecord(ban.LastEditedBy), - ban.LastEditedAt, - ban.ExpirationTime, - ban.Hidden, - new [] { ban.RoleId.Replace(BanManager.PrefixJob, null).Replace(BanManager.PrefixAntag, null) }, - MakePlayerRecord(unbanningAdmin), - ban.Unban?.UnbanTime); + return await MakeBanNoteRecord(db.DbContext, ban); } public async Task> GetAllAdminRemarks(Guid player) @@ -1408,8 +1321,7 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id} .ToListAsync()).Select(MakeAdminNoteRecord)); notes.AddRange(await GetActiveWatchlistsImpl(db, player)); notes.AddRange(await GetMessagesImpl(db, player)); - notes.AddRange(await GetServerBansAsNotesForUser(db, player)); - notes.AddRange(await GetGroupedServerRoleBansAsNotesForUser(db, player)); + notes.AddRange(await GetBansAsNotesForUser(db, player)); return notes; } public async Task EditAdminNote(int id, string message, NoteSeverity severity, bool secret, Guid editedBy, DateTimeOffset editedAt, DateTimeOffset? expiryTime) @@ -1492,7 +1404,7 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id} await db.DbContext.SaveChangesAsync(); } - public async Task HideServerBanFromNotes(int id, Guid deletedBy, DateTimeOffset deletedAt) + public async Task HideBanFromNotes(int id, Guid deletedBy, DateTimeOffset deletedAt) { await using var db = await GetDb(); @@ -1505,19 +1417,6 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id} await db.DbContext.SaveChangesAsync(); } - public async Task HideServerRoleBanFromNotes(int id, Guid deletedBy, DateTimeOffset deletedAt) - { - await using var db = await GetDb(); - - var roleBan = await db.DbContext.RoleBan.Where(roleBan => roleBan.Id == id).SingleAsync(); - - roleBan.Hidden = true; - roleBan.LastEditedById = deletedBy; - roleBan.LastEditedAt = deletedAt.UtcDateTime; - - await db.DbContext.SaveChangesAsync(); - } - public async Task> GetVisibleAdminRemarks(Guid player) { await using var db = await GetDb(); @@ -1535,8 +1434,7 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id} .Include(note => note.Player) .ToListAsync()).Select(MakeAdminNoteRecord)); notesCol.AddRange(await GetMessagesImpl(db, player)); - notesCol.AddRange(await GetServerBansAsNotesForUser(db, player)); - notesCol.AddRange(await GetGroupedServerRoleBansAsNotesForUser(db, player)); + notesCol.AddRange(await GetBansAsNotesForUser(db, player)); return notesCol; } @@ -1599,43 +1497,65 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id} await db.DbContext.SaveChangesAsync(); } + private static IQueryable BanRecordQuery(ServerDbContext dbContext) + { + return dbContext.Ban + .Include(ban => ban.Unban) + .Include(ban => ban.Rounds!) + .ThenInclude(r => r.Round) + .ThenInclude(r => r!.Server) + .Include(ban => ban.Addresses) + .Include(ban => ban.Players) + .Include(ban => ban.Roles) + .Include(ban => ban.Hwids) + .Include(ban => ban.CreatedBy) + .Include(ban => ban.LastEditedBy) + .Include(ban => ban.Unban); + } + + private async Task MakeBanNoteRecord(ServerDbContext dbContext, Ban ban) + { + var playerRecords = await AsyncSelect(ban.Players, + async bp => MakePlayerRecord(bp.UserId, + await dbContext.Player.SingleOrDefaultAsync(p => p.UserId == bp.UserId))); + + return new BanNoteRecord( + ban.Id, + ban.Type, + [..ban.Rounds!.Select(br => MakeRoundRecord(br.Round!))], + [..playerRecords], + ban.PlaytimeAtNote, + ban.Reason, + ban.Severity, + MakePlayerRecord(ban.CreatedBy!), + NormalizeDatabaseTime(ban.BanTime), + MakePlayerRecord(ban.LastEditedBy!), + NormalizeDatabaseTime(ban.LastEditedAt), + NormalizeDatabaseTime(ban.ExpirationTime), + ban.Hidden, + ban.Unban?.UnbanningAdmin == null + ? null + : MakePlayerRecord( + ban.Unban.UnbanningAdmin.Value, + await dbContext.Player.SingleOrDefaultAsync(p => p.UserId == ban.Unban.UnbanningAdmin.Value)), + NormalizeDatabaseTime(ban.Unban?.UnbanTime), + [..ban.Roles!.Select(br => new BanRoleDef(br.RoleType, br.RoleId))]); + } + // These two are here because they get converted into notes later - protected async Task> GetServerBansAsNotesForUser(DbGuard db, Guid user) + protected async Task> GetBansAsNotesForUser(DbGuard db, Guid user) { // You can't group queries, as player will not always exist. When it doesn't, the // whole query returns nothing - var player = await db.DbContext.Player.SingleOrDefaultAsync(p => p.UserId == user); - var bans = await db.DbContext.Ban - .Where(ban => ban.PlayerUserId == user && !ban.Hidden) - .Include(ban => ban.Unban) - .Include(ban => ban.Round) - .ThenInclude(r => r!.Server) - .Include(ban => ban.CreatedBy) - .Include(ban => ban.LastEditedBy) - .Include(ban => ban.Unban) + var bans = await BanRecordQuery(db.DbContext) + .AsSplitQuery() + .Where(ban => ban.Players!.Any(bp => bp.UserId == user) && !ban.Hidden) .ToArrayAsync(); - var banNotes = new List(); + var banNotes = new List(); foreach (var ban in bans) { - var banNote = new ServerBanNoteRecord( - ban.Id, - MakeRoundRecord(ban.Round), - MakePlayerRecord(player), - ban.PlaytimeAtNote, - ban.Reason, - ban.Severity, - MakePlayerRecord(ban.CreatedBy), - NormalizeDatabaseTime(ban.BanTime), - MakePlayerRecord(ban.LastEditedBy), - NormalizeDatabaseTime(ban.LastEditedAt), - NormalizeDatabaseTime(ban.ExpirationTime), - ban.Hidden, - MakePlayerRecord(ban.Unban?.UnbanningAdmin == null - ? null - : await db.DbContext.Player.SingleOrDefaultAsync( - p => p.UserId == ban.Unban.UnbanningAdmin.Value)), - NormalizeDatabaseTime(ban.Unban?.UnbanTime)); + var banNote = await MakeBanNoteRecord(db.DbContext, ban); banNotes.Add(banNote); } @@ -1643,56 +1563,6 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id} return banNotes; } - protected async Task> GetGroupedServerRoleBansAsNotesForUser(DbGuard db, Guid user) - { - // Server side query - var bansQuery = await db.DbContext.RoleBan - .Where(ban => ban.PlayerUserId == user && !ban.Hidden) - .Include(ban => ban.Unban) - .Include(ban => ban.Round) - .ThenInclude(r => r!.Server) - .Include(ban => ban.CreatedBy) - .Include(ban => ban.LastEditedBy) - .Include(ban => ban.Unban) - .ToArrayAsync(); - - // Client side query, as EF can't do groups yet - var bansEnumerable = bansQuery - .GroupBy(ban => new { ban.BanTime, CreatedBy = (Player?)ban.CreatedBy, ban.Reason, Unbanned = ban.Unban == null }) - .Select(banGroup => banGroup) - .ToArray(); - - List bans = new(); - var player = await db.DbContext.Player.SingleOrDefaultAsync(p => p.UserId == user); - foreach (var banGroup in bansEnumerable) - { - var firstBan = banGroup.First(); - Player? unbanningAdmin = null; - - if (firstBan.Unban?.UnbanningAdmin is not null) - unbanningAdmin = await db.DbContext.Player.SingleOrDefaultAsync(p => p.UserId == firstBan.Unban.UnbanningAdmin.Value); - - bans.Add(new ServerRoleBanNoteRecord( - firstBan.Id, - MakeRoundRecord(firstBan.Round), - MakePlayerRecord(player), - firstBan.PlaytimeAtNote, - firstBan.Reason, - firstBan.Severity, - MakePlayerRecord(firstBan.CreatedBy), - NormalizeDatabaseTime(firstBan.BanTime), - MakePlayerRecord(firstBan.LastEditedBy), - NormalizeDatabaseTime(firstBan.LastEditedAt), - NormalizeDatabaseTime(firstBan.ExpirationTime), - firstBan.Hidden, - banGroup.Select(ban => ban.RoleId.Replace(BanManager.PrefixJob, null).Replace(BanManager.PrefixAntag, null)).ToArray(), - MakePlayerRecord(unbanningAdmin), - NormalizeDatabaseTime(firstBan.Unban?.UnbanTime))); - } - - return bans; - } - #endregion #region Job Whitelists @@ -1864,5 +1734,19 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id} { } + + private static async Task> AsyncSelect( + IEnumerable? enumerable, + Func> selector) + { + var results = new List(); + + foreach (var item in enumerable ?? []) + { + results.Add(await selector(item)); + } + + return [..results]; + } } } diff --git a/Content.Server/Database/ServerDbManager.cs b/Content.Server/Database/ServerDbManager.cs index e36c484bde..747d09f7a0 100644 --- a/Content.Server/Database/ServerDbManager.cs +++ b/Content.Server/Database/ServerDbManager.cs @@ -63,7 +63,7 @@ namespace Content.Server.Database /// /// The ban id to look for. /// The ban with the given id or null if none exist. - Task GetServerBanAsync(int id); + Task GetBanAsync(int id); /// /// Looks up an user's most recent received un-pardoned ban. @@ -75,11 +75,12 @@ namespace Content.Server.Database /// The legacy HWID of the user. /// The modern HWIDs of the user. /// The user's latest received un-pardoned ban, or null if none exist. - Task GetServerBanAsync( + Task GetBanAsync( IPAddress? address, NetUserId? userId, ImmutableArray? hwId, - ImmutableArray>? modernHWIds); + ImmutableArray>? modernHWIds, + BanType type = BanType.Server); /// /// Looks up an user's ban history. @@ -91,17 +92,18 @@ namespace Content.Server.Database /// The modern HWIDs of the user. /// If true, bans that have been expired or pardoned are also included. /// The user's ban history. - Task> GetServerBansAsync( + Task> GetBansAsync( IPAddress? address, NetUserId? userId, ImmutableArray? hwId, ImmutableArray>? modernHWIds, - bool includeUnbanned=true); + bool includeUnbanned=true, + BanType type = BanType.Server); - Task AddServerBanAsync(ServerBanDef serverBan); - Task AddServerUnbanAsync(ServerUnbanDef serverBan); + Task AddBanAsync(BanDef ban); + Task AddUnbanAsync(UnbanDef ban); - public Task EditServerBan( + public Task EditBan( int id, string reason, NoteSeverity severity, @@ -127,45 +129,6 @@ namespace Content.Server.Database #endregion - #region Role Bans - /// - /// Looks up a role ban by id. - /// This will return a pardoned role ban as well. - /// - /// The role ban id to look for. - /// The role ban with the given id or null if none exist. - Task GetServerRoleBanAsync(int id); - - /// - /// Looks up an user's role ban history. - /// This will return pardoned role bans based on the bool. - /// Requires one of , , or to not be null. - /// - /// The IP address of the user. - /// The NetUserId of the user. - /// The Hardware Id of the user. - /// The modern HWIDs of the user. - /// Whether expired and pardoned bans are included. - /// The user's role ban history. - Task> GetServerRoleBansAsync( - IPAddress? address, - NetUserId? userId, - ImmutableArray? hwId, - ImmutableArray>? modernHWIds, - bool includeUnbanned = true); - - Task AddServerRoleBanAsync(ServerRoleBanDef serverBan); - Task AddServerRoleUnbanAsync(ServerRoleUnbanDef serverBan); - - public Task EditServerRoleBan( - int id, - string reason, - NoteSeverity severity, - DateTimeOffset? expiration, - Guid editedBy, - DateTimeOffset editedAt); - #endregion - #region Playtime /// @@ -205,7 +168,7 @@ namespace Content.Server.Database ConnectionDenyReason? denied, int serverId); - Task AddServerBanHitsAsync(int connection, IEnumerable bans); + Task AddServerBanHitsAsync(int connection, IEnumerable bans); #endregion @@ -297,8 +260,7 @@ namespace Content.Server.Database Task GetAdminNote(int id); Task GetAdminWatchlist(int id); Task GetAdminMessage(int id); - Task GetServerBanAsNoteAsync(int id); - Task GetServerRoleBanAsNoteAsync(int id); + Task GetBanAsNoteAsync(int id); Task> GetAllAdminRemarks(Guid player); Task> GetVisibleAdminNotes(Guid player); Task> GetActiveWatchlists(Guid player); @@ -309,8 +271,7 @@ namespace Content.Server.Database Task DeleteAdminNote(int id, Guid deletedBy, DateTimeOffset deletedAt); Task DeleteAdminWatchlist(int id, Guid deletedBy, DateTimeOffset deletedAt); Task DeleteAdminMessage(int id, Guid deletedBy, DateTimeOffset deletedAt); - Task HideServerBanFromNotes(int id, Guid deletedBy, DateTimeOffset deletedAt); - Task HideServerRoleBanFromNotes(int id, Guid deletedBy, DateTimeOffset deletedAt); + Task HideBanFromNotes(int id, Guid deletedBy, DateTimeOffset deletedAt); /// /// Mark an admin message as being seen by the target player. @@ -516,49 +477,51 @@ namespace Content.Server.Database return RunDbCommand(() => _db.GetAssignedUserIdAsync(name)); } - public Task GetServerBanAsync(int id) + public Task GetBanAsync(int id) { DbReadOpsMetric.Inc(); - return RunDbCommand(() => _db.GetServerBanAsync(id)); + return RunDbCommand(() => _db.GetBanAsync(id)); } - public Task GetServerBanAsync( - IPAddress? address, - NetUserId? userId, - ImmutableArray? hwId, - ImmutableArray>? modernHWIds) - { - DbReadOpsMetric.Inc(); - return RunDbCommand(() => _db.GetServerBanAsync(address, userId, hwId, modernHWIds)); - } - - public Task> GetServerBansAsync( + public Task GetBanAsync( IPAddress? address, NetUserId? userId, ImmutableArray? hwId, ImmutableArray>? modernHWIds, - bool includeUnbanned=true) + BanType type = BanType.Server) { DbReadOpsMetric.Inc(); - return RunDbCommand(() => _db.GetServerBansAsync(address, userId, hwId, modernHWIds, includeUnbanned)); + return RunDbCommand(() => _db.GetBanAsync(address, userId, hwId, modernHWIds, type)); } - public Task AddServerBanAsync(ServerBanDef serverBan) + public Task> GetBansAsync( + IPAddress? address, + NetUserId? userId, + ImmutableArray? hwId, + ImmutableArray>? modernHWIds, + bool includeUnbanned=true, + BanType type = BanType.Server) { - DbWriteOpsMetric.Inc(); - return RunDbCommand(() => _db.AddServerBanAsync(serverBan)); + DbReadOpsMetric.Inc(); + return RunDbCommand(() => _db.GetBansAsync(address, userId, hwId, modernHWIds, includeUnbanned, type)); } - public Task AddServerUnbanAsync(ServerUnbanDef serverUnban) + public Task AddBanAsync(BanDef ban) { DbWriteOpsMetric.Inc(); - return RunDbCommand(() => _db.AddServerUnbanAsync(serverUnban)); + return RunDbCommand(() => _db.AddBanAsync(ban)); } - public Task EditServerBan(int id, string reason, NoteSeverity severity, DateTimeOffset? expiration, Guid editedBy, DateTimeOffset editedAt) + public Task AddUnbanAsync(UnbanDef unban) { DbWriteOpsMetric.Inc(); - return RunDbCommand(() => _db.EditServerBan(id, reason, severity, expiration, editedBy, editedAt)); + return RunDbCommand(() => _db.AddUnbanAsync(unban)); + } + + public Task EditBan(int id, string reason, NoteSeverity severity, DateTimeOffset? expiration, Guid editedBy, DateTimeOffset editedAt) + { + DbWriteOpsMetric.Inc(); + return RunDbCommand(() => _db.EditBan(id, reason, severity, expiration, editedBy, editedAt)); } public Task UpdateBanExemption(NetUserId userId, ServerBanExemptFlags flags) @@ -573,43 +536,6 @@ namespace Content.Server.Database return RunDbCommand(() => _db.GetBanExemption(userId, cancel)); } - #region Role Ban - public Task GetServerRoleBanAsync(int id) - { - DbReadOpsMetric.Inc(); - return RunDbCommand(() => _db.GetServerRoleBanAsync(id)); - } - - public Task> GetServerRoleBansAsync( - IPAddress? address, - NetUserId? userId, - ImmutableArray? hwId, - ImmutableArray>? modernHWIds, - bool includeUnbanned = true) - { - DbReadOpsMetric.Inc(); - return RunDbCommand(() => _db.GetServerRoleBansAsync(address, userId, hwId, modernHWIds, includeUnbanned)); - } - - public Task AddServerRoleBanAsync(ServerRoleBanDef serverRoleBan) - { - DbWriteOpsMetric.Inc(); - return RunDbCommand(() => _db.AddServerRoleBanAsync(serverRoleBan)); - } - - public Task AddServerRoleUnbanAsync(ServerRoleUnbanDef serverRoleUnban) - { - DbWriteOpsMetric.Inc(); - return RunDbCommand(() => _db.AddServerRoleUnbanAsync(serverRoleUnban)); - } - - public Task EditServerRoleBan(int id, string reason, NoteSeverity severity, DateTimeOffset? expiration, Guid editedBy, DateTimeOffset editedAt) - { - DbWriteOpsMetric.Inc(); - return RunDbCommand(() => _db.EditServerRoleBan(id, reason, severity, expiration, editedBy, editedAt)); - } - #endregion - #region Playtime public Task> GetPlayTimes(Guid player, CancellationToken cancel) @@ -661,7 +587,7 @@ namespace Content.Server.Database return RunDbCommand(() => _db.AddConnectionLogAsync(userId, userName, address, hwId, trust, denied, serverId)); } - public Task AddServerBanHitsAsync(int connection, IEnumerable bans) + public Task AddServerBanHitsAsync(int connection, IEnumerable bans) { DbWriteOpsMetric.Inc(); return RunDbCommand(() => _db.AddServerBanHitsAsync(connection, bans)); @@ -922,16 +848,10 @@ namespace Content.Server.Database return RunDbCommand(() => _db.GetAdminMessage(id)); } - public Task GetServerBanAsNoteAsync(int id) + public Task GetBanAsNoteAsync(int id) { DbReadOpsMetric.Inc(); - return RunDbCommand(() => _db.GetServerBanAsNoteAsync(id)); - } - - public Task GetServerRoleBanAsNoteAsync(int id) - { - DbReadOpsMetric.Inc(); - return RunDbCommand(() => _db.GetServerRoleBanAsNoteAsync(id)); + return RunDbCommand(() => _db.GetBanAsNoteAsync(id)); } public Task> GetAllAdminRemarks(Guid player) @@ -993,16 +913,10 @@ namespace Content.Server.Database return RunDbCommand(() => _db.DeleteAdminMessage(id, deletedBy, deletedAt)); } - public Task HideServerBanFromNotes(int id, Guid deletedBy, DateTimeOffset deletedAt) + public Task HideBanFromNotes(int id, Guid deletedBy, DateTimeOffset deletedAt) { DbWriteOpsMetric.Inc(); - return RunDbCommand(() => _db.HideServerBanFromNotes(id, deletedBy, deletedAt)); - } - - public Task HideServerRoleBanFromNotes(int id, Guid deletedBy, DateTimeOffset deletedAt) - { - DbWriteOpsMetric.Inc(); - return RunDbCommand(() => _db.HideServerRoleBanFromNotes(id, deletedBy, deletedAt)); + return RunDbCommand(() => _db.HideBanFromNotes(id, deletedBy, deletedAt)); } public Task MarkMessageAsSeen(int id, bool dismissedToo) diff --git a/Content.Server/Database/ServerDbPostgres.cs b/Content.Server/Database/ServerDbPostgres.cs index c034670837..005624747e 100644 --- a/Content.Server/Database/ServerDbPostgres.cs +++ b/Content.Server/Database/ServerDbPostgres.cs @@ -58,24 +58,26 @@ namespace Content.Server.Database } #region Ban - public override async Task GetServerBanAsync(int id) + public override async Task GetBanAsync(int id) { await using var db = await GetDbImpl(); var query = db.PgDbContext.Ban - .Include(p => p.Unban) - .Where(p => p.Id == id); + .ApplyIncludes(GetBanDefIncludes()) + .Where(p => p.Id == id) + .AsSplitQuery(); var ban = await query.SingleOrDefaultAsync(); return ConvertBan(ban); } - public override async Task GetServerBanAsync( + public override async Task GetBanAsync( IPAddress? address, NetUserId? userId, ImmutableArray? hwId, - ImmutableArray>? modernHWIds) + ImmutableArray>? modernHWIds, + BanType type) { if (address == null && userId == null && hwId == null) { @@ -86,7 +88,7 @@ namespace Content.Server.Database var exempt = await GetBanExemptionCore(db, userId); var newPlayer = userId == null || !await PlayerRecordExists(db, userId.Value); - var query = MakeBanLookupQuery(address, userId, hwId, modernHWIds, db, includeUnbanned: false, exempt, newPlayer) + var query = MakeBanLookupQuery(address, userId, hwId, modernHWIds, db, includeUnbanned: false, exempt, newPlayer, type) .OrderByDescending(b => b.BanTime); var ban = await query.FirstOrDefaultAsync(); @@ -94,11 +96,12 @@ namespace Content.Server.Database return ConvertBan(ban); } - public override async Task> GetServerBansAsync(IPAddress? address, + public override async Task> GetBansAsync(IPAddress? address, NetUserId? userId, ImmutableArray? hwId, ImmutableArray>? modernHWIds, - bool includeUnbanned) + bool includeUnbanned, + BanType type) { if (address == null && userId == null && hwId == null) { @@ -107,12 +110,11 @@ namespace Content.Server.Database await using var db = await GetDbImpl(); - var exempt = await GetBanExemptionCore(db, userId); + var exempt = type == BanType.Role ? null : await GetBanExemptionCore(db, userId); var newPlayer = !await db.PgDbContext.Player.AnyAsync(p => p.UserId == userId); - var query = MakeBanLookupQuery(address, userId, hwId, modernHWIds, db, includeUnbanned, exempt, newPlayer); - + var query = MakeBanLookupQuery(address, userId, hwId, modernHWIds, db, includeUnbanned, exempt, newPlayer, type); var queryBans = await query.ToArrayAsync(); - var bans = new List(queryBans.Length); + var bans = new List(queryBans.Length); foreach (var ban in queryBans) { @@ -127,7 +129,8 @@ namespace Content.Server.Database return bans; } - private static IQueryable MakeBanLookupQuery( + // This has to return IDs instead of direct objects because otherwise all the includes are too complicated. + private static IQueryable MakeBanLookupQuery( IPAddress? address, NetUserId? userId, ImmutableArray? hwId, @@ -135,34 +138,55 @@ namespace Content.Server.Database DbGuardImpl db, bool includeUnbanned, ServerBanExemptFlags? exemptFlags, - bool newPlayer) + bool newPlayer, + BanType type) { DebugTools.Assert(!(address == null && userId == null && hwId == null)); - var query = MakeBanLookupQualityShared( - userId, - hwId, - modernHWIds, - db.PgDbContext.Ban); + var selectorQueries = new List>(); - if (address != null && !exemptFlags.GetValueOrDefault(ServerBanExemptFlags.None).HasFlag(ServerBanExemptFlags.IP)) + if (userId is { } uid) + selectorQueries.Add(db.DbContext.BanPlayer.Where(b => b.UserId == uid.UserId)); + + if (hwId != null && hwId.Value.Length > 0) { - var newQ = db.PgDbContext.Ban - .Include(p => p.Unban) - .Where(b => b.Address != null - && EF.Functions.ContainsOrEqual(b.Address.Value, address) - && !(b.ExemptFlags.HasFlag(ServerBanExemptFlags.BlacklistedRange) && !newPlayer)); + selectorQueries.Add(db.DbContext.BanHwid.Where(bh => + bh.HWId!.Type == HwidType.Legacy && bh.HWId!.Hwid.SequenceEqual(hwId.Value.ToArray()) + )); + } - query = query == null ? newQ : query.Union(newQ); + if (modernHWIds != null) + { + foreach (var modernHwid in modernHWIds) + { + selectorQueries.Add(db.DbContext.BanHwid + .Where(b => b.HWId!.Type == HwidType.Modern + && b.HWId!.Hwid.SequenceEqual(modernHwid.ToArray()))); + } + } + + if (address != null && !exemptFlags.GetValueOrDefault(ServerBanExemptFlags.None) + .HasFlag(ServerBanExemptFlags.IP)) + { + selectorQueries.Add(db.PgDbContext.BanAddress + .Where(ba => EF.Functions.ContainsOrEqual(ba.Address, address) + && !(ba.Ban!.ExemptFlags.HasFlag(ServerBanExemptFlags.BlacklistedRange) && + !newPlayer))); } DebugTools.Assert( - query != null, + selectorQueries.Count > 0, "At least one filter item (IP/UserID/HWID) must have been given to make query not null."); + var selectorQuery = selectorQueries + .Select(q => q.Select(sel => sel.BanId)) + .Aggregate((selectors, queryable) => selectors.Union(queryable)); + + var banQuery = db.DbContext.Ban.Where(b => selectorQuery.Contains(b.Id)); + if (!includeUnbanned) { - query = query.Where(p => + banQuery = banQuery.Where(p => p.Unban == null && (p.ExpirationTime == null || p.ExpirationTime.Value > DateTime.UtcNow)); } @@ -171,68 +195,23 @@ namespace Content.Server.Database if (exempt != ServerBanExemptFlags.None) exempt |= ServerBanExemptFlags.BlacklistedRange; // Any kind of exemption should bypass BlacklistedRange - query = query.Where(b => (b.ExemptFlags & exempt) == 0); + banQuery = banQuery.Where(b => (b.ExemptFlags & exempt) == 0); } - return query.Distinct(); + return banQuery + .Where(b => b.Type == type) + .ApplyIncludes(GetBanDefIncludes(type)) + .AsSplitQuery(); } - private static IQueryable? MakeBanLookupQualityShared( - NetUserId? userId, - ImmutableArray? hwId, - ImmutableArray>? modernHWIds, - DbSet set) - where TBan : class, IBanCommon - where TUnban : class, IUnbanCommon - { - IQueryable? query = null; - - if (userId is { } uid) - { - var newQ = set - .Include(p => p.Unban) - .Where(b => b.PlayerUserId == uid.UserId); - - query = query == null ? newQ : query.Union(newQ); - } - - if (hwId != null && hwId.Value.Length > 0) - { - var newQ = set - .Include(p => p.Unban) - .Where(b => b.HWId!.Type == HwidType.Legacy && b.HWId!.Hwid.SequenceEqual(hwId.Value.ToArray())); - - query = query == null ? newQ : query.Union(newQ); - } - - if (modernHWIds != null) - { - foreach (var modernHwid in modernHWIds) - { - var newQ = set - .Include(p => p.Unban) - .Where(b => b.HWId!.Type == HwidType.Modern && b.HWId!.Hwid.SequenceEqual(modernHwid.ToArray())); - - query = query == null ? newQ : query.Union(newQ); - } - } - - return query; - } - - private static ServerBanDef? ConvertBan(ServerBan? ban) + [return: NotNullIfNotNull(nameof(ban))] + private static BanDef? ConvertBan(Ban? ban) { if (ban == null) { return null; } - NetUserId? uid = null; - if (ban.PlayerUserId is {} guid) - { - uid = new NetUserId(guid); - } - NetUserId? aUid = null; if (ban.BanningAdmin is {} aGuid) { @@ -241,23 +220,31 @@ namespace Content.Server.Database var unbanDef = ConvertUnban(ban.Unban); - return new ServerBanDef( + ImmutableArray? roles = null; + if (ban.Type == BanType.Role) + { + roles = [..ban.Roles!.Select(br => new BanRoleDef(br.RoleType, br.RoleId))]; + } + + return new BanDef( ban.Id, - uid, - ban.Address.ToTuple(), - ban.HWId, + ban.Type, + [..ban.Players!.Select(bp => new NetUserId(bp.UserId))], + [..ban.Addresses!.Select(ba => ba.Address.ToTuple())], + [..ban.Hwids!.Select(bh => bh.HWId)], ban.BanTime, ban.ExpirationTime, - ban.RoundId, + [..ban.Rounds!.Select(r => r.RoundId)], ban.PlaytimeAtNote, ban.Reason, ban.Severity, aUid, unbanDef, - ban.ExemptFlags); + ban.ExemptFlags, + roles); } - private static ServerUnbanDef? ConvertUnban(ServerUnban? unban) + private static UnbanDef? ConvertUnban(Unban? unban) { if (unban == null) { @@ -270,224 +257,54 @@ namespace Content.Server.Database aUid = new NetUserId(aGuid); } - return new ServerUnbanDef( + return new UnbanDef( unban.Id, aUid, unban.UnbanTime); } - public override async Task AddServerBanAsync(ServerBanDef serverBan) + public override async Task AddBanAsync(BanDef ban) { await using var db = await GetDbImpl(); - db.PgDbContext.Ban.Add(new ServerBan + var banEntity = new Ban { - Address = serverBan.Address.ToNpgsqlInet(), - HWId = serverBan.HWId, - Reason = serverBan.Reason, - Severity = serverBan.Severity, - BanningAdmin = serverBan.BanningAdmin?.UserId, - BanTime = serverBan.BanTime.UtcDateTime, - ExpirationTime = serverBan.ExpirationTime?.UtcDateTime, - RoundId = serverBan.RoundId, - PlaytimeAtNote = serverBan.PlaytimeAtNote, - PlayerUserId = serverBan.UserId?.UserId, - ExemptFlags = serverBan.ExemptFlags - }); - - await db.PgDbContext.SaveChangesAsync(); - } - - public override async Task AddServerUnbanAsync(ServerUnbanDef serverUnban) - { - await using var db = await GetDbImpl(); - - db.PgDbContext.Unban.Add(new ServerUnban - { - BanId = serverUnban.BanId, - UnbanningAdmin = serverUnban.UnbanningAdmin?.UserId, - UnbanTime = serverUnban.UnbanTime.UtcDateTime - }); - - await db.PgDbContext.SaveChangesAsync(); - } - #endregion - - #region Role Ban - public override async Task GetServerRoleBanAsync(int id) - { - await using var db = await GetDbImpl(); - - var query = db.PgDbContext.RoleBan - .Include(p => p.Unban) - .Where(p => p.Id == id); - - var ban = await query.SingleOrDefaultAsync(); - - return ConvertRoleBan(ban); - - } - - public override async Task> GetServerRoleBansAsync(IPAddress? address, - NetUserId? userId, - ImmutableArray? hwId, - ImmutableArray>? modernHWIds, - bool includeUnbanned) - { - if (address == null && userId == null && hwId == null) - { - throw new ArgumentException("Address, userId, and hwId cannot all be null"); - } - - await using var db = await GetDbImpl(); - - var query = MakeRoleBanLookupQuery(address, userId, hwId, modernHWIds, db, includeUnbanned) - .OrderByDescending(b => b.BanTime); - - return await QueryRoleBans(query); - } - - private static async Task> QueryRoleBans(IQueryable query) - { - var queryRoleBans = await query.ToArrayAsync(); - var bans = new List(queryRoleBans.Length); - - foreach (var ban in queryRoleBans) - { - var banDef = ConvertRoleBan(ban); - - if (banDef != null) - { - bans.Add(banDef); - } - } - - return bans; - } - - private static IQueryable MakeRoleBanLookupQuery( - IPAddress? address, - NetUserId? userId, - ImmutableArray? hwId, - ImmutableArray>? modernHWIds, - DbGuardImpl db, - bool includeUnbanned) - { - var query = MakeBanLookupQualityShared( - userId, - hwId, - modernHWIds, - db.PgDbContext.RoleBan); - - if (address != null) - { - var newQ = db.PgDbContext.RoleBan - .Include(p => p.Unban) - .Where(b => b.Address != null && EF.Functions.ContainsOrEqual(b.Address.Value, address)); - - query = query == null ? newQ : query.Union(newQ); - } - - if (!includeUnbanned) - { - query = query?.Where(p => - p.Unban == null && (p.ExpirationTime == null || p.ExpirationTime.Value > DateTime.UtcNow)); - } - - query = query!.Distinct(); - return query; - } - - [return: NotNullIfNotNull(nameof(ban))] - private static ServerRoleBanDef? ConvertRoleBan(ServerRoleBan? ban) - { - if (ban == null) - { - return null; - } - - NetUserId? uid = null; - if (ban.PlayerUserId is {} guid) - { - uid = new NetUserId(guid); - } - - NetUserId? aUid = null; - if (ban.BanningAdmin is {} aGuid) - { - aUid = new NetUserId(aGuid); - } - - var unbanDef = ConvertRoleUnban(ban.Unban); - - return new ServerRoleBanDef( - ban.Id, - uid, - ban.Address.ToTuple(), - ban.HWId, - ban.BanTime, - ban.ExpirationTime, - ban.RoundId, - ban.PlaytimeAtNote, - ban.Reason, - ban.Severity, - aUid, - unbanDef, - ban.RoleId); - } - - private static ServerRoleUnbanDef? ConvertRoleUnban(ServerRoleUnban? unban) - { - if (unban == null) - { - return null; - } - - NetUserId? aUid = null; - if (unban.UnbanningAdmin is {} aGuid) - { - aUid = new NetUserId(aGuid); - } - - return new ServerRoleUnbanDef( - unban.Id, - aUid, - unban.UnbanTime); - } - - public override async Task AddServerRoleBanAsync(ServerRoleBanDef serverRoleBan) - { - await using var db = await GetDbImpl(); - - var ban = new ServerRoleBan - { - Address = serverRoleBan.Address.ToNpgsqlInet(), - HWId = serverRoleBan.HWId, - Reason = serverRoleBan.Reason, - Severity = serverRoleBan.Severity, - BanningAdmin = serverRoleBan.BanningAdmin?.UserId, - BanTime = serverRoleBan.BanTime.UtcDateTime, - ExpirationTime = serverRoleBan.ExpirationTime?.UtcDateTime, - RoundId = serverRoleBan.RoundId, - PlaytimeAtNote = serverRoleBan.PlaytimeAtNote, - PlayerUserId = serverRoleBan.UserId?.UserId, - RoleId = serverRoleBan.Role, + Type = ban.Type, + Addresses = [..ban.Addresses.Select(ba => new BanAddress { Address = ba.ToNpgsqlInet() })], + Hwids = [..ban.HWIds.Select(bh => new BanHwid { HWId = bh })], + Reason = ban.Reason, + Severity = ban.Severity, + BanningAdmin = ban.BanningAdmin?.UserId, + BanTime = ban.BanTime.UtcDateTime, + ExpirationTime = ban.ExpirationTime?.UtcDateTime, + Rounds = [..ban.RoundIds.Select(bri => new BanRound { RoundId = bri })], + PlaytimeAtNote = ban.PlaytimeAtNote, + Players = [..ban.UserIds.Select(bp => new BanPlayer { UserId = bp.UserId })], + ExemptFlags = ban.ExemptFlags, + Roles = ban.Roles == null + ? [] + : ban.Roles.Value.Select(brd => new BanRole + { + RoleType = brd.RoleType, + RoleId = brd.RoleId + }) + .ToList(), }; - db.PgDbContext.RoleBan.Add(ban); + db.PgDbContext.Ban.Add(banEntity); await db.PgDbContext.SaveChangesAsync(); - return ConvertRoleBan(ban); + return ConvertBan(banEntity); } - public override async Task AddServerRoleUnbanAsync(ServerRoleUnbanDef serverRoleUnban) + public override async Task AddUnbanAsync(UnbanDef unban) { await using var db = await GetDbImpl(); - db.PgDbContext.RoleUnban.Add(new ServerRoleUnban + db.PgDbContext.Unban.Add(new Unban { - BanId = serverRoleUnban.BanId, - UnbanningAdmin = serverRoleUnban.UnbanningAdmin?.UserId, - UnbanTime = serverRoleUnban.UnbanTime.UtcDateTime + BanId = unban.BanId, + UnbanningAdmin = unban.UnbanningAdmin?.UserId, + UnbanTime = unban.UnbanTime.UtcDateTime }); await db.PgDbContext.SaveChangesAsync(); diff --git a/Content.Server/Database/ServerDbSqlite.cs b/Content.Server/Database/ServerDbSqlite.cs index c3109ec6e6..6ccd4c5e28 100644 --- a/Content.Server/Database/ServerDbSqlite.cs +++ b/Content.Server/Database/ServerDbSqlite.cs @@ -66,48 +66,52 @@ namespace Content.Server.Database } #region Ban - public override async Task GetServerBanAsync(int id) + public override async Task GetBanAsync(int id) { await using var db = await GetDbImpl(); var ban = await db.SqliteDbContext.Ban - .Include(p => p.Unban) + .ApplyIncludes(GetBanDefIncludes()) .Where(p => p.Id == id) + .AsSplitQuery() .SingleOrDefaultAsync(); return ConvertBan(ban); } - public override async Task GetServerBanAsync( - IPAddress? address, - NetUserId? userId, - ImmutableArray? hwId, - ImmutableArray>? modernHWIds) - { - await using var db = await GetDbImpl(); - - return (await GetServerBanQueryAsync(db, address, userId, hwId, modernHWIds, includeUnbanned: false)).FirstOrDefault(); - } - - public override async Task> GetServerBansAsync( + public override async Task GetBanAsync( IPAddress? address, NetUserId? userId, ImmutableArray? hwId, ImmutableArray>? modernHWIds, - bool includeUnbanned) + BanType type) { await using var db = await GetDbImpl(); - return (await GetServerBanQueryAsync(db, address, userId, hwId, modernHWIds, includeUnbanned)).ToList(); + return (await GetBanQueryAsync(db, address, userId, hwId, modernHWIds, includeUnbanned: false, type)).FirstOrDefault(); } - private async Task> GetServerBanQueryAsync( + public override async Task> GetBansAsync( + IPAddress? address, + NetUserId? userId, + ImmutableArray? hwId, + ImmutableArray>? modernHWIds, + bool includeUnbanned, + BanType type) + { + await using var db = await GetDbImpl(); + + return (await GetBanQueryAsync(db, address, userId, hwId, modernHWIds, includeUnbanned, type)).ToList(); + } + + private async Task> GetBanQueryAsync( DbGuardImpl db, IPAddress? address, NetUserId? userId, ImmutableArray? hwId, ImmutableArray>? modernHWIds, - bool includeUnbanned) + bool includeUnbanned, + BanType type) { var exempt = await GetBanExemptionCore(db, userId); @@ -115,7 +119,7 @@ namespace Content.Server.Database // SQLite can't do the net masking stuff we need to match IP address ranges. // So just pull down the whole list into memory. - var queryBans = await GetAllBans(db.SqliteDbContext, includeUnbanned, exempt); + var queryBans = await GetAllBans(db.SqliteDbContext, includeUnbanned, exempt, type); var playerInfo = new BanMatcher.PlayerInfo { @@ -132,12 +136,12 @@ namespace Content.Server.Database .Where(b => BanMatcher.BanMatches(b!, playerInfo))!; } - private static async Task> GetAllBans( - SqliteServerDbContext db, + private static async Task> GetAllBans(SqliteServerDbContext db, bool includeUnbanned, - ServerBanExemptFlags? exemptFlags) + ServerBanExemptFlags? exemptFlags, + BanType type) { - IQueryable query = db.Ban.Include(p => p.Unban); + var query = db.Ban.Where(b => b.Type == type).ApplyIncludes(GetBanDefIncludes(type)); if (!includeUnbanned) { query = query.Where(p => @@ -153,244 +157,65 @@ namespace Content.Server.Database query = query.Where(b => (b.ExemptFlags & exempt) == 0); } - return await query.ToListAsync(); + return await query.AsSplitQuery().ToListAsync(); } - public override async Task AddServerBanAsync(ServerBanDef serverBan) + public override async Task AddBanAsync(BanDef ban) { await using var db = await GetDbImpl(); - db.SqliteDbContext.Ban.Add(new ServerBan + var banEntity = new Ban { - Address = serverBan.Address.ToNpgsqlInet(), - Reason = serverBan.Reason, - Severity = serverBan.Severity, - BanningAdmin = serverBan.BanningAdmin?.UserId, - HWId = serverBan.HWId, - BanTime = serverBan.BanTime.UtcDateTime, - ExpirationTime = serverBan.ExpirationTime?.UtcDateTime, - RoundId = serverBan.RoundId, - PlaytimeAtNote = serverBan.PlaytimeAtNote, - PlayerUserId = serverBan.UserId?.UserId, - ExemptFlags = serverBan.ExemptFlags - }); - - await db.SqliteDbContext.SaveChangesAsync(); - } - - public override async Task AddServerUnbanAsync(ServerUnbanDef serverUnban) - { - await using var db = await GetDbImpl(); - - db.SqliteDbContext.Unban.Add(new ServerUnban - { - BanId = serverUnban.BanId, - UnbanningAdmin = serverUnban.UnbanningAdmin?.UserId, - UnbanTime = serverUnban.UnbanTime.UtcDateTime - }); - - await db.SqliteDbContext.SaveChangesAsync(); - } - #endregion - - #region Role Ban - public override async Task GetServerRoleBanAsync(int id) - { - await using var db = await GetDbImpl(); - - var ban = await db.SqliteDbContext.RoleBan - .Include(p => p.Unban) - .Where(p => p.Id == id) - .SingleOrDefaultAsync(); - - return ConvertRoleBan(ban); - } - - public override async Task> GetServerRoleBansAsync( - IPAddress? address, - NetUserId? userId, - ImmutableArray? hwId, - ImmutableArray>? modernHWIds, - bool includeUnbanned) - { - await using var db = await GetDbImpl(); - - // SQLite can't do the net masking stuff we need to match IP address ranges. - // So just pull down the whole list into memory. - var queryBans = await GetAllRoleBans(db.SqliteDbContext, includeUnbanned); - - return queryBans - .Where(b => RoleBanMatches(b, address, userId, hwId, modernHWIds)) - .Select(ConvertRoleBan) - .ToList()!; - } - - private static async Task> GetAllRoleBans( - SqliteServerDbContext db, - bool includeUnbanned) - { - IQueryable query = db.RoleBan.Include(p => p.Unban); - if (!includeUnbanned) - { - query = query.Where(p => - p.Unban == null && (p.ExpirationTime == null || p.ExpirationTime.Value > DateTime.UtcNow)); - } - - return await query.ToListAsync(); - } - - private static bool RoleBanMatches( - ServerRoleBan ban, - IPAddress? address, - NetUserId? userId, - ImmutableArray? hwId, - ImmutableArray>? modernHWIds) - { - if (address != null && ban.Address is not null && address.IsInSubnet(ban.Address.ToTuple().Value)) - { - return true; - } - - if (userId is { } id && ban.PlayerUserId == id.UserId) - { - return true; - } - - switch (ban.HWId?.Type) - { - case HwidType.Legacy: - if (hwId is { Length: > 0 } hwIdVar && hwIdVar.AsSpan().SequenceEqual(ban.HWId.Hwid)) - return true; - break; - - case HwidType.Modern: - if (modernHWIds != null) - { - foreach (var modernHWId in modernHWIds) + Type = ban.Type, + Addresses = [..ban.Addresses.Select(ba => new BanAddress { Address = ba.ToNpgsqlInet() })], + Hwids = [..ban.HWIds.Select(bh => new BanHwid { HWId = bh })], + Reason = ban.Reason, + Severity = ban.Severity, + BanningAdmin = ban.BanningAdmin?.UserId, + BanTime = ban.BanTime.UtcDateTime, + ExpirationTime = ban.ExpirationTime?.UtcDateTime, + Rounds = [..ban.RoundIds.Select(bri => new BanRound { RoundId = bri })], + PlaytimeAtNote = ban.PlaytimeAtNote, + Players = [..ban.UserIds.Select(bp => new BanPlayer { UserId = bp.UserId })], + ExemptFlags = ban.ExemptFlags, + Roles = ban.Roles == null + ? [] + : ban.Roles.Value.Select(brd => new BanRole { - if (modernHWId.AsSpan().SequenceEqual(ban.HWId.Hwid)) - return true; - } - } - - break; - } - - return false; - } - - public override async Task AddServerRoleBanAsync(ServerRoleBanDef serverBan) - { - await using var db = await GetDbImpl(); - - var ban = new ServerRoleBan - { - Address = serverBan.Address.ToNpgsqlInet(), - Reason = serverBan.Reason, - Severity = serverBan.Severity, - BanningAdmin = serverBan.BanningAdmin?.UserId, - HWId = serverBan.HWId, - BanTime = serverBan.BanTime.UtcDateTime, - ExpirationTime = serverBan.ExpirationTime?.UtcDateTime, - RoundId = serverBan.RoundId, - PlaytimeAtNote = serverBan.PlaytimeAtNote, - PlayerUserId = serverBan.UserId?.UserId, - RoleId = serverBan.Role, + RoleType = brd.RoleType, + RoleId = brd.RoleId + }) + .ToList(), }; - db.SqliteDbContext.RoleBan.Add(ban); + db.SqliteDbContext.Ban.Add(banEntity); await db.SqliteDbContext.SaveChangesAsync(); - return ConvertRoleBan(ban); + return ConvertBan(banEntity); } - public override async Task AddServerRoleUnbanAsync(ServerRoleUnbanDef serverUnban) + public override async Task AddUnbanAsync(UnbanDef unban) { await using var db = await GetDbImpl(); - db.SqliteDbContext.RoleUnban.Add(new ServerRoleUnban + db.SqliteDbContext.Unban.Add(new Unban { - BanId = serverUnban.BanId, - UnbanningAdmin = serverUnban.UnbanningAdmin?.UserId, - UnbanTime = serverUnban.UnbanTime.UtcDateTime + BanId = unban.BanId, + UnbanningAdmin = unban.UnbanningAdmin?.UserId, + UnbanTime = unban.UnbanTime.UtcDateTime }); await db.SqliteDbContext.SaveChangesAsync(); } - - [return: NotNullIfNotNull(nameof(ban))] - private static ServerRoleBanDef? ConvertRoleBan(ServerRoleBan? ban) - { - if (ban == null) - { - return null; - } - - NetUserId? uid = null; - if (ban.PlayerUserId is { } guid) - { - uid = new NetUserId(guid); - } - - NetUserId? aUid = null; - if (ban.BanningAdmin is { } aGuid) - { - aUid = new NetUserId(aGuid); - } - - var unban = ConvertRoleUnban(ban.Unban); - - return new ServerRoleBanDef( - ban.Id, - uid, - ban.Address.ToTuple(), - ban.HWId, - // SQLite apparently always reads DateTime as unspecified, but we always write as UTC. - DateTime.SpecifyKind(ban.BanTime, DateTimeKind.Utc), - ban.ExpirationTime == null ? null : DateTime.SpecifyKind(ban.ExpirationTime.Value, DateTimeKind.Utc), - ban.RoundId, - ban.PlaytimeAtNote, - ban.Reason, - ban.Severity, - aUid, - unban, - ban.RoleId); - } - - private static ServerRoleUnbanDef? ConvertRoleUnban(ServerRoleUnban? unban) - { - if (unban == null) - { - return null; - } - - NetUserId? aUid = null; - if (unban.UnbanningAdmin is { } aGuid) - { - aUid = new NetUserId(aGuid); - } - - return new ServerRoleUnbanDef( - unban.Id, - aUid, - // SQLite apparently always reads DateTime as unspecified, but we always write as UTC. - DateTime.SpecifyKind(unban.UnbanTime, DateTimeKind.Utc)); - } #endregion [return: NotNullIfNotNull(nameof(ban))] - private static ServerBanDef? ConvertBan(ServerBan? ban) + private static BanDef? ConvertBan(Ban? ban) { if (ban == null) { return null; } - NetUserId? uid = null; - if (ban.PlayerUserId is { } guid) - { - uid = new NetUserId(guid); - } - NetUserId? aUid = null; if (ban.BanningAdmin is { } aGuid) { @@ -399,23 +224,32 @@ namespace Content.Server.Database var unban = ConvertUnban(ban.Unban); - return new ServerBanDef( + ImmutableArray? roles = null; + if (ban.Type == BanType.Role) + { + roles = [..ban.Roles!.Select(br => new BanRoleDef(br.RoleType, br.RoleId))]; + } + + return new BanDef( ban.Id, - uid, - ban.Address.ToTuple(), - ban.HWId, + ban.Type, + [..ban.Players!.Select(bp => new NetUserId(bp.UserId))], + [..ban.Addresses!.Select(ba => ba.Address.ToTuple())], + [..ban.Hwids!.Select(bh => bh.HWId)], // SQLite apparently always reads DateTime as unspecified, but we always write as UTC. DateTime.SpecifyKind(ban.BanTime, DateTimeKind.Utc), ban.ExpirationTime == null ? null : DateTime.SpecifyKind(ban.ExpirationTime.Value, DateTimeKind.Utc), - ban.RoundId, + [..ban.Rounds!.Select(r => r.RoundId)], ban.PlaytimeAtNote, ban.Reason, ban.Severity, aUid, - unban); + unban, + ban.ExemptFlags, + roles); } - private static ServerUnbanDef? ConvertUnban(ServerUnban? unban) + private static UnbanDef? ConvertUnban(Unban? unban) { if (unban == null) { @@ -428,7 +262,7 @@ namespace Content.Server.Database aUid = new NetUserId(aGuid); } - return new ServerUnbanDef( + return new UnbanDef( unban.Id, aUid, // SQLite apparently always reads DateTime as unspecified, but we always write as UTC. diff --git a/Content.Server/Database/ServerRoleBanDef.cs b/Content.Server/Database/ServerRoleBanDef.cs deleted file mode 100644 index dda3a82237..0000000000 --- a/Content.Server/Database/ServerRoleBanDef.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Net; -using Content.Shared.Database; -using Robust.Shared.Network; - -namespace Content.Server.Database; - -public sealed class ServerRoleBanDef -{ - public int? Id { get; } - public NetUserId? UserId { get; } - public (IPAddress address, int cidrMask)? Address { get; } - public ImmutableTypedHwid? HWId { get; } - - public DateTimeOffset BanTime { get; } - public DateTimeOffset? ExpirationTime { get; } - public int? RoundId { get; } - public TimeSpan PlaytimeAtNote { get; } - public string Reason { get; } - public NoteSeverity Severity { get; set; } - public NetUserId? BanningAdmin { get; } - public ServerRoleUnbanDef? Unban { get; } - public string Role { get; } - - public ServerRoleBanDef( - int? id, - NetUserId? userId, - (IPAddress, int)? address, - ImmutableTypedHwid? hwId, - DateTimeOffset banTime, - DateTimeOffset? expirationTime, - int? roundId, - TimeSpan playtimeAtNote, - string reason, - NoteSeverity severity, - NetUserId? banningAdmin, - ServerRoleUnbanDef? unban, - string role) - { - if (userId == null && address == null && hwId == null) - { - throw new ArgumentException("Must have at least one of banned user, banned address or hardware ID"); - } - - if (address is {} addr && addr.Item1.IsIPv4MappedToIPv6) - { - // Fix IPv6-mapped IPv4 addresses - // So that IPv4 addresses are consistent between separate-socket and dual-stack socket modes. - address = (addr.Item1.MapToIPv4(), addr.Item2 - 96); - } - - Id = id; - UserId = userId; - Address = address; - HWId = hwId; - BanTime = banTime; - ExpirationTime = expirationTime; - RoundId = roundId; - PlaytimeAtNote = playtimeAtNote; - Reason = reason; - Severity = severity; - BanningAdmin = banningAdmin; - Unban = unban; - Role = role; - } -} diff --git a/Content.Server/Database/ServerRoleUnbanDef.cs b/Content.Server/Database/ServerRoleUnbanDef.cs deleted file mode 100644 index 3960a86808..0000000000 --- a/Content.Server/Database/ServerRoleUnbanDef.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Robust.Shared.Network; - -namespace Content.Server.Database; - -public sealed class ServerRoleUnbanDef -{ - public int BanId { get; } - - public NetUserId? UnbanningAdmin { get; } - - public DateTimeOffset UnbanTime { get; } - - public ServerRoleUnbanDef(int banId, NetUserId? unbanningAdmin, DateTimeOffset unbanTime) - { - BanId = banId; - UnbanningAdmin = unbanningAdmin; - UnbanTime = unbanTime; - } -} diff --git a/Content.Server/Database/ServerUnbanDef.cs b/Content.Server/Database/UnbanDef.cs similarity index 72% rename from Content.Server/Database/ServerUnbanDef.cs rename to Content.Server/Database/UnbanDef.cs index 3d39a6b90c..1fa4fc2a6a 100644 --- a/Content.Server/Database/ServerUnbanDef.cs +++ b/Content.Server/Database/UnbanDef.cs @@ -2,7 +2,7 @@ namespace Content.Server.Database { - public sealed class ServerUnbanDef + public sealed class UnbanDef { public int BanId { get; } @@ -10,7 +10,7 @@ namespace Content.Server.Database public DateTimeOffset UnbanTime { get; } - public ServerUnbanDef(int banId, NetUserId? unbanningAdmin, DateTimeOffset unbanTime) + public UnbanDef(int banId, NetUserId? unbanningAdmin, DateTimeOffset unbanTime) { BanId = banId; UnbanningAdmin = unbanningAdmin; diff --git a/Content.Server/IP/IPAddressExt.cs b/Content.Server/IP/IPAddressExt.cs index a61477e01b..8c514e96b9 100644 --- a/Content.Server/IP/IPAddressExt.cs +++ b/Content.Server/IP/IPAddressExt.cs @@ -11,22 +11,14 @@ namespace Content.Server.IP { // Npgsql used to map inet types as a tuple like this. // I'm upgrading the dependencies and I don't wanna rewrite a bunch of DB code, so a few helpers it shall be. - [return: NotNullIfNotNull(nameof(tuple))] - public static NpgsqlInet? ToNpgsqlInet(this (IPAddress, int)? tuple) + public static NpgsqlInet ToNpgsqlInet(this (IPAddress, int) tuple) { - if (tuple == null) - return null; - - return new NpgsqlInet(tuple.Value.Item1, (byte) tuple.Value.Item2); + return new NpgsqlInet(tuple.Item1, (byte)tuple.Item2); } - [return: NotNullIfNotNull(nameof(inet))] - public static (IPAddress, int)? ToTuple(this NpgsqlInet? inet) + public static (IPAddress, int) ToTuple(this NpgsqlInet inet) { - if (inet == null) - return null; - - return (inet.Value.Address, inet.Value.Netmask); + return (inet.Address, inet.Netmask); } // Taken from https://stackoverflow.com/a/56461160/4678631 diff --git a/Content.Server/Voting/Managers/VoteManager.DefaultVotes.cs b/Content.Server/Voting/Managers/VoteManager.DefaultVotes.cs index 614a54e0f6..a34c90c363 100644 --- a/Content.Server/Voting/Managers/VoteManager.DefaultVotes.cs +++ b/Content.Server/Voting/Managers/VoteManager.DefaultVotes.cs @@ -371,14 +371,7 @@ namespace Content.Server.Voting.Managers } var targetUid = located.UserId; var targetHWid = located.LastHWId; - (IPAddress, int)? targetIP = null; - - if (located.LastAddress is not null) - { - targetIP = located.LastAddress.AddressFamily is AddressFamily.InterNetwork - ? (located.LastAddress, 32) // People with ipv4 addresses get a /32 address so we ban that - : (located.LastAddress, 64); // This can only be an ipv6 address. People with ipv6 address should get /64 addresses so we ban that. - } + var targetIP = located.LastAddress; if (!_playerManager.TryGetSessionById(located.UserId, out ICommonSession? targetSession)) { @@ -544,7 +537,15 @@ namespace Content.Server.Voting.Managers uint minutes = (uint)_cfg.GetCVar(CCVars.VotekickBanDuration); - _bans.CreateServerBan(targetUid, target, null, targetIP, targetHWid, minutes, severity, Loc.GetString("votekick-ban-reason", ("reason", reason))); + var banInfo = new CreateServerBanInfo(Loc.GetString("votekick-ban-reason", ("reason", reason))); + banInfo.AddUser(targetUid, target); + banInfo.AddHWId(targetHWid); + banInfo.AddAddress(targetIP); + banInfo.WithSeverity(severity); + if (minutes > 0) + banInfo.WithMinutes(minutes); + + _bans.CreateServerBan(banInfo); } } else diff --git a/Content.Shared.Database/Bans.cs b/Content.Shared.Database/Bans.cs new file mode 100644 index 0000000000..9fb100f5db --- /dev/null +++ b/Content.Shared.Database/Bans.cs @@ -0,0 +1,33 @@ +namespace Content.Shared.Database; + +/// +/// Types of bans that can be stored in the database. +/// +public enum BanType : byte +{ + /// + /// A ban from the entire server. If a player matches the ban info, they will be refused connection. + /// + Server, + + /// + /// A ban from playing one or more roles. + /// + Role, +} + +/// +/// A single role for a database role ban. +/// +/// The type of role being banned, e.g. Job. +/// +/// The ID of the role being banned. This is likely a prototype ID based on . +/// +[Serializable] +public record struct BanRoleDef(string RoleType, string RoleId) +{ + public override string ToString() + { + return $"{RoleType}:{RoleId}"; + } +} diff --git a/Content.Shared/Administration/BanList/BanListEuiState.cs b/Content.Shared/Administration/BanList/BanListEuiState.cs index 09faa9706e..c885ff1f70 100644 --- a/Content.Shared/Administration/BanList/BanListEuiState.cs +++ b/Content.Shared/Administration/BanList/BanListEuiState.cs @@ -6,7 +6,7 @@ namespace Content.Shared.Administration.BanList; [Serializable, NetSerializable] public sealed class BanListEuiState : EuiStateBase { - public BanListEuiState(string banListPlayerName, List bans, List roleBans) + public BanListEuiState(string banListPlayerName, List bans, List roleBans) { BanListPlayerName = banListPlayerName; Bans = bans; @@ -14,6 +14,6 @@ public sealed class BanListEuiState : EuiStateBase } public string BanListPlayerName { get; } - public List Bans { get; } - public List RoleBans { get; } + public List Bans { get; } + public List RoleBans { get; } } diff --git a/Content.Shared/Administration/BanList/SharedBan.cs b/Content.Shared/Administration/BanList/SharedBan.cs new file mode 100644 index 0000000000..68ccc02c58 --- /dev/null +++ b/Content.Shared/Administration/BanList/SharedBan.cs @@ -0,0 +1,20 @@ +using System.Collections.Immutable; +using Content.Shared.Database; +using Robust.Shared.Network; +using Robust.Shared.Serialization; + +namespace Content.Shared.Administration.BanList; + +[Serializable, NetSerializable] +public record SharedBan( + int? Id, + BanType Type, + ImmutableArray UserIds, + ImmutableArray<(string address, int cidrMask)> Addresses, + ImmutableArray HWIds, + DateTime BanTime, + DateTime? ExpirationTime, + string Reason, + string? BanningAdminName, + SharedUnban? Unban, + ImmutableArray? Roles); diff --git a/Content.Shared/Administration/BanList/SharedServerBan.cs b/Content.Shared/Administration/BanList/SharedServerBan.cs deleted file mode 100644 index a8b9ce0d9a..0000000000 --- a/Content.Shared/Administration/BanList/SharedServerBan.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Robust.Shared.Network; -using Robust.Shared.Serialization; - -namespace Content.Shared.Administration.BanList; - -[Serializable, NetSerializable] -public record SharedServerBan( - int? Id, - NetUserId? UserId, - (string address, int cidrMask)? Address, - string? HWId, - DateTime BanTime, - DateTime? ExpirationTime, - string Reason, - string? BanningAdminName, - SharedServerUnban? Unban -); diff --git a/Content.Shared/Administration/BanList/SharedServerRoleBan.cs b/Content.Shared/Administration/BanList/SharedServerRoleBan.cs deleted file mode 100644 index fca2ea1583..0000000000 --- a/Content.Shared/Administration/BanList/SharedServerRoleBan.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Robust.Shared.Network; -using Robust.Shared.Serialization; - -namespace Content.Shared.Administration.BanList; - -[Serializable, NetSerializable] -public sealed record SharedServerRoleBan( - int? Id, - NetUserId? UserId, - (string address, int cidrMask)? Address, - string? HWId, - DateTime BanTime, - DateTime? ExpirationTime, - string Reason, - string? BanningAdminName, - SharedServerUnban? Unban, - string Role -) : SharedServerBan(Id, UserId, Address, HWId, BanTime, ExpirationTime, Reason, BanningAdminName, Unban); diff --git a/Content.Shared/Administration/BanList/SharedServerUnban.cs b/Content.Shared/Administration/BanList/SharedUnban.cs similarity index 81% rename from Content.Shared/Administration/BanList/SharedServerUnban.cs rename to Content.Shared/Administration/BanList/SharedUnban.cs index f3a57e4159..d60bb9184e 100644 --- a/Content.Shared/Administration/BanList/SharedServerUnban.cs +++ b/Content.Shared/Administration/BanList/SharedUnban.cs @@ -3,7 +3,7 @@ namespace Content.Shared.Administration.BanList; [Serializable, NetSerializable] -public sealed record SharedServerUnban( +public sealed record SharedUnban( string? UnbanningAdmin, DateTime UnbanTime ); diff --git a/Content.Shared/Administration/Notes/SharedAdminNote.cs b/Content.Shared/Administration/Notes/SharedAdminNote.cs index 09d4f3f947..d849d35078 100644 --- a/Content.Shared/Administration/Notes/SharedAdminNote.cs +++ b/Content.Shared/Administration/Notes/SharedAdminNote.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using Content.Shared.Database; using Robust.Shared.Network; using Robust.Shared.Serialization; @@ -7,8 +8,8 @@ namespace Content.Shared.Administration.Notes; [Serializable, NetSerializable] public sealed record SharedAdminNote( int Id, // Id of note, message, watchlist, ban or role ban. Should be paired with NoteType to uniquely identify a shared admin note. - NetUserId Player, // Notes player - int? Round, // Which round was it added in? + ImmutableArray Players, // Notes player + ImmutableArray Rounds, // Which round was it added in? string? ServerName, // Which server was this added on? TimeSpan PlaytimeAtNote, // Playtime at the time of getting the note NoteType NoteType, // Type of note @@ -20,7 +21,7 @@ public sealed record SharedAdminNote( DateTime CreatedAt, // When was it created? DateTime? LastEditedAt, // When was it last edited? DateTime? ExpiryTime, // Does it expire? - string[]? BannedRoles, // Only valid for role bans. List of banned roles + ImmutableArray? BannedRoles, // Only valid for role bans. List of banned roles DateTime? UnbannedTime, // Only valid for bans. Set if unbanned string? UnbannedByName, // Only valid for bans. Set if unbanned bool? Seen // Only valid for messages, otherwise should be null. Has the user seen this message? diff --git a/Content.Shared/Players/MsgRoleBans.cs b/Content.Shared/Players/MsgRoleBans.cs index bcc28d01d2..e6fc2df3fc 100644 --- a/Content.Shared/Players/MsgRoleBans.cs +++ b/Content.Shared/Players/MsgRoleBans.cs @@ -1,5 +1,7 @@ +using Content.Shared.Roles; using Lidgren.Network; using Robust.Shared.Network; +using Robust.Shared.Prototypes; using Robust.Shared.Serialization; namespace Content.Shared.Players; @@ -11,8 +13,8 @@ public sealed class MsgRoleBans : NetMessage { public override MsgGroups MsgGroup => MsgGroups.EntityEvent; - public List JobBans = new(); - public List AntagBans = new(); + public List> JobBans = new(); + public List> AntagBans = new(); public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer) { diff --git a/Resources/Locale/en-US/job/role-ban-command.ftl b/Resources/Locale/en-US/job/role-ban-command.ftl index 26062c25b7..148fb9a7b0 100644 --- a/Resources/Locale/en-US/job/role-ban-command.ftl +++ b/Resources/Locale/en-US/job/role-ban-command.ftl @@ -44,7 +44,6 @@ cmd-roleban-severity-parse = ${severity} is not a valid severity\n{$help}. cmd-roleban-arg-count = Invalid amount of arguments. cmd-roleban-job-parse = Job {$job} does not exist. cmd-roleban-name-parse = Unable to find a player with that name. -cmd-roleban-existing = {$target} already has a role ban for {$role}. cmd-roleban-success = Role banned {$target} from {$role} with reason {$reason} {$length}. cmd-roleban-inf = permanently diff --git a/Resources/engineCommandPerms.yml b/Resources/engineCommandPerms.yml index 4520a1da2d..e5fda48f25 100644 --- a/Resources/engineCommandPerms.yml +++ b/Resources/engineCommandPerms.yml @@ -131,6 +131,7 @@ - fuck - replay_recording_start - replay_recording_stop + - transfer_test - Flags: QUERY Commands: diff --git a/Tools/dump_user_data.py b/Tools/dump_user_data.py index 09f9410805..23b2a7523f 100755 --- a/Tools/dump_user_data.py +++ b/Tools/dump_user_data.py @@ -8,7 +8,7 @@ import os import psycopg2 from uuid import UUID -LATEST_DB_MIGRATION = "20250314222016_ConstructionFavorites" +LATEST_DB_MIGRATION = "20260120200503_BanRefactor" def main(): parser = argparse.ArgumentParser() @@ -42,9 +42,8 @@ def main(): dump_player(cur, user_id, arg_output) dump_preference(cur, user_id, arg_output) dump_role_whitelists(cur, user_id, arg_output) - dump_server_ban(cur, user_id, arg_output) + dump_ban(cur, user_id, arg_output) dump_server_ban_exemption(cur, user_id, arg_output) - dump_server_role_ban(cur, user_id, arg_output) dump_uploaded_resource_log(cur, user_id, arg_output) dump_whitelist(cur, user_id, arg_output) @@ -301,7 +300,7 @@ FROM ( f.write(json_data) -def dump_server_ban(cur: "psycopg2.cursor", user_id: str, outdir: str): +def dump_ban(cur: "psycopg2.cursor", user_id: str, outdir: str): print("Dumping server_ban...") cur.execute(""" @@ -311,19 +310,39 @@ FROM ( SELECT *, (SELECT to_jsonb(unban_sq) - 'ban_id' FROM ( - SELECT * FROM server_unban WHERE server_unban.ban_id = server_ban.server_ban_id + SELECT * FROM unban WHERE unban.ban_id = ban.ban_id ) unban_sq) - as unban + as unban, + (SELECT COALESCE(json_agg(to_jsonb(ban_player_subq) - 'ban_id'), '[]') FROM ( + SELECT * FROM ban_player WHERE ban_player.ban_id = ban.ban_id + ) ban_player_subq) + as ban_player, + (SELECT COALESCE(json_agg(to_jsonb(ban_address_subq) - 'ban_id'), '[]') FROM ( + SELECT * FROM ban_address WHERE ban_address.ban_id = ban.ban_id + ) ban_address_subq) + as ban_address, + (SELECT COALESCE(json_agg(to_jsonb(ban_role_subq) - 'ban_id'), '[]') FROM ( + SELECT * FROM ban_role WHERE ban_role.ban_id = ban.ban_id + ) ban_role_subq) + as ban_role, + (SELECT COALESCE(json_agg(to_jsonb(ban_hwid_subq) - 'ban_id'), '[]') FROM ( + SELECT * FROM ban_hwid WHERE ban_hwid.ban_id = ban.ban_id + ) ban_hwid_subq) + as ban_hwid, + (SELECT COALESCE(json_agg(to_jsonb(ban_round_subq) - 'ban_id'), '[]') FROM ( + SELECT * FROM ban_round WHERE ban_round.ban_id = ban.ban_id + ) ban_round_subq) + as ban_round FROM - server_ban + ban WHERE - player_user_id = %s + ban_id IN (SELECT bp.ban_id FROM ban_player bp WHERE bp.user_id = %s) ) as data """, (user_id,)) json_data = cur.fetchall()[0][0] - with open(os.path.join(outdir, "server_ban.json"), "w", encoding="utf-8") as f: + with open(os.path.join(outdir, "ban.json"), "w", encoding="utf-8") as f: f.write(json_data) diff --git a/Tools/erase_user_data.py b/Tools/erase_user_data.py index 509654f91e..dda9042cf7 100644 --- a/Tools/erase_user_data.py +++ b/Tools/erase_user_data.py @@ -12,7 +12,7 @@ import os import psycopg2 from uuid import UUID -LATEST_DB_MIGRATION = "20250314222016_ConstructionFavorites" +LATEST_DB_MIGRATION = "20260120200503_BanRefactor" def main(): parser = argparse.ArgumentParser() @@ -38,9 +38,8 @@ def main(): clear_play_time(cur, user_id) clear_player(cur, user_id) clear_preference(cur, user_id) - clear_server_ban(cur, user_id) + clear_ban(cur, user_id) clear_server_ban_exemption(cur, user_id) - clear_server_role_ban(cur, user_id) clear_uploaded_resource_log(cur, user_id) clear_whitelist(cur, user_id) clear_blacklist(cur, user_id) @@ -144,14 +143,14 @@ WHERE """, (user_id,)) -def clear_server_ban(cur: "psycopg2.cursor", user_id: str): - print("Clearing server_ban...") +def clear_ban(cur: "psycopg2.cursor", user_id: str): + print("Clearing ban...") cur.execute(""" DELETE FROM - server_ban + ban WHERE - player_user_id = %s + ban_id IN (SELECT bp.ban_id FROM ban_player bp WHERE bp.user_id = %s) """, (user_id,)) @@ -166,17 +165,6 @@ WHERE """, (user_id,)) -def clear_server_role_ban(cur: "psycopg2.cursor", user_id: str): - print("Clearing server_role_ban...") - - cur.execute(""" -DELETE FROM - server_role_ban -WHERE - player_user_id = %s -""", (user_id,)) - - def clear_uploaded_resource_log(cur: "psycopg2.cursor", user_id: str): print("Clearing uploaded_resource_log...") From 26d4d7d3378ae811fb9a766f3adc878899765e15 Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Fri, 23 Jan 2026 19:51:24 +0100 Subject: [PATCH 02/47] Fix PostgreSQL migration for ban DB refactor (#42609) This didn't come up in testing. The ban hit foreign key was added before the data was migrated, so it didn't work. Fix that. --- .../Postgres/20260120200503_BanRefactor.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Content.Server.Database/Migrations/Postgres/20260120200503_BanRefactor.cs b/Content.Server.Database/Migrations/Postgres/20260120200503_BanRefactor.cs index 64a20c2933..6e6a9dfbe5 100644 --- a/Content.Server.Database/Migrations/Postgres/20260120200503_BanRefactor.cs +++ b/Content.Server.Database/Migrations/Postgres/20260120200503_BanRefactor.cs @@ -240,14 +240,6 @@ namespace Content.Server.Database.Migrations.Postgres column: "ban_id", unique: true); - migrationBuilder.AddForeignKey( - name: "FK_server_ban_hit_ban_ban_id", - table: "server_ban_hit", - column: "ban_id", - principalTable: "ban", - principalColumn: "ban_id", - onDelete: ReferentialAction.Cascade); - migrationBuilder.Sql(""" CREATE INDEX "IX_ban_address_address" ON ban_address @@ -490,6 +482,14 @@ namespace Content.Server.Database.Migrations.Postgres AND round_id IS NOT NULL; """); + migrationBuilder.AddForeignKey( + name: "FK_server_ban_hit_ban_ban_id", + table: "server_ban_hit", + column: "ban_id", + principalTable: "ban", + principalColumn: "ban_id", + onDelete: ReferentialAction.Cascade); + migrationBuilder.DropForeignKey( name: "FK_server_ban_hit_server_ban_ban_id", table: "server_ban_hit"); From 76801bd8b2226432ad0a0cdb452f1be782d8cc0c Mon Sep 17 00:00:00 2001 From: Princess Cheeseballs <66055347+Princess-Cheeseballs@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:11:19 -0800 Subject: [PATCH 03/47] Tweak Traitor Uplink - The Rest of the Uplink (#42582) * jaws of death * I hate YAML * open the gate * I forgot about this one * I forgor * Fix croissant * this didn't work actually rip * made lights look better and undo on the boxing gloves * small change * Update Resources/Prototypes/Entities/Clothing/Hands/gloves.yml Co-authored-by: IProduceWidgets <107586145+IProduceWidgets@users.noreply.github.com> * baguette contraband, eat your evidence. * suffix --------- Co-authored-by: Princess Cheeseballs <66055347+Pronana@users.noreply.github.com> Co-authored-by: IProduceWidgets <107586145+IProduceWidgets@users.noreply.github.com> --- .../EntitySystems/LightBehaviorSystem.cs | 47 +-- .../Systems/LightBehaviorOnTriggerSystem.cs | 21 ++ .../Radio/EntitySystems/JammerSystem.cs | 97 +----- .../PowerCell/PowerCellSystem.Draw.cs | 10 +- .../PowerCell/ToggleCellDrawSystem.cs | 4 +- .../Radio/EntitySystems/SharedJammerSystem.cs | 81 +++-- .../LightBehaviorOnTriggerComponent.cs | 16 + .../Effects/ScramOnTriggerComponent.cs | 5 +- .../Trigger/Systems/ScramOnTriggerSystem.cs | 5 +- .../Locale/en-US/store/uplink-catalog.ftl | 11 +- .../Catalog/Fills/Boxes/syndicate.yml | 2 +- .../Prototypes/Catalog/uplink_catalog.yml | 289 +++++++----------- .../Entities/Clothing/Hands/gloves.yml | 82 +++-- .../Entities/Mobs/Player/guardian.yml | 12 - .../Objects/Consumable/Food/Baked/misc.yml | 4 - .../Consumable/Food/Containers/box.yml | 10 +- .../Devices/Circuitboards/law_boards.yml | 12 + .../Entities/Objects/Devices/flatpack.yml | 14 + .../Entities/Objects/Misc/implanters.yml | 2 +- .../Objects/Misc/subdermal_implants.yml | 4 +- .../Entities/Objects/Power/powersink.yml | 2 +- .../Entities/Objects/Tools/jammer.yml | 79 +++-- .../Entities/Objects/Tools/jaws_of_life.yml | 4 +- .../Weapons/Guns/Battery/battery_guns.yml | 3 +- .../Objects/Weapons/Guns/Pistols/pistols.yml | 8 +- .../Objects/Weapons/Melee/baguette.yml | 10 +- .../Objects/Weapons/Throwable/croissant.yml | 11 +- .../Objects/Weapons/Throwable/grenades.yml | 25 +- .../Weapons/Throwable/scattering_grenades.yml | 36 +-- 29 files changed, 443 insertions(+), 463 deletions(-) create mode 100644 Content.Client/Trigger/Systems/LightBehaviorOnTriggerSystem.cs create mode 100644 Content.Shared/Trigger/Components/Effects/LightBehaviorOnTriggerComponent.cs diff --git a/Content.Client/Light/EntitySystems/LightBehaviorSystem.cs b/Content.Client/Light/EntitySystems/LightBehaviorSystem.cs index d4eaad3882..b91062b60b 100644 --- a/Content.Client/Light/EntitySystems/LightBehaviorSystem.cs +++ b/Content.Client/Light/EntitySystems/LightBehaviorSystem.cs @@ -1,5 +1,7 @@ +using System.ComponentModel.Design; using System.Linq; using Content.Client.Light.Components; +using Content.Shared.Trigger.Components.Effects; using Robust.Client.GameObjects; using Robust.Client.Animations; using Robust.Shared.Random; @@ -36,6 +38,10 @@ public sealed class LightBehaviorSystem : EntitySystem container.LightBehaviour.UpdatePlaybackValues(container.Animation); _player.Play(uid, container.Animation, container.FullKey); } + else + { + StopLightBehaviour((uid, component), container.LightBehaviour.ID, resetToOriginalSettings: true); + } } private void OnLightStartup(Entity entity, ref ComponentStartup args) @@ -53,7 +59,7 @@ public sealed class LightBehaviorSystem : EntitySystem { if (container.LightBehaviour.Enabled) { - StartLightBehaviour(entity, container.LightBehaviour.ID); + StartLightBehaviour((entity, entity), container.LightBehaviour.ID); } } } @@ -82,12 +88,13 @@ public sealed class LightBehaviorSystem : EntitySystem /// If specified light behaviours are already animating, calling this does nothing. /// Multiple light behaviours can have the same ID. /// - public void StartLightBehaviour(Entity entity, string id = "") + public void StartLightBehaviour(Entity entity, string id = "") { - if (!TryComp(entity, out AnimationPlayerComponent? animation)) - { + if (!Resolve(entity, ref entity.Comp)) + return; + + if (!TryComp(entity, out AnimationPlayerComponent? animation)) return; - } foreach (var container in entity.Comp.Animations) { @@ -95,7 +102,7 @@ public sealed class LightBehaviorSystem : EntitySystem { if (!_player.HasRunningAnimation(entity, animation, LightBehaviourComponent.KeyPrefix + container.Key)) { - CopyLightSettings(entity, container.LightBehaviour.Property); + CopyLightSettings((entity, entity.Comp), container.LightBehaviour.Property); container.LightBehaviour.UpdatePlaybackValues(container.Animation); _player.Play(entity, container.Animation, LightBehaviourComponent.KeyPrefix + container.Key); } @@ -118,11 +125,9 @@ public sealed class LightBehaviorSystem : EntitySystem return; } - var comp = entity.Comp; - var toRemove = new List(); - foreach (var container in comp.Animations) + foreach (var container in entity.Comp.Animations) { if (container.LightBehaviour.ID == id || id == string.Empty) { @@ -140,18 +145,24 @@ public sealed class LightBehaviorSystem : EntitySystem foreach (var container in toRemove) { - comp.Animations.Remove(container); + entity.Comp.Animations.Remove(container); } - if (resetToOriginalSettings && TryComp(entity, out PointLightComponent? light)) + if (resetToOriginalSettings) + ResetToOriginalSettings(entity); + + entity.Comp.OriginalPropertyValues.Clear(); + } + + private void ResetToOriginalSettings(Entity entity) + { + if (!Resolve(entity, ref entity.Comp2)) + return; + + foreach (var (property, value) in entity.Comp1.OriginalPropertyValues) { - foreach (var (property, value) in comp.OriginalPropertyValues) - { - AnimationHelper.SetAnimatableProperty(light, property, value); - } + AnimationHelper.SetAnimatableProperty(entity.Comp2, property, value); } - - comp.OriginalPropertyValues.Clear(); } /// @@ -194,7 +205,7 @@ public sealed class LightBehaviorSystem : EntitySystem if (playImmediately) { - StartLightBehaviour(entity, behaviour.ID); + StartLightBehaviour((entity, entity), behaviour.ID); } } } diff --git a/Content.Client/Trigger/Systems/LightBehaviorOnTriggerSystem.cs b/Content.Client/Trigger/Systems/LightBehaviorOnTriggerSystem.cs new file mode 100644 index 0000000000..01e530067e --- /dev/null +++ b/Content.Client/Trigger/Systems/LightBehaviorOnTriggerSystem.cs @@ -0,0 +1,21 @@ +using Content.Client.Light.EntitySystems; +using Content.Shared.Trigger; +using Content.Shared.Trigger.Components.Effects; +using Robust.Shared.Timing; + +namespace Content.Client.Trigger.Systems; + +/// +/// This handles... +/// +public sealed class LightBehaviorOnTriggerSystem : XOnTriggerSystem +{ + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly LightBehaviorSystem _light = default!; + + protected override void OnTrigger(Entity ent, EntityUid target, ref TriggerEvent args) + { + if (_timing.IsFirstTimePredicted) + _light.StartLightBehaviour(target, ent.Comp.Behavior); + } +} diff --git a/Content.Server/Radio/EntitySystems/JammerSystem.cs b/Content.Server/Radio/EntitySystems/JammerSystem.cs index dead85f51f..e39fc23831 100644 --- a/Content.Server/Radio/EntitySystems/JammerSystem.cs +++ b/Content.Server/Radio/EntitySystems/JammerSystem.cs @@ -19,100 +19,23 @@ public sealed class JammerSystem : SharedJammerSystem { base.Initialize(); - SubscribeLocalEvent(OnActivate); - SubscribeLocalEvent(OnPowerCellChanged); SubscribeLocalEvent(OnRadioSendAttempt); - } - - // TODO: Very important: Make this charge rate based instead of updating every single tick - // See BatteryComponent - public override void Update(float frameTime) - { - var query = EntityQueryEnumerator(); - - while (query.MoveNext(out var uid, out var _, out var jam)) - { - - if (_powerCell.TryGetBatteryFromSlot(uid, out var battery)) - { - if (!_battery.TryUseCharge(battery.Value.AsNullable(), GetCurrentWattage((uid, jam)) * frameTime)) - { - ChangeLEDState(uid, false); - RemComp(uid); - RemComp(uid); - } - else - { - var chargeFraction = _battery.GetChargeLevel(battery.Value.AsNullable()); - var chargeLevel = chargeFraction switch - { - > 0.50f => RadioJammerChargeLevel.High, - < 0.15f => RadioJammerChargeLevel.Low, - _ => RadioJammerChargeLevel.Medium, - }; - ChangeChargeLevel(uid, chargeLevel); - } - - } - - } - } - - private void OnActivate(Entity ent, ref ActivateInWorldEvent args) - { - if (args.Handled || !args.Complex) - return; - - var activated = !HasComp(ent) && - _powerCell.TryGetBatteryFromSlot(ent.Owner, out var battery) && - _battery.GetCharge(battery.Value.AsNullable()) > GetCurrentWattage(ent); - if (activated) - { - ChangeLEDState(ent.Owner, true); - EnsureComp(ent); - EnsureComp(ent, out var jammingComp); - _jammer.SetRange((ent, jammingComp), GetCurrentRange(ent)); - _jammer.AddJammableNetwork((ent, jammingComp), DeviceNetworkComponent.DeviceNetIdDefaults.Wireless.ToString()); - - // Add excluded frequencies using the system method - if (ent.Comp.FrequenciesExcluded != null) - { - foreach (var freq in ent.Comp.FrequenciesExcluded) - { - _jammer.AddExcludedFrequency((ent, jammingComp), (uint)freq); - } - } - } - else - { - ChangeLEDState(ent.Owner, false); - RemCompDeferred(ent); - RemCompDeferred(ent); - } - var state = Loc.GetString(activated ? "radio-jammer-component-on-state" : "radio-jammer-component-off-state"); - var message = Loc.GetString("radio-jammer-component-on-use", ("state", state)); - Popup.PopupEntity(message, args.User, args.User); - args.Handled = true; - } - - private void OnPowerCellChanged(Entity ent, ref PowerCellChangedEvent args) - { - if (args.Ejected) - { - ChangeLEDState(ent.Owner, false); - RemCompDeferred(ent); - } + SubscribeLocalEvent(OnRadioReceiveAttempt); } private void OnRadioSendAttempt(ref RadioSendAttemptEvent args) { - if (ShouldCancelSend(args.RadioSource, args.Channel.Frequency)) - { + if (ShouldCancel(args.RadioSource, args.Channel.Frequency)) args.Cancelled = true; - } } - private bool ShouldCancelSend(EntityUid sourceUid, int frequency) + private void OnRadioReceiveAttempt(ref RadioReceiveAttemptEvent args) + { + if (ShouldCancel(args.RadioReceiver, args.Channel.Frequency)) + args.Cancelled = true; + } + + private bool ShouldCancel(EntityUid sourceUid, int frequency) { var source = Transform(sourceUid).Coordinates; var query = EntityQueryEnumerator(); @@ -120,7 +43,7 @@ public sealed class JammerSystem : SharedJammerSystem while (query.MoveNext(out var uid, out _, out var jam, out var transform)) { // Check if this jammer excludes the frequency - if (jam.FrequenciesExcluded != null && jam.FrequenciesExcluded.Contains(frequency)) + if (jam.FrequenciesExcluded.Contains(frequency)) continue; if (_transform.InRange(source, transform.Coordinates, GetCurrentRange((uid, jam)))) diff --git a/Content.Shared/PowerCell/PowerCellSystem.Draw.cs b/Content.Shared/PowerCell/PowerCellSystem.Draw.cs index 8790ec941c..73e0d5dcd0 100644 --- a/Content.Shared/PowerCell/PowerCellSystem.Draw.cs +++ b/Content.Shared/PowerCell/PowerCellSystem.Draw.cs @@ -11,11 +11,11 @@ public sealed partial class PowerCellSystem [PublicAPI] public void SetDrawEnabled(Entity ent, bool enabled) { - if (!Resolve(ent, ref ent.Comp, false) || ent.Comp.Enabled == enabled) - return; - - ent.Comp.Enabled = enabled; - Dirty(ent, ent.Comp); + if (Resolve(ent, ref ent.Comp, false) && ent.Comp.Enabled != enabled) + { + ent.Comp.Enabled = enabled; + Dirty(ent, ent.Comp); + } if (TryGetBatteryFromSlot(ent.Owner, out var battery)) _battery.RefreshChargeRate(battery.Value.AsNullable()); diff --git a/Content.Shared/PowerCell/ToggleCellDrawSystem.cs b/Content.Shared/PowerCell/ToggleCellDrawSystem.cs index 9c50a8aa60..c4d78ff52e 100644 --- a/Content.Shared/PowerCell/ToggleCellDrawSystem.cs +++ b/Content.Shared/PowerCell/ToggleCellDrawSystem.cs @@ -36,9 +36,7 @@ public sealed class ToggleCellDrawSystem : EntitySystem private void OnToggled(Entity ent, ref ItemToggledEvent args) { - var uid = ent.Owner; - var draw = Comp(uid); - _cell.SetDrawEnabled((uid, draw), args.Activated); + _cell.SetDrawEnabled(ent.Owner, args.Activated); } private void OnEmpty(Entity ent, ref PowerCellSlotEmptyEvent args) diff --git a/Content.Shared/Radio/EntitySystems/SharedJammerSystem.cs b/Content.Shared/Radio/EntitySystems/SharedJammerSystem.cs index 67af4cc900..5fd1009466 100644 --- a/Content.Shared/Radio/EntitySystems/SharedJammerSystem.cs +++ b/Content.Shared/Radio/EntitySystems/SharedJammerSystem.cs @@ -1,25 +1,67 @@ +using Content.Shared.DeviceNetwork.Components; using Content.Shared.Popups; using Content.Shared.Verbs; using Content.Shared.Examine; using Content.Shared.Radio.Components; using Content.Shared.DeviceNetwork.Systems; +using Content.Shared.Item.ItemToggle; +using Content.Shared.Item.ItemToggle.Components; +using Content.Shared.Power; namespace Content.Shared.Radio.EntitySystems; public abstract class SharedJammerSystem : EntitySystem { + [Dependency] private readonly ItemToggleSystem _itemToggle = default!; [Dependency] private readonly SharedAppearanceSystem _appearance = default!; [Dependency] private readonly SharedDeviceNetworkJammerSystem _jammer = default!; - [Dependency] protected readonly SharedPopupSystem Popup = default!; + [Dependency] private readonly SharedPopupSystem _popup = default!; public override void Initialize() { base.Initialize(); + SubscribeLocalEvent(OnItemToggle); + SubscribeLocalEvent(OnRefreshChargeRate); SubscribeLocalEvent>(OnGetVerb); SubscribeLocalEvent(OnExamine); } + private void OnItemToggle(Entity entity, ref ItemToggledEvent args) + { + if (args.Activated) + { + EnsureComp(entity); + EnsureComp(entity, out var jammingComp); + _jammer.SetRange((entity, jammingComp), GetCurrentRange(entity)); + _jammer.AddJammableNetwork((entity, jammingComp), DeviceNetworkComponent.DeviceNetIdDefaults.Wireless.ToString()); + + // Add excluded frequencies using the system method + foreach (var freq in entity.Comp.FrequenciesExcluded) + { + _jammer.AddExcludedFrequency((entity, jammingComp), (uint)freq); + } + } + else + { + RemCompDeferred(entity); + RemCompDeferred(entity); + } + + if (args.User == null) + return; + + var state = Loc.GetString(args.Activated ? "radio-jammer-component-on-state" : "radio-jammer-component-off-state"); + var message = Loc.GetString("radio-jammer-component-on-use", ("state", state)); + _popup.PopupPredicted(message, args.User.Value, args.User.Value); + } + + private void OnRefreshChargeRate(Entity entity, ref RefreshChargeRateEvent args) + { + if (_itemToggle.IsActivated(entity.Owner)) + args.NewChargeRate -= GetCurrentWattage(entity); + } + private void OnGetVerb(Entity entity, ref GetVerbsEvent args) { if (!args.CanAccess || !args.CanInteract) @@ -47,7 +89,7 @@ public abstract class SharedJammerSystem : EntitySystem // The range should be updated when it turns on again! _jammer.TrySetRange(entity.Owner, GetCurrentRange(entity)); - Popup.PopupClient(Loc.GetString(setting.Message), user, user); + _popup.PopupClient(Loc.GetString(setting.Message), user, user); }, Text = Loc.GetString(setting.Name), }; @@ -58,37 +100,26 @@ public abstract class SharedJammerSystem : EntitySystem private void OnExamine(Entity ent, ref ExaminedEvent args) { - if (args.IsInDetailsRange) - { - var powerIndicator = HasComp(ent) - ? Loc.GetString("radio-jammer-component-examine-on-state") - : Loc.GetString("radio-jammer-component-examine-off-state"); - args.PushMarkup(powerIndicator); + if (!args.IsInDetailsRange) + return; - var powerLevel = Loc.GetString(ent.Comp.Settings[ent.Comp.SelectedPowerLevel].Name); - var switchIndicator = Loc.GetString("radio-jammer-component-switch-setting", ("powerLevel", powerLevel)); - args.PushMarkup(switchIndicator); - } + var powerIndicator = _itemToggle.IsActivated(ent.Owner) + ? Loc.GetString("radio-jammer-component-examine-on-state") + : Loc.GetString("radio-jammer-component-examine-off-state"); + args.PushMarkup(powerIndicator); + + var powerLevel = Loc.GetString(ent.Comp.Settings[ent.Comp.SelectedPowerLevel].Name); + var switchIndicator = Loc.GetString("radio-jammer-component-switch-setting", ("powerLevel", powerLevel)); + args.PushMarkup(switchIndicator); } - public float GetCurrentWattage(Entity jammer) + private float GetCurrentWattage(Entity jammer) { return jammer.Comp.Settings[jammer.Comp.SelectedPowerLevel].Wattage; } - public float GetCurrentRange(Entity jammer) + protected float GetCurrentRange(Entity jammer) { return jammer.Comp.Settings[jammer.Comp.SelectedPowerLevel].Range; } - - protected void ChangeLEDState(Entity ent, bool isLEDOn) - { - _appearance.SetData(ent, RadioJammerVisuals.LEDOn, isLEDOn, ent.Comp); - } - - protected void ChangeChargeLevel(Entity ent, RadioJammerChargeLevel chargeLevel) - { - _appearance.SetData(ent, RadioJammerVisuals.ChargeLevel, chargeLevel, ent.Comp); - } - } diff --git a/Content.Shared/Trigger/Components/Effects/LightBehaviorOnTriggerComponent.cs b/Content.Shared/Trigger/Components/Effects/LightBehaviorOnTriggerComponent.cs new file mode 100644 index 0000000000..b31bff7841 --- /dev/null +++ b/Content.Shared/Trigger/Components/Effects/LightBehaviorOnTriggerComponent.cs @@ -0,0 +1,16 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared.Trigger.Components.Effects; + +/// +/// Plays a light behavior on the target when this trigger is activated, of note is that the entity needs a PointLightComponent +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class LightBehaviorOnTriggerComponent : BaseXOnTriggerComponent +{ + /// + /// The light behavior we're triggering. + /// + [DataField(required: true)] + public string Behavior = string.Empty; +} diff --git a/Content.Shared/Trigger/Components/Effects/ScramOnTriggerComponent.cs b/Content.Shared/Trigger/Components/Effects/ScramOnTriggerComponent.cs index bacf0f69e8..ecdb2c7da5 100644 --- a/Content.Shared/Trigger/Components/Effects/ScramOnTriggerComponent.cs +++ b/Content.Shared/Trigger/Components/Effects/ScramOnTriggerComponent.cs @@ -1,3 +1,4 @@ +using System.Numerics; using Robust.Shared.Audio; using Robust.Shared.GameStates; @@ -12,10 +13,10 @@ namespace Content.Shared.Trigger.Components.Effects; public sealed partial class ScramOnTriggerComponent : BaseXOnTriggerComponent { /// - /// Up to how far to teleport the entity. + /// Up to how far to teleport the entity. Represented with X as Min Radius, and Y as Max Radius /// [DataField, AutoNetworkedField] - public float TeleportRadius = 100f; + public Vector2 TeleportRadius = new (10f, 15f); /// /// the sound to play when teleporting. diff --git a/Content.Shared/Trigger/Systems/ScramOnTriggerSystem.cs b/Content.Shared/Trigger/Systems/ScramOnTriggerSystem.cs index cd79eed13b..32666f1be8 100644 --- a/Content.Shared/Trigger/Systems/ScramOnTriggerSystem.cs +++ b/Content.Shared/Trigger/Systems/ScramOnTriggerSystem.cs @@ -1,3 +1,4 @@ +using System.Numerics; using Content.Shared.Maps; using Content.Shared.Movement.Pulling.Components; using Content.Shared.Movement.Pulling.Systems; @@ -50,7 +51,7 @@ public sealed class ScramOnTriggerSystem : XOnTriggerSystem /// Trends towards the outer radius. Compensates for small grids. - private EntityCoordinates? SelectRandomTileInRange(EntityUid uid, float radius, int tries = 40, PhysicsComponent? physicsComponent = null) + private EntityCoordinates? SelectRandomTileInRange(EntityUid uid, Vector2 radius, int tries = 40, PhysicsComponent? physicsComponent = null) { var userCoords = Transform(uid).Coordinates; EntityCoordinates? targetCoords = null; @@ -68,7 +69,7 @@ public sealed class ScramOnTriggerSystem : XOnTriggerSystem Date: Sat, 24 Jan 2026 00:27:56 +0000 Subject: [PATCH 04/47] Automatic changelog update --- Resources/Changelog/Changelog.yml | 42 +++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/Resources/Changelog/Changelog.yml b/Resources/Changelog/Changelog.yml index 3dffc32b9b..6f502f693e 100644 --- a/Resources/Changelog/Changelog.yml +++ b/Resources/Changelog/Changelog.yml @@ -1,11 +1,4 @@ Entries: -- author: imatsoup - changes: - - message: Butterflies can no longer be infected by Romerol or become Romerol zombies. - type: Tweak - id: 8948 - time: '2025-09-10T21:48:53.0000000+00:00' - url: https://github.com/space-wizards/space-station-14/pull/40265 - author: Princess-Cheeseballs changes: - message: Cardboard boxes can no longer freely move in space. @@ -3977,3 +3970,38 @@ id: 9454 time: '2026-01-23T02:05:59.0000000+00:00' url: https://github.com/space-wizards/space-station-14/pull/42172 +- author: Princess-Cheeseballs + changes: + - message: Added the Syndimov Lawboard to the Traitor Uplink. + type: Add + - message: You can now get Pythons and Cat Ears in the Syndicate Snack box. + type: Add + - message: Removed Whitehole Grenade, Necronomicon, Explosive Banana Peel and Super + Surplus crates from the uplink. + type: Remove + - message: Removed Singularity Beacon and Antimov Lawboard from the Traitor Uplink. + type: Remove + - message: Several less used uplink items have had their costs reduced. + type: Tweak + - message: Clustersoap and Cluster Bananas now explodes into soaplets when thrown + and no longer needs to be primed. The Clustersoap has been reduced to 1 TC as + well. + type: Tweak + - message: Martyr Cyborgs now start glowing red before exploding. + type: Tweak + - message: Scram Implanter's range has been crunched down to 10-15 tiles from 0-100. + type: Tweak + - message: Rigged Boxing Gloves have had their overall time to stun increased but + are also now available for all Traitors to purchase. + type: Tweak + - message: Combat bakery kit now comes with a flatpack microwave instead of a machine + board. The throwing croissants and baguette have been made equivalent in damage + to other Traitor uplink items as well. + type: Tweak + - message: Holoclowns no longer try to attack their host. + type: Fix + - message: Singularity grenade shouldn't throw you inside walls anymore + type: Fix + id: 9455 + time: '2026-01-24T00:26:43.0000000+00:00' + url: https://github.com/space-wizards/space-station-14/pull/42582 From 007aca75c3f66d5eed7457ab6d9d9c02e02035bb Mon Sep 17 00:00:00 2001 From: alexalexmax <149889301+alexalexmax@users.noreply.github.com> Date: Fri, 23 Jan 2026 23:03:00 -0500 Subject: [PATCH 05/47] Fix holoparasite stun (#42315) add immunity tags --- Resources/Prototypes/Entities/Mobs/Player/guardian.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Resources/Prototypes/Entities/Mobs/Player/guardian.yml b/Resources/Prototypes/Entities/Mobs/Player/guardian.yml index 220b4d793e..44ddd11954 100644 --- a/Resources/Prototypes/Entities/Mobs/Player/guardian.yml +++ b/Resources/Prototypes/Entities/Mobs/Player/guardian.yml @@ -111,6 +111,8 @@ - type: Tag tags: - CannotSuicide + - StunImmune + - SlowImmune # From the uplink injector - type: entity From b710525d2f9aee3efb67b448c1321d6880ae8094 Mon Sep 17 00:00:00 2001 From: PJBot Date: Sat, 24 Jan 2026 04:19:45 +0000 Subject: [PATCH 06/47] Automatic changelog update --- Resources/Changelog/Changelog.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Resources/Changelog/Changelog.yml b/Resources/Changelog/Changelog.yml index 6f502f693e..dd871813f9 100644 --- a/Resources/Changelog/Changelog.yml +++ b/Resources/Changelog/Changelog.yml @@ -1,11 +1,4 @@ Entries: -- author: Princess-Cheeseballs - changes: - - message: Cardboard boxes can no longer freely move in space. - type: Fix - id: 8949 - time: '2025-09-10T22:13:59.0000000+00:00' - url: https://github.com/space-wizards/space-station-14/pull/40260 - author: TsjipTsjip LuckyShotPictures IProduceWidgets changes: - message: Admin shuttles have been added to the repo, and can be found in /Maps/Shuttles/AdminSpawn/.. @@ -4005,3 +3998,10 @@ id: 9455 time: '2026-01-24T00:26:43.0000000+00:00' url: https://github.com/space-wizards/space-station-14/pull/42582 +- author: alexalexmax + changes: + - message: Tasers no longer slow holoparasites down. + type: Fix + id: 9456 + time: '2026-01-24T04:18:37.0000000+00:00' + url: https://github.com/space-wizards/space-station-14/pull/42315 From 7c03f61e31c0bc3bfb45c1a0d07093898332f96e Mon Sep 17 00:00:00 2001 From: Samuka <47865393+Samuka-C@users.noreply.github.com> Date: Sat, 24 Jan 2026 16:00:37 -0300 Subject: [PATCH 07/47] Admin log now shows who called or recalled evac (#41557) * admin log now shows who called or recalled evac * ops * named parameters * show in player search * ops, forgot 2 * when did this happen? --- .../Commands/ShuttleCommands.cs | 4 +- .../CommunicationsConsoleSystem.cs | 8 ++-- .../GameTicking/Rules/XenoborgsRuleSystem.cs | 2 +- .../GameTicking/Rules/ZombieRuleSystem.cs | 2 +- Content.Server/RoundEnd/RoundEndSystem.cs | 38 +++++++++---------- 5 files changed, 26 insertions(+), 28 deletions(-) diff --git a/Content.Server/Administration/Commands/ShuttleCommands.cs b/Content.Server/Administration/Commands/ShuttleCommands.cs index 6dffe1f52c..f8f8c201a5 100644 --- a/Content.Server/Administration/Commands/ShuttleCommands.cs +++ b/Content.Server/Administration/Commands/ShuttleCommands.cs @@ -16,13 +16,13 @@ namespace Content.Server.Administration.Commands { // ReSharper disable once ConvertIfStatementToSwitchStatement if (args.Length == 1 && TimeSpan.TryParseExact(args[0], ContentLocalizationManager.TimeSpanMinutesFormats, LocalizationManager.DefaultCulture, out var timeSpan)) - _roundEndSystem.RequestRoundEnd(timeSpan, shell.Player?.AttachedEntity, false); + _roundEndSystem.RequestRoundEnd(timeSpan, shell.Player?.AttachedEntity, checkCooldown: false); else if (args.Length == 1) shell.WriteLine(Loc.GetString("shell-timespan-minutes-must-be-correct")); else - _roundEndSystem.RequestRoundEnd(shell.Player?.AttachedEntity, false); + _roundEndSystem.RequestRoundEnd(shell.Player?.AttachedEntity, checkCooldown: false); } } diff --git a/Content.Server/Communications/CommunicationsConsoleSystem.cs b/Content.Server/Communications/CommunicationsConsoleSystem.cs index 7d381f79ef..ef7ccc8bf0 100644 --- a/Content.Server/Communications/CommunicationsConsoleSystem.cs +++ b/Content.Server/Communications/CommunicationsConsoleSystem.cs @@ -312,7 +312,7 @@ namespace Content.Server.Communications return; } - _roundEndSystem.RequestRoundEnd(uid); + _roundEndSystem.RequestRoundEnd(mob, uid); _adminLogger.Add(LogType.Action, LogImpact.High, $"{ToPrettyString(mob):player} has called the shuttle."); } @@ -321,13 +321,15 @@ namespace Content.Server.Communications if (!CanCallOrRecall(comp)) return; - if (!CanUse(message.Actor, uid)) + var mob = message.Actor; + + if (!CanUse(mob, uid)) { _popupSystem.PopupEntity(Loc.GetString("comms-console-permission-denied"), uid, message.Actor); return; } - _roundEndSystem.CancelRoundEndCountdown(uid); + _roundEndSystem.CancelRoundEndCountdown(mob, uid); _adminLogger.Add(LogType.Action, LogImpact.High, $"{ToPrettyString(message.Actor):player} has recalled the shuttle."); } } diff --git a/Content.Server/GameTicking/Rules/XenoborgsRuleSystem.cs b/Content.Server/GameTicking/Rules/XenoborgsRuleSystem.cs index cda1bce258..0b6a700d11 100644 --- a/Content.Server/GameTicking/Rules/XenoborgsRuleSystem.cs +++ b/Content.Server/GameTicking/Rules/XenoborgsRuleSystem.cs @@ -109,7 +109,7 @@ public sealed class XenoborgsRuleSystem : GameRuleSystem { _chatSystem.DispatchStationAnnouncement(station, Loc.GetString("xenoborg-shuttle-call"), colorOverride: Color.BlueViolet); } - _roundEnd.RequestRoundEnd(null, false, cantRecall: true); + _roundEnd.RequestRoundEnd(null, null, false, cantRecall: true); xenoborgsRuleComponent.XenoborgShuttleCalled = true; } diff --git a/Content.Server/GameTicking/Rules/ZombieRuleSystem.cs b/Content.Server/GameTicking/Rules/ZombieRuleSystem.cs index 334f751a2a..a15d1d9981 100644 --- a/Content.Server/GameTicking/Rules/ZombieRuleSystem.cs +++ b/Content.Server/GameTicking/Rules/ZombieRuleSystem.cs @@ -124,7 +124,7 @@ public sealed class ZombieRuleSystem : GameRuleSystem { _chat.DispatchStationAnnouncement(station, Loc.GetString("zombie-shuttle-call"), colorOverride: Color.Crimson); } - _roundEnd.RequestRoundEnd(null, false); + _roundEnd.RequestRoundEnd(checkCooldown: false); } // we include dead for this count because we don't want to end the round diff --git a/Content.Server/RoundEnd/RoundEndSystem.cs b/Content.Server/RoundEnd/RoundEndSystem.cs index 9419d4b01c..ed1ea90a16 100644 --- a/Content.Server/RoundEnd/RoundEndSystem.cs +++ b/Content.Server/RoundEnd/RoundEndSystem.cs @@ -134,12 +134,13 @@ namespace Content.Server.RoundEnd /// /// Starts the process of ending the round by calling evac /// - /// + /// who called evac + /// machine used to call evac /// /// text in the announcement of shuttle calling /// name in the announcement of shuttle calling /// if the station shouldn't be able to recall the shuttle - public void RequestRoundEnd(EntityUid? requester = null, bool checkCooldown = true, string text = "round-end-system-shuttle-called-announcement", string name = "round-end-system-shuttle-sender-announcement", bool cantRecall = false) + public void RequestRoundEnd(EntityUid? requester = null, EntityUid? machine = null, bool checkCooldown = true, string text = "round-end-system-shuttle-called-announcement", string name = "round-end-system-shuttle-sender-announcement", bool cantRecall = false) { var duration = DefaultCountdownDuration; @@ -154,19 +155,20 @@ namespace Content.Server.RoundEnd } } - RequestRoundEnd(duration, requester, checkCooldown, text, name, cantRecall); + RequestRoundEnd(duration, requester, machine, checkCooldown, text, name, cantRecall); } /// /// Starts the process of ending the round by calling evac /// /// time for evac to arrive - /// + /// who called evac + /// machine used to call evac /// /// text in the announcement of shuttle calling /// name in the announcement of shuttle calling /// if the station shouldn't be able to recall the shuttle - public void RequestRoundEnd(TimeSpan countdownTime, EntityUid? requester = null, bool checkCooldown = true, string text = "round-end-system-shuttle-called-announcement", string name = "round-end-system-shuttle-sender-announcement", bool cantRecall = false) + public void RequestRoundEnd(TimeSpan countdownTime, EntityUid? requester = null, EntityUid? machine = null, bool checkCooldown = true, string text = "round-end-system-shuttle-called-announcement", string name = "round-end-system-shuttle-sender-announcement", bool cantRecall = false) { if (_gameTicker.RunLevel != GameRunLevel.InRound) return; @@ -180,14 +182,11 @@ namespace Content.Server.RoundEnd _countdownTokenSource = new(); CantRecall = cantRecall; + var what = machine != null ? $" with {ToPrettyString(machine.Value):entity} " : ""; if (requester != null) - { - _adminLogger.Add(LogType.ShuttleCalled, LogImpact.High, $"Shuttle called by {ToPrettyString(requester.Value):user}"); - } + _adminLogger.Add(LogType.ShuttleCalled, LogImpact.High, $"Shuttle called by {ToPrettyString(requester.Value):player}{what}"); else - { - _adminLogger.Add(LogType.ShuttleCalled, LogImpact.High, $"Shuttle called"); - } + _adminLogger.Add(LogType.ShuttleCalled, LogImpact.High, $"Shuttle called{what}"); // I originally had these set up here but somehow time gets passed as 0 to Loc so IDEK. int time; @@ -239,7 +238,7 @@ namespace Content.Server.RoundEnd } } - public void CancelRoundEndCountdown(EntityUid? requester = null, bool forceRecall = false) + public void CancelRoundEndCountdown(EntityUid? requester = null, EntityUid? machine = null, bool forceRecall = false) { if (_gameTicker.RunLevel != GameRunLevel.InRound) return; @@ -253,14 +252,11 @@ namespace Content.Server.RoundEnd _countdownTokenSource.Cancel(); _countdownTokenSource = null; + var what = machine != null ? $" with {ToPrettyString(machine.Value):entity} " : ""; if (requester != null) - { - _adminLogger.Add(LogType.ShuttleRecalled, LogImpact.High, $"Shuttle recalled by {ToPrettyString(requester.Value):user}"); - } + _adminLogger.Add(LogType.ShuttleRecalled, LogImpact.High, $"Shuttle recalled by {ToPrettyString(requester.Value):player}{what}"); else - { - _adminLogger.Add(LogType.ShuttleRecalled, LogImpact.High, $"Shuttle recalled"); - } + _adminLogger.Add(LogType.ShuttleRecalled, LogImpact.High, $"Shuttle recalled{what}"); _chatSystem.DispatchGlobalAnnouncement(Loc.GetString("round-end-system-shuttle-recalled-announcement"), Loc.GetString("round-end-system-shuttle-sender-announcement"), false, colorOverride: Color.Gold); @@ -350,8 +346,8 @@ namespace Content.Server.RoundEnd } else { - RequestRoundEnd(time, null, false, textCall, - Loc.GetString(sender)); + RequestRoundEnd(time, checkCooldown: false, text: textCall, + name: Loc.GetString(sender)); } break; } @@ -387,7 +383,7 @@ namespace Content.Server.RoundEnd { if (!_shuttle.EmergencyShuttleArrived && ExpectedCountdownEnd is null) { - RequestRoundEnd(null, false, "round-end-system-shuttle-auto-called-announcement"); + RequestRoundEnd(checkCooldown: false, text: "round-end-system-shuttle-auto-called-announcement"); _autoCalledBefore = true; } From 351fbed6b8639ad1949b1829dc7bd44cd0e14a95 Mon Sep 17 00:00:00 2001 From: PJBot Date: Sat, 24 Jan 2026 19:16:48 +0000 Subject: [PATCH 08/47] Automatic changelog update --- Resources/Changelog/Admin.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Resources/Changelog/Admin.yml b/Resources/Changelog/Admin.yml index dd93af67dc..4e86408fb4 100644 --- a/Resources/Changelog/Admin.yml +++ b/Resources/Changelog/Admin.yml @@ -1605,5 +1605,13 @@ Entries: id: 195 time: '2026-01-16T22:24:15.0000000+00:00' url: https://github.com/space-wizards/space-station-14/pull/42443 +- author: Samuka + changes: + - message: shuttle called and recalled logs now show who recalled or called the + shuttle + type: Fix + id: 196 + time: '2026-01-24T19:15:40.0000000+00:00' + url: https://github.com/space-wizards/space-station-14/pull/41557 Name: Admin Order: 3 From 6ce33a463cedbbb333e0116fafc1fed1c417753f Mon Sep 17 00:00:00 2001 From: Kowlin <10947836+Kowlin@users.noreply.github.com> Date: Sat, 24 Jan 2026 20:38:21 +0100 Subject: [PATCH 09/47] De-panic bunker Vulture & set Cvars for feedback panel (#42612) * Disable Vulture's panic bunker * Add configs for feedback --- Resources/ConfigPresets/WizardsDen/vulture.toml | 7 +++++++ Resources/ConfigPresets/WizardsDen/wizardsDen.toml | 3 +++ 2 files changed, 10 insertions(+) diff --git a/Resources/ConfigPresets/WizardsDen/vulture.toml b/Resources/ConfigPresets/WizardsDen/vulture.toml index 8eccfa48e1..e14a5880be 100644 --- a/Resources/ConfigPresets/WizardsDen/vulture.toml +++ b/Resources/ConfigPresets/WizardsDen/vulture.toml @@ -3,6 +3,10 @@ [game] hostname = "[EN][Testing] Wizard's Den Vulture [US East 2]" desc = "Official English testing server for Space Station 14.\nVanilla, roleplay ruleset.\n\nYou can play with the newest changes to the game here, but these changes may not be final or stable, and may be reverted before the next stable release.\nPlease report bugs on our GitHub, forum, or community Discord." +panic_bunker.enabled = false +panic_bunker.disable_with_admins = false +panic_bunker.enable_without_admins = false +panic_bunker.custom_reason = "" [hub] tags = "lang:en,region:am_n_e,rp:low" @@ -14,3 +18,6 @@ force_client_hud_version_watermark = true [chat] motd = "\n########################################################\n\n[font size=17]This is a test server. You can play with the newest changes to the game, but these [color=red]changes may not be final or stable[/color], and may be reverted. Please report bugs via our GitHub, forum, or community Discord.[/font]\n\n########################################################\n" + +[feedback] +valid_origins = "wizden wizden_master" diff --git a/Resources/ConfigPresets/WizardsDen/wizardsDen.toml b/Resources/ConfigPresets/WizardsDen/wizardsDen.toml index 547dc6d6fa..bcd4ee648f 100644 --- a/Resources/ConfigPresets/WizardsDen/wizardsDen.toml +++ b/Resources/ConfigPresets/WizardsDen/wizardsDen.toml @@ -58,3 +58,6 @@ max_explosion_range = 10 privacy_policy_link = "https://spacestation14.com/about/privacy/#game-server-privacy-policy" privacy_policy_identifier = "wizden" privacy_policy_version = "2025-01-19" + +[feedback] +valid_origins = "wizden" From 2977d89f3e93654126d2cd7c4a272e080929cf48 Mon Sep 17 00:00:00 2001 From: B_Kirill <153602297+B-Kirill@users.noreply.github.com> Date: Sun, 25 Jan 2026 06:35:41 +1000 Subject: [PATCH 10/47] Cleanup warnings: CS0168, CS0414, CS8321 (#42623) --- Content.Client/Humanoid/OrganMarkingPicker.xaml.cs | 1 - Content.Server/Administration/Managers/BanManager.cs | 1 - Content.Server/Body/Commands/AddHandCommand.cs | 2 -- Content.Server/Database/ServerDbBase.cs | 2 +- Content.Server/Radio/EntitySystems/JammerSystem.cs | 8 -------- Content.Shared/Body/InitialBodySystem.cs | 1 - Content.Shared/Chemistry/Reaction/ReactionMixerSystem.cs | 2 -- Content.Shared/Humanoid/HumanoidCharacterAppearance.cs | 5 ----- .../Components/EntitySystems/HandheldGrinderSystem.cs | 2 -- Content.Shared/Radio/EntitySystems/SharedJammerSystem.cs | 1 - .../Teleportation/Systems/SharedPortalSystem.cs | 1 - 11 files changed, 1 insertion(+), 25 deletions(-) diff --git a/Content.Client/Humanoid/OrganMarkingPicker.xaml.cs b/Content.Client/Humanoid/OrganMarkingPicker.xaml.cs index 63ef73950d..8bcdf41dfe 100644 --- a/Content.Client/Humanoid/OrganMarkingPicker.xaml.cs +++ b/Content.Client/Humanoid/OrganMarkingPicker.xaml.cs @@ -14,7 +14,6 @@ namespace Content.Client.Humanoid; public sealed partial class OrganMarkingPicker : Control { [Dependency] private readonly MarkingManager _marking = default!; - [Dependency] private readonly IPrototypeManager _prototype = default!; [Dependency] private readonly IEntityManager _entity = default!; private readonly SpriteSystem _sprite; diff --git a/Content.Server/Administration/Managers/BanManager.cs b/Content.Server/Administration/Managers/BanManager.cs index ccf76e3995..650e1a1872 100644 --- a/Content.Server/Administration/Managers/BanManager.cs +++ b/Content.Server/Administration/Managers/BanManager.cs @@ -29,7 +29,6 @@ public sealed partial class BanManager : IBanManager, IPostInjectInit [Dependency] private readonly IConfigurationManager _cfg = default!; [Dependency] private readonly IChatManager _chat = default!; [Dependency] private readonly IServerDbManager _db = default!; - [Dependency] private readonly ServerDbEntryManager _entryManager = default!; [Dependency] private readonly IGameTiming _gameTiming = default!; [Dependency] private readonly ILocalizationManager _localizationManager = default!; [Dependency] private readonly ILogManager _logManager = default!; diff --git a/Content.Server/Body/Commands/AddHandCommand.cs b/Content.Server/Body/Commands/AddHandCommand.cs index 5d02d19155..ae8d6f69eb 100644 --- a/Content.Server/Body/Commands/AddHandCommand.cs +++ b/Content.Server/Body/Commands/AddHandCommand.cs @@ -3,7 +3,6 @@ using Content.Server.Hands.Systems; using Content.Shared.Administration; using Content.Shared.Hands.Components; using Robust.Shared.Console; -using Robust.Shared.Prototypes; namespace Content.Server.Body.Commands { @@ -11,7 +10,6 @@ namespace Content.Server.Body.Commands sealed class AddHandCommand : IConsoleCommand { [Dependency] private readonly IEntityManager _entManager = default!; - [Dependency] private readonly IPrototypeManager _protoManager = default!; private static int _handIdAccumulator; diff --git a/Content.Server/Database/ServerDbBase.cs b/Content.Server/Database/ServerDbBase.cs index 2c5524f502..ec46301235 100644 --- a/Content.Server/Database/ServerDbBase.cs +++ b/Content.Server/Database/ServerDbBase.cs @@ -215,7 +215,7 @@ namespace Content.Server.Database { return document.Deserialize(); } - catch (JsonException exception) + catch (JsonException) { return null; } diff --git a/Content.Server/Radio/EntitySystems/JammerSystem.cs b/Content.Server/Radio/EntitySystems/JammerSystem.cs index e39fc23831..812e0404b4 100644 --- a/Content.Server/Radio/EntitySystems/JammerSystem.cs +++ b/Content.Server/Radio/EntitySystems/JammerSystem.cs @@ -1,19 +1,11 @@ -using Content.Shared.DeviceNetwork.Components; -using Content.Shared.Interaction; -using Content.Shared.Power.EntitySystems; -using Content.Shared.PowerCell; using Content.Shared.Radio.EntitySystems; using Content.Shared.Radio.Components; -using Content.Shared.DeviceNetwork.Systems; namespace Content.Server.Radio.EntitySystems; public sealed class JammerSystem : SharedJammerSystem { - [Dependency] private readonly PowerCellSystem _powerCell = default!; - [Dependency] private readonly SharedBatterySystem _battery = default!; [Dependency] private readonly SharedTransformSystem _transform = default!; - [Dependency] private readonly SharedDeviceNetworkJammerSystem _jammer = default!; public override void Initialize() { diff --git a/Content.Shared/Body/InitialBodySystem.cs b/Content.Shared/Body/InitialBodySystem.cs index a062d1b0d1..639024171f 100644 --- a/Content.Shared/Body/InitialBodySystem.cs +++ b/Content.Shared/Body/InitialBodySystem.cs @@ -7,7 +7,6 @@ namespace Content.Shared.Body; public sealed class InitialBodySystem : EntitySystem { [Dependency] private readonly SharedContainerSystem _container = default!; - [Dependency] private readonly SharedTransformSystem _transform = default!; public override void Initialize() { diff --git a/Content.Shared/Chemistry/Reaction/ReactionMixerSystem.cs b/Content.Shared/Chemistry/Reaction/ReactionMixerSystem.cs index bd5b481517..34c94f2cb7 100644 --- a/Content.Shared/Chemistry/Reaction/ReactionMixerSystem.cs +++ b/Content.Shared/Chemistry/Reaction/ReactionMixerSystem.cs @@ -7,7 +7,6 @@ using Content.Shared.Chemistry.EntitySystems; using Content.Shared.Interaction.Events; using Content.Shared.Popups; using Robust.Shared.Audio.Systems; -using Robust.Shared.Network; namespace Content.Shared.Chemistry.Reaction; @@ -17,7 +16,6 @@ public sealed partial class ReactionMixerSystem : EntitySystem [Dependency] private readonly SharedSolutionContainerSystem _solutionContainer = default!; [Dependency] private readonly SharedDoAfterSystem _doAfter = default!; [Dependency] private readonly SharedAudioSystem _audio = default!; - [Dependency] private readonly INetManager _net = default!; public override void Initialize() { diff --git a/Content.Shared/Humanoid/HumanoidCharacterAppearance.cs b/Content.Shared/Humanoid/HumanoidCharacterAppearance.cs index b2123db04b..734f0b8b6a 100644 --- a/Content.Shared/Humanoid/HumanoidCharacterAppearance.cs +++ b/Content.Shared/Humanoid/HumanoidCharacterAppearance.cs @@ -104,11 +104,6 @@ public sealed partial class HumanoidCharacterAppearance : ICharacterAppearance, }; return new HumanoidCharacterAppearance(newEyeColor, newSkinColor, new()); - - float RandomizeColor(float channel) - { - return MathHelper.Clamp01(channel + random.Next(-25, 25) / 100f); - } } public static Color ClampColor(Color color) diff --git a/Content.Shared/Kitchen/Components/EntitySystems/HandheldGrinderSystem.cs b/Content.Shared/Kitchen/Components/EntitySystems/HandheldGrinderSystem.cs index 26130d6d30..3a5931d261 100644 --- a/Content.Shared/Kitchen/Components/EntitySystems/HandheldGrinderSystem.cs +++ b/Content.Shared/Kitchen/Components/EntitySystems/HandheldGrinderSystem.cs @@ -9,7 +9,6 @@ using Content.Shared.Popups; using Content.Shared.Stacks; using Robust.Shared.Audio.Systems; using Robust.Shared.Containers; -using Robust.Shared.Network; using Robust.Shared.Serialization; namespace Content.Shared.Kitchen.EntitySystems; @@ -22,7 +21,6 @@ internal sealed class HandheldGrinderSystem : EntitySystem [Dependency] private readonly SharedDestructibleSystem _destructibleSystem = default!; [Dependency] private readonly SharedDoAfterSystem _doAfter = default!; [Dependency] private readonly SharedAudioSystem _audio = default!; - [Dependency] private readonly INetManager _net = default!; [Dependency] private readonly SharedPuddleSystem _puddle = default!; [Dependency] private readonly SharedPopupSystem _popup = default!; diff --git a/Content.Shared/Radio/EntitySystems/SharedJammerSystem.cs b/Content.Shared/Radio/EntitySystems/SharedJammerSystem.cs index 5fd1009466..258b964cca 100644 --- a/Content.Shared/Radio/EntitySystems/SharedJammerSystem.cs +++ b/Content.Shared/Radio/EntitySystems/SharedJammerSystem.cs @@ -13,7 +13,6 @@ namespace Content.Shared.Radio.EntitySystems; public abstract class SharedJammerSystem : EntitySystem { [Dependency] private readonly ItemToggleSystem _itemToggle = default!; - [Dependency] private readonly SharedAppearanceSystem _appearance = default!; [Dependency] private readonly SharedDeviceNetworkJammerSystem _jammer = default!; [Dependency] private readonly SharedPopupSystem _popup = default!; diff --git a/Content.Shared/Teleportation/Systems/SharedPortalSystem.cs b/Content.Shared/Teleportation/Systems/SharedPortalSystem.cs index 473a7f9fb9..3735b68f61 100644 --- a/Content.Shared/Teleportation/Systems/SharedPortalSystem.cs +++ b/Content.Shared/Teleportation/Systems/SharedPortalSystem.cs @@ -33,7 +33,6 @@ public abstract class SharedPortalSystem : EntitySystem [Dependency] private readonly SharedTransformSystem _transform = default!; [Dependency] private readonly PullingSystem _pulling = default!; [Dependency] private readonly SharedPopupSystem _popup = default!; - [Dependency] private readonly SharedGrapplingGunSystem _grappling = default!; [Dependency] private readonly SharedJointSystem _joints = default!; private const string PortalFixture = "portalFixture"; From 69168f81d865a792670492cc74390eb65a31dbff Mon Sep 17 00:00:00 2001 From: Hitlinemoss <209321380+Hitlinemoss@users.noreply.github.com> Date: Sat, 24 Jan 2026 15:50:45 -0500 Subject: [PATCH 11/47] Improvements to automatic job highlights (#42630) * Improvements to automatic chat highlights * Automatic highlights option is more clearly labelled --- Resources/Locale/en-US/chat/highlights.ftl | 63 ++++++++++--------- .../en-US/escape-menu/ui/options-menu.ftl | 2 +- 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/Resources/Locale/en-US/chat/highlights.ftl b/Resources/Locale/en-US/chat/highlights.ftl index 0a1c2d948d..c6ec41bc6c 100644 --- a/Resources/Locale/en-US/chat/highlights.ftl +++ b/Resources/Locale/en-US/chat/highlights.ftl @@ -2,54 +2,55 @@ highlights-captain = Captain, "Cap", Bridge, Command highlights-head-of-personnel = Head Of Personnel, "HoP", Service, Bridge, Command highlights-chief-engineer = Chief Engineer, "CE", Engineering, Engineer, "Engi", Bridge, Command -highlights-chief-medical-officer = Chief Medical Officer, "CMO", MedBay, "Med", Bridge, Command -highlights-head-of-security = Head of Security, "HoS", Security, "Sec", Bridge, Command -highlights-quartermaster = Quartermaster, "QM", Cargo, Bridge, Command -highlights-research-director = Research Director, "RD", Science, "Sci", Bridge, Command +highlights-chief-medical-officer = Chief Medical Officer, "CMO", Medbay, Medical, "Med", Bridge, Command +highlights-head-of-security = Head of Security, "HoS", Armory, Security, "Sec", Bridge, Command +highlights-quartermaster = Quartermaster, "QM", Cargo, Supply, Bridge, Command +highlights-research-director = Research Director, "RD", Science, "Sci", "RND", "R&D", Bridge, Command # Security -highlights-detective = Detective, "Det", Security, "Sec" -highlights-security-cadet = Security Cadet, Secoff, Cadet, Security, "Sec" -highlights-security-officer = Security Officer, Secoff, Officer, Security, "Sec" -highlights-warden = Warden, "Ward", Security, "Sec" +highlights-detective = Detective, "Det", Armory, Security, "Sec" +highlights-security-cadet = Security Cadet, Secoff, Cadet, Armory, Security, "Sec" +highlights-security-officer = Security Officer, Secoff, Officer, Armory, Security, "Sec" +highlights-warden = Warden, "Ward", Brig, Genpop, Jail, "Prison", Armory, Security, "Sec" # Cargo -highlights-cargo-technician = Cargo Technician, Cargo Tech, "Cargo" -highlights-salvage-specialist = Salvage Specialist, Salvager, Salvage, "Salv", "Cargo", Miner +highlights-cargo-technician = Cargo Technician, Cargo Tech, "Cargo", Supply +highlights-salvage-specialist = Salvage Specialist, Salvager, Salvage, "Salv", Miner, "Cargo", Supply # Engineering -highlights-atmospheric-technician = Atmospheric Technician, Atmos tech, Atmospheric, Engineering, "Atmos", "Engi" +highlights-atmospheric-technician = Atmospheric Technician, Atmos Tech, Atmospheric, Engineering, "Atmos", "Engi" highlights-station-engineer = Station Engineer, Engineering, Engineer, "Engi" highlights-technical-assistant = Technical Assistant, Tech Assistant, Engineering, Engineer, "Engi" # Medical -highlights-chemist = Chemist, Chemistry, "Chem", MedBay, "Med" -highlights-medical-doctor = Medical Doctor, Doctor, "Doc", MedBay, "Med" -highlights-medical-intern = Medical Intern, "Doc", Intern, MedBay, "Med" -highlights-paramedic = Paramedic, "Para", MedBay, "Med" +highlights-chemist = Chemist, Chemistry, "Chem", Medbay, Medical, "Med" +highlights-medical-doctor = Medical Doctor, Doctor, "Doc", Medbay, Medical, "Med" +highlights-medical-intern = Medical Intern, Intern, Medbay, Medical, "Med" +highlights-paramedic = Paramedic, "Para", "Medic", Medbay, Medical, "Med" # Science -highlights-scientist = Scientist, Science, "Sci" -highlights-research-assistant = Research Assistant, Science, "Sci" +highlights-scientist = Scientist, Science, "Sci", "RND", "R&D" +highlights-research-assistant = Research Assistant, Science, "Sci", "RND", "R&D" # Civilian -highlights-bartender = Bartender, Barkeeper, Barkeep, "Bar" -highlights-botanist = Botanist, Botany, Hydroponics -highlights-chaplain = Chaplain, "Chap", Chapel -highlights-chef = Chef, "Cook", Kitchen -highlights-clown = Clown, Jester -highlights-janitor = Janitor, "Jani" -highlights-lawyer = Lawyer, Attorney -highlights-librarian = Librarian, Library -highlights-mime = Mime -highlights-passenger = Passenger, Greytider, "Tider" -highlights-service-worker = Service Worker +highlights-bartender = Bartender, Barkeeper, Barkeep, "Bar", Service, "Serv" +highlights-botanist = Botanist, Botany, Hydroponics, Service, "Serv" +highlights-chaplain = Chaplain, "Chap", Chapel, Service, "Serv" +highlights-chef = Chef, "Cook", Kitchen, Service, "Serv" +highlights-clown = Clown, Theatre, Theater, Service, "Serv" +highlights-janitor = Janitor, "Jani", Service, "Serv" +highlights-lawyer = Lawyer, Attorney, "Law", Service, "Serv" +highlights-librarian = Librarian, Library, Service, "Serv" +highlights-mime = Mime, Theatre, Theater, Service, "Serv" +highlights-musician = Musician, "Music", Theatre, Theater, Service, "Serv" +highlights-passenger = Passenger, Greytider, Graytider, "Tider", "Tide" +highlights-service-worker = Service Worker, Service, "Serv" # Station-specific -highlights-reporter = Reporter, Journalist -highlights-psychologist = Psychologist, Psychology +highlights-reporter = Reporter, Journalist, Newsroom, News +highlights-psychologist = Psychologist, Psychology, "Psych", Medbay, Medical, "Med" # Silicon highlights-personal-ai = Personal AI, "pAI" -highlights-cyborg = Cyborg, Silicon, Borg +highlights-cyborg = Cyborg, Silicon, Borg, Robotics, "Robot" highlights-station-ai = Station AI, Silicon, "AI", "sAI" diff --git a/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl b/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl index a263e52229..7c13f01952 100644 --- a/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl +++ b/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl @@ -49,7 +49,7 @@ ui-options-misc-label = Misc ui-options-interface-label = Interface -ui-options-auto-fill-highlights = Auto-fill the highlights with the character's information +ui-options-auto-fill-highlights = Automatically set the highlights list based on your character's name and job ui-options-highlights-color = Highlights color: ui-options-highlights-color-example = This is highlighted text. ui-options-show-held-item = Show held item next to cursor From 0d32aad06add394928c828fa6bf8a262da8ac5be Mon Sep 17 00:00:00 2001 From: PJBot Date: Sat, 24 Jan 2026 21:07:36 +0000 Subject: [PATCH 12/47] Automatic changelog update --- Resources/Changelog/Changelog.yml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/Resources/Changelog/Changelog.yml b/Resources/Changelog/Changelog.yml index dd871813f9..d616065c0c 100644 --- a/Resources/Changelog/Changelog.yml +++ b/Resources/Changelog/Changelog.yml @@ -1,11 +1,4 @@ Entries: -- author: TsjipTsjip LuckyShotPictures IProduceWidgets - changes: - - message: Admin shuttles have been added to the repo, and can be found in /Maps/Shuttles/AdminSpawn/.. - type: Add - id: 8950 - time: '2025-09-11T01:01:28.0000000+00:00' - url: https://github.com/space-wizards/space-station-14/pull/32139 - author: ToastEnjoyer changes: - message: Fixed the laser carbine not being labeled as contraband @@ -4005,3 +3998,14 @@ id: 9456 time: '2026-01-24T04:18:37.0000000+00:00' url: https://github.com/space-wizards/space-station-14/pull/42315 +- author: Hitlinemoss + changes: + - message: The "Auto-fill the highlights with the character's information" accessibility + option has been renamed to "Automatically set the highlights list based on your + character's name and job". + type: Tweak + - message: More automatic job-text highlights have been added. + type: Tweak + id: 9457 + time: '2026-01-24T21:06:28.0000000+00:00' + url: https://github.com/space-wizards/space-station-14/pull/42630 From e4c83a4040804172f202627f39d37ae5884ee250 Mon Sep 17 00:00:00 2001 From: Myra Date: Sat, 24 Jan 2026 23:19:21 +0100 Subject: [PATCH 13/47] Fix roundstart with thief gamerule (#42633) --- Resources/Prototypes/Objectives/objectiveGroups.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/Resources/Prototypes/Objectives/objectiveGroups.yml b/Resources/Prototypes/Objectives/objectiveGroups.yml index 31ef38f428..9aa13fa74a 100644 --- a/Resources/Prototypes/Objectives/objectiveGroups.yml +++ b/Resources/Prototypes/Objectives/objectiveGroups.yml @@ -85,7 +85,6 @@ AmePartFlatpackStealObjective: 1 ExpeditionsCircuitboardStealObjective: 1 #sup CargoShuttleCircuitboardStealObjective: 1 - ClothingEyesHudBeerStealObjective: 0.5 #srv, beer goggles less common cause its so easy BibleStealObjective: 1 ClothingNeckGoldmedalStealObjective: 1 #other ClothingNeckClownmedalStealObjective: 0.5 From de3311e9e43eedab36e07a7c92f31d18fc092f76 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 25 Jan 2026 03:12:42 +0100 Subject: [PATCH 14/47] Update Credits (#42636) Co-authored-by: PJBot --- Resources/Credits/GitHub.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Resources/Credits/GitHub.txt b/Resources/Credits/GitHub.txt index 75030de7b4..63561b953d 100644 --- a/Resources/Credits/GitHub.txt +++ b/Resources/Credits/GitHub.txt @@ -1 +1 @@ -0-Anon, 0leshe, 0tito, 0x6273, 12rabbits, 1337dakota, 13spacemen, 154942, 2013HORSEMEATSCANDAL, 20kdc, 21Melkuu, 27alaing, 2DSiggy, 3nderall, 4310v343k, 4dplanner, 5tickman, 612git, 778b, 96flo, aaron, abadaba695, Ablankmann, abregado, Absolute-Potato, Absotively, achookh, Acruid, ActiveMammmoth, actually-reb, ada-please, adamsong, Adeinitas, adm2play, Admiral-Obvious-001, adrian, Adrian16199, Ady4ik, Aearo-Deepwater, Aerocrux, Aeshus, Aexolott, Aexxie, africalimedrop, afrokada, AftrLite, AgentSmithRadio, Agoichi, ahandleman, Ahion, aiden, aidenkrz, Aidenkrz, Aisu9, ajcm, AJCM-git, AjexRose, Alekshhh, alexalexmax, alexkar598, AlexMorgan3817, alexum418, alexumandxgabriel08x, Alice4267, Alithsko, Alkheemist, alliephante, ALMv1, Alpaccalypse, Alpha-Two, AlphaQwerty, Altoids1, amatwiedle, amylizzle, ancientpower, Andre19926, Andrew-Fall, AndrewEyeke, AndrewFenriz, AndreyCamper, anri, Anzarot121, ApolloVector, Appiah, april-gras, ar4ill, Arcane-Waffle, archee1, ArchPigeon, ArchRBX, areitpog, Arendian, areyouconfused, arimah, Arkanic, ArkiveDev, armoks, Arteben, ArthurMousatov, ArtisticRoomba, artur, Artxmisery, ArZarLordOfMango, as334, AshBats, AsikKEsel, AsnDen, asperger-sind, aspiringLich, astriloqua, Atakku, august-sun, AutoOtter, AverageNotDoingAnythingEnjoyer, avghdev, AwareFoxy, Awlod, Axionyxx, AzzyIsNotHere, azzyisnothere, B-Kirill, B3CKDOOR, baa14453, BackeTako, BadaBoomie, Bakke, BananaFlambe, Baptr0b0t, BarryNorfolk, BasedUser, bebr3ght, beck-thompson, beesterman, bellwetherlogic, ben, benbryant0, benev0, benjamin-burges, BGare, bhespiritu, bibbly, BigfootBravo, BIGZi0348, bingojohnson, BismarckShuffle, Bixkitts, Blackern5000, Blazeror, blitzthesquishy, Blobadoodle, bloodrizer, Bloody2372, blueDev2, Boaz1111, BobdaBiscuit, BobTheSleder, boiled-water-tsar, Bokser815, bolantej, Booblesnoot42, Boolean-Buckeye, botanySupremist, brainfood1183, BramvanZijp, Brandon-Huu, breeplayx3, BriBrooo, BRINGit34, brndd, bryce0110, BubblegumBlue, buletsponge, buntobaggins, buunie099, bvelliquette, BWTCK, byondfuckery, c0rigin, c4llv07e, CaasGit, Caconym27, Calecute, Callmore, Camdot, cammusubi, capnsockless, CaptainMaru, captainsqrbeard, Carbonhell, Carolyn3114, Carou02, carteblanche4me, catdotjs, catlord, Catofquestionableethics, CatTheSystem, CawsForConcern, CDWimmer, Centronias, Chaboricks, chairbender, chaisftw, Chaoticaa, Charlese2, charlie, chartman, ChaseFlorom, chavonadelal, Cheackraze, CheddaCheez, cheesePizza2, CheesePlated, Chief-Engineer, chillyconmor, christhirtle, chromiumboy, Chronophylos, Chubbicous, Chubbygummibear, Ciac32, ciaran, citrea, civilCornball, claustro305, Clement-O, cloudyias, clyf, Clyybber, CMDR-Piboy314, cnv41, coco, cohanna, Cohnway, Cojoke-dot, ColdAutumnRain, Colin-Tel, collinlunn, ComicIronic, Compilatron144, CookieMasterT, coolboy911, CoolioDudio, coolmankid12345, Coolsurf6, cooperwallace, corentt, CormosLemming, CrafterKolyan, CraftyRenter, crazybrain23, Crazydave91920, CrazyPhantom779, creadth, CrigCrag, CroilBird, Crotalus, CrudeWax, cryals, CrzyPotato, cubixthree, cutemoongod, Cyberboss, d34d10cc, DadeKuma, Daemon, daerSeebaer, dahnte, dakamakat, DamianX, dan, dangerrevolution, daniel-cr, DanSAussieITS, Daracke, Darkie, david, DawBla, Daxxi3, dch-GH, ddeegan, de0rix, Deahaka, dean, DEATHB4DEFEAT, Deatherd, deathride58, debugok, Decappi, Decortex, Deeeeja, deepdarkdepths, DeepwaterCreations, Deerstop, degradka, Delete69, deltanedas, DenisShvalov, DerbyX, derek, dersheppard, Deserty0, Detintinto, DevilishMilk, devinschubert14, dexlerxd, dffdff2423, DieselMohawk, DieselMohawkTheSequel, digitalic, Dimastra, DinnerCalzone, DinoWattz, Disp-Dev, DisposableCrewmember42, dissidentbullet, DjfjdfofdjfjD, doc-michael, docnite, Doctor-Cpu, DogZeroX, dolgovmi, dontbetank, Doomsdrayk, Doru991, DoubleRiceEddiedd, DoutorWhite, DR-DOCTOR-EVIL-EVIL, Dragonjspider, dragonryan06, drakewill-CRL, Drayff, dreamlyjack, DrEnzyme, dribblydrone, DrMelon, drongood12, DrSingh, DrSmugleaf, drteaspoon420, DTanxxx, DubiousDoggo, DuckManZach, Duddino, dukevanity, duskyjay, Dutch-VanDerLinde, dvir001, dylanstrategie, dylanwhittingham, Dynexust, Easypoller, echo, EchoOfNothing, eclips_e, eden077, EEASAS, Efruit, efzapa, Ekkosangen, ElectroSR, elsie, elthundercloud, Elysium206, emberwinters, Emisse, emmafornash, EmoGarbage404, Endecc, EnrichedCaramel, Entvari, eoineoineoin, ephememory, eris, erohrs2, erorr404v1, Errant-4, ertanic, esguard, estacaoespacialpirata, eternally-confused, eugene, ewokswagger, exincore, exp111, f0x-n3rd, F1restar4, FacePluslll, Fahasor, FairlySadPanda, farrellka-dev, FATFSAAM2, Feluk6174, ficcialfaint, Fiftyllama, Fildrance, fillervk, FinnishPaladin, firenamefn, Firewars763, FirinMaLazors, Fishfish458, fl-oz, Flareguy, flashgnash, FlipBrooke, FluffiestFloof, FluffMe, FluidRock, flymo5678, foboscheshir, FoLoKe, fooberticus, ForestNoises, forgotmyotheraccount, forkeyboards, forthbridge, Fortune117, foxhorn, freeman2651, freeze2222, frobnic8, Froffy025, Fromoriss, froozigiusz, FrostMando, FrostRibbon, Fruitsalad, Funce, FungiFellow, FunkySphere, FunTust, Futuristic-OK, GalacticChimp, gamer3107, Gamewar360, gansulalan, GaussiArson, Gaxeer, gbasood, gcoremans, Geekyhobo, genderGeometries, GeneralGaws, Genkail, Gentleman-Bird, geraeumig, Ghagliiarghii, Git-Nivrak, githubuser508, GitHubUser53123, gituhabu, GlassEclipse, GnarpGnarp, GNF54, godisdeadLOL, goet, GoldenCan, Goldminermac, Golinth, golubgik, GoodWheatley, Gorox221, GR1231, gradientvera, graevy, GraniteSidewalk, GreaseMonk, greenrock64, GreyMario, GrownSamoyedDog, GTRsound, gusxyz, Gyrandola, h3half, hamurlik, Hanzdegloker, HappyRoach, happyrobot33, Hardly3D, harikattar, Hayden, he1acdvv, Hebi, Helix-ctrl, helm4142, Henry, HerCoyote23, Hi-Im-Shot, HighTechPuddle, Hitlinemoss, hiucko, hivehum, Hmeister-fake, Hmeister-real, Hobbitmax, hobnob, HoidC, Holinka4ever, holyssss, HoofedEar, Hoolny, hord-brayden, hoshizora-sayo, Hreno, Hrosts, htmlsystem, Huaqas, hubismal, Hugal31, Hyenh, hyperb1, hyperDelegate, hyphenationc, i-justuser-i, iaada, iacore, IamVelcroboy, Ian321, icekot8, icesickleone, iczero, iglov, IgorAnt028, igorsaux, ike709, illersaver, Illiux, Ilushkins33, Ilya246, IlyaElDunaev, imatsoup, IMCB, impubbi, imrenq, imweax, indeano, Injazz, Insineer, insoPL, IntegerTempest, Interrobang01, Intoxicating-Innocence, IProduceWidgets, itsmethom, Itzbenz, iztokbajcar, Jackal298, Jackrost, JackRyd3r, jacksonzck, JackspajfMain, Jacktastic09, Jackw2As, jacob, jamessimo, janekvap-havok, Jark255, Jarmer123, Jaskanbe, JasperJRoth, jbox144, JCGWE30, jerryimmouse, JerryImMouse, Jessetriesagain, jessicamaybe, JesterX666, Jewelots, Jezithyr, jicksaw, JiimBob, JimGamemaster, jimmy12or, JIPDawg, jjtParadox, jkwookee, jmcb, JohnGinnane, johnjjohn, johnku1, Jophire, Jopogrechkin, joshepvodka, JpegOfAFrog, jproads, JrInventor05, Jrpl, jukereise, juliangiebel, JustArt1m, JustCone14, justdie12, justin, justintether, JustinTrotter, JustinWinningham, justtne, K-Dynamic, k3yw, Kadeo64, Kaga-404, kaiserbirch, KaiShibaa, kalane15, kalanosh, KamTheSythe, Kanashi-Panda, katzenminer, kbailey-git, Keelin, Keer-Sar, KEEYNy, keikiru, Kelrak, kerisargit, keronshb, KIBORG04, KieueCaprie, Kimpes, kin98, KingFroozy, kira-er, kiri-yoshikage, Kirillcas, Kirus59, Kistras, Kit, Kit0vras, KittenColony, Kittygyat, klaypexx, kleinerstation13, Kmc2000, Ko4ergaPunk, kognise, kokoc9n, komunre, KonstantinAngelov, kontakt, korczoczek, kosticia, koteq, kotobdev, Kowlin, KrasnoshchekovPavel, Krosus777, Krunklehorn, Kryyto, Kupie, kxvvv, Kyoth25f, kyupolaris, kzhanik, LaCumbiaDelCoronavirus, lajolico, Lamrr, lanedon, LankLTE, laok233, lapatison, larryrussian, lawdog4817, Lazzi0706, leah, leander-0, leonardo-dabepis, leonidussaks, leonsfriedrich, LeoSantich, lettern, LetterN, Level10Cybermancer, LEVELcat, lever1209, LevitatingTree, Lgibb18, lgruthes, LightVillet, lilazero, liltenhead, linkbro1, linkuyx, Litraxx, little-meow-meow, LittleBuilderJane, LittleNorthStar, LittleNyanCat, lizelive, ljm862, lmsnoise, localcc, lokachop, lolman360, Lomcastar, Lordbrandon12, LordCarve, LordEclipse, lucas, LucasTheDrgn, luckyshotpictures, LudwigVonChesterfield, luegamer, luizwritescode, LukaSlade, luminight, lunarcomets, Lusatia, Luxeator, lvvova1, Lyndomen, lyroth001, lzimann, lzk228, M1tht1c, M3739, M4rchy-S, M87S, mac6na6na, MACMAN2003, Macoron, magicalus, magmodius, magnuscrowe, maland1, malchanceux, MaloTV, ManelNavola, manelnavola, Mangohydra, marboww, Markek1, MarkerWicker, marlyn, matt, Matz05, max, MaxNox7, maylokana, MDuch369, meara1179, meganerobot, MehimoNemo, Mehnix, MeltedPixel, memeproof, MendaxxDev, Menshin, Mephisto72, MerrytheManokit, Mervill, metalgearsloth, MetalSage, MFMessage, mhamsterr, michaelcu, micheel665, mifia, mikeysaurus, MilenVolf, MilonPL, Minemoder5000, Minty642, minus1over12, Mirino97, mirrorcult, misandrie, MishaUnity, MissKay1994, MisterImp, MisterMecky, Mith-randalf, Mixelz, mjarduk, MjrLandWhale, mkanke-real, MLGTASTICa, mnva0, moderatelyaware, modern-nm, mohamedwidar, mokiros, momo, Moneyl, monotheonist, Moomoobeef, moony, Morb0, MossyGreySlope, mqole, mr-bo-jangles, Mr0maks, MrFippik, MrPersival, mrrobdemo, mtrs163, muburu, MureixloI, murolem, murphyneko, musicmanvr, MWKane, Myakot, Myctai, N3X15, nabegator, nails-n-tape, Nairodian, Naive817, NakataRin, namespace-Memory, Nannek, NazrinNya, neborsh, neomoth, neutrino-laser, NickPowers43, nikitosych, nikthechampiongr, Nimfar11, ninruB, Nirnael, NIXC, nkokic, NkoKirkto, nmajask, noctyrnal, noelkathegod, noirogen, nok-ko, NonchalantNoob, NoobyLegion, Nopey, NoreUhh, not-gavnaed, notafet, notquitehadouken, notsodana, noudoit, noverd, Nox38, NuclearWinter, Nuggets219, nukashimika, nuke-haus, NULL882, nullarmo, nyeogmi, Nylux, Nyranu, Nyxilath, och-och, OctoRocket, OldDanceJacket, OliverOtter, onesch, OneZerooo0, OnsenCapy, OnyxTheBrave, opl-, Orange-Winds, OrangeMoronage9622, OrbitSystem07, Orsoniks, osjarw, Ostaf, othymer, OttoMaticode, Owai-Seek, packmore, PAFFhassoocks, paige404, paigemaeforrest, pali6, Palladinium, Pangogie, panzer-iv1, partyaddict, patrikturi, PaulRitter, pavlockblaine03, peccneck, Peptide90, peptron1, perryprog, PeterFuto, PetMudstone, pewter-wiz, PGrayCS, pgraycs, Pgriha, phantom-lily, pheenty, philingham, Phill101, Phooooooooooooooooooooooooooooooosphate, phunnyguy, PicklOH, PilgrimViis, Pill-U, pinkbat5, Piras314, Pireax, Pissachu, pissdemon, Pixel8-dev, PixeltheAertistContrib, PixelTheKermit, PJB3005, Plasmaguy, plinyvic, Plykiya, poeMota, pofitlo, pointer-to-null, Pok27, poklj, PolterTzi, PoorMansDreams, PopGamer45, portfiend, potato1234x, PotentiallyTom, PotRoastPiggy, Princess-Cheeseballs, ProfanedBane, Prole0, ProPandaBear, PrPleGoo, ps3moira, Pspritechologist, Psychpsyo, psykana, psykzz, PuceTint, pumkin69, PuroSlavKing, PursuitInAshes, Putnam3145, py01, Pyrovi, qrtDaniil, qrwas, Quantum-cross, quasr-9, quatre, QueerNB, QuietlyWhisper, qwerltaz, Radezolid, RadioMull, Radosvik, Radrark, Rainbeon, Rainfey, Raitononai, Ramlik, RamZ, randy10122, Rane, Ranger6012, Rapidgame7, ravage123321, rbertoche, RedBookcase, Redfire1331, Redict, RedlineTriad, redmushie, RednoWCirabrab, redspyy, ReeZer2, RemberBM, RemieRichards, RemTim, rene-descartes2021, Renlou, retequizzle, rewafflution, rhailrake, rhsvenson, rich-dunne, RieBi, riggleprime, RIKELOLDABOSS, rinary1, Rinkashikachi, riolume, rlebell33, RobbyTheFish, robinthedragon, Rockdtben, Rohesie, rok-povsic, rokudara-sen, rolfero, RomanNovo, rosieposieeee, Roudenn, router, ruddygreat, rumaks-xyz, RumiTiger, Ruzihm, rwrv, S1rFl0, S1ss3l, Saakra, Sadie-silly, saga3152, saintmuntzer, Salex08, sam, samgithubaccount, Samuka-C, SaphireLattice, SapphicOverload, sarahon, sativaleanne, SaveliyM360, sBasalto, ScalyChimp, ScarKy0, ScholarNZL, schrodinger71, scrato, Scribbles0, scrivoy, scruq445, scuffedjays, ScumbagDog, SeamLesss, Segonist, semensponge, sephtasm, ser1-1y, Serkket, sewerpig, SG6732, sh18rw, Shaddap1, ShadeAware, ShadowCommander, shadowtheprotogen546, shaeone, shampunj, shariathotpatrol, SharkSnake98, shibechef, Siginanto, signalsender, SignalWalker, siigiil, silicon14wastaken, Silverfur-underscore, Simyon264, sirdragooon, Sirionaut, SirWarock, Sk1tch, SkaldetSkaeg, Skarletto, skeeka-dev, skrybl, Skybailey-dev, skye, Skyedra, SlamBamActionman, slarticodefast, Slava0135, sleepyyapril, slimmslamm, Slyfox333, Smugman, SnappingOpossum, snebl, snicket, sniperchance, Snowni, snowsignal, SolidSyn, SolidusSnek, solstar2, SomegnihT, SonarZeBat, SonicHDC, SoulFN, SoulSloth, Soundwavesghost, soupkilove, southbridge-fur, sowelipililimute, Soydium, SpaceLizard24, SpaceLizardSky, SpaceManiac, SpaceRox1244, SpaceyLady, Spangs04, spanky-spanky, Sparlight, spartak, SpartanKadence, spderman3333, SpeltIncorrectyl, Spessmann, SphiraI, SplinterGP, spoogemonster, sporekto, sporkyz, ssdaniel24, stalengd, stanberytrask, Stanislav4ix, StanTheCarpenter, starbuckss14, Stealthbomber16, Steffo99, stellar-novas, stewie523, stomf, Stop-Signs, stopbreaking, stopka-html, StrawberryMoses, Stray-Pyramid, strO0pwafel, Strol20, StStevens, Subversionary, sunbear-dev, SuperGDPWYL, superjj18, Supernorn, SurrealShibe, SweetAplle, SweptWasTaken, SyaoranFox, Sybil, SYNCHRONIC, Szunti, t, Tainakov, takemysoult, taonewt, tap, TaralGit, Taran, taurie, Tayrtahn, tday93, teamaki, TeenSarlacc, TekuNut, telavivgamers, telyonok, temm1ie, TemporalOroboros, tentekal, terezi4real, Terraspark4941, texcruize, Tezzaide, TGODiamond, TGRCdev, tgrkzus, thanosdegraf, ThatGuyUSA, ThatOneGoblin25, thatrandomcanadianguy, TheArturZh, TheBlueYowie, thecopbennet, TheCze, TheDarkElites, thedraccx, TheEmber, theexetron, TheFlyingSentry, thefoty, TheGrimbeeper, TheIntoxicatedCat, thekilk, themias, theomund, TheProNoob678, TherapyGoth, ThereDrD0, TheSecondLord, TheShuEd, thetolbean, thevinter, TheWaffleJesus, thinbug0, ThunderBear2006, timothyteakettle, TimrodDX, timurjavid, tin-man-tim, TiniestShark, Titian3, tk-a369, tkdrg, tmtmtl30, ToastEnjoyer, Toby222, TokenStyle, Tollhouse, Toly65, tom-leys, tomasalves8, Tomeno, Tonydatguy, topy, tornado-technology, TornadoTechnology, tosatur, TotallyLemon, ToxicSonicFan04, Tr1bute, travis-g-reid, treytipton, TriviaSolari, trixxedbit, TrixxedHeart, tropicalhibi, truepaintgit, Truoizys, Tryded, TsjipTsjip, tuchila-adi-bogdan, Tunguso4ka, TurboTrackerss14, TVK-04, tyashley, Tyler-IN, TytosB, Tyzemol, UbaserB, Uberration, ubis1, UBlueberry, uhbg, UKNOWH, UltimateJester, Unbelievable-Salmon, underscorex5, UnicornOnLSD, Unisol, Unkn0wnGh0st333, unusualcrow, Uriende, UristMcDorf, user424242420, Utmanarn, Vaaankas, valentfingerov, valquaint, Varen, Vasilis, VasilisThePikachu, veliebm, Velken, VelonacepsCalyxEggs, veprolet, VerinSenpai, veritable-calamity, Veritius, Vermidia, vero5123, verslebas, vexerot, vgskye, viceemargo, VigersRay, violet754, Visne, vitopigno, vitusveit, vlad, vlados1408, VMSolidus, vmzd, VoidMeticulous, voidnull000, volotomite, volundr-, Voomra, Vordenburg, vorkathbruh, Vortebo, vulppine, wachte1, wafehling, walksanatora, Warentan, WarMechanic, Watermelon914, weaversam8, wertanchik, whateverusername0, whatston3, widgetbeck, Will-Oliver-Br, Willhelm53, WilliamECrew, willicassi, Winkarst-cpu, wirdal, wixoaGit, WlarusFromDaSpace, Wolfkey-SomeoneElseTookMyUsername, Worldwaker, wrexbe, wtcwr68, xeri7, xkreksx, xprospero, xRiriq, xsainteer, YanehCheck, yathxyz, Ygg01, YotaXP, youarereadingthis, YoungThugSS14, Yousifb26, youtissoum, yunii, yuriykiss, YuriyKiss, zach-hill, Zadeon, Zalycon, zamp, Zandario, Zap527, Zealith-Gamer, ZelteHonor, zero, ZeroDiamond, ZeWaka, zHonys, zionnBE, ZNixian, Zokkie, ZoldorfTheWizard, zonespace27, Zylofan, Zymem, zzylex +0-Anon, 0leshe, 0tito, 0x6273, 12rabbits, 1337dakota, 13spacemen, 154942, 2013HORSEMEATSCANDAL, 20kdc, 21Melkuu, 27alaing, 2DSiggy, 3nderall, 4310v343k, 4dplanner, 5tickman, 612git, 778b, 96flo, aaron, abadaba695, Ablankmann, abregado, Absolute-Potato, Absotively, achookh, Acruid, ActiveMammmoth, actually-reb, ada-please, adamsong, Adeinitas, adm2play, Admiral-Obvious-001, adrian, Adrian16199, Ady4ik, Aearo-Deepwater, Aerocrux, Aeshus, Aexolott, Aexxie, africalimedrop, afrokada, AftrLite, AgentSmithRadio, Agoichi, ahandleman, Ahion, aiden, Aidenkrz, aidenkrz, Aisu9, ajcm, AJCM-git, AjexRose, Alekshhh, alexalexmax, alexkar598, AlexMorgan3817, alexum418, alexumandxgabriel08x, Alice4267, Alithsko, Alkheemist, alliephante, ALMv1, Alpaccalypse, Alpha-Two, AlphaQwerty, Altoids1, amatwiedle, amylizzle, ancientpower, Andre19926, Andrew-Fall, AndrewEyeke, AndrewFenriz, AndreyCamper, anri, Anzarot121, ApolloVector, Appiah, april-gras, ar4ill, Arcane-Waffle, archee1, ArchPigeon, ArchRBX, areitpog, Arendian, areyouconfused, arimah, Arkanic, ArkiveDev, armoks, Arteben, ArthurMousatov, ArtisticRoomba, artur, Artxmisery, ArZarLordOfMango, as334, AshBats, AsikKEsel, AsnDen, asperger-sind, aspiringLich, astriloqua, Atakku, Ataman, august-sun, AutoOtter, AverageNotDoingAnythingEnjoyer, avghdev, AwareFoxy, Awlod, Axionyxx, AzzyIsNotHere, azzyisnothere, B-Kirill, B3CKDOOR, baa14453, BackeTako, BadaBoomie, Bakke, BananaFlambe, Baptr0b0t, BarryNorfolk, BasedUser, bebr3ght, beck-thompson, beesterman, bellwetherlogic, ben, benbryant0, benev0, benjamin-burges, BGare, bhespiritu, bibbly, BigfootBravo, BIGZi0348, bingojohnson, BismarckShuffle, Bixkitts, Blackern5000, Blazeror, blitzthesquishy, Blobadoodle, bloodrizer, Bloody2372, blueDev2, Boaz1111, BobdaBiscuit, BobTheSleder, boiled-water-tsar, Bokser815, bolantej, Booblesnoot42, Boolean-Buckeye, botanySupremist, brainfood1183, BramvanZijp, Brandon-Huu, breeplayx3, BriBrooo, BRINGit34, brndd, bryce0110, BubblegumBlue, buletsponge, buntobaggins, buunie099, bvelliquette, BWTCK, byondfuckery, c0rigin, c4llv07e, CaasGit, Caconym27, Calecute, Callmore, Camdot, cammusubi, capnsockless, CaptainMaru, captainsqrbeard, Carbonhell, Carolyn3114, Carou02, carteblanche4me, catdotjs, catlord, Catofquestionableethics, CatTheSystem, CawsForConcern, CDWimmer, Centronias, Chaboricks, chairbender, chaisftw, Chaoticaa, Charlese2, charlie, chartman, ChaseFlorom, chavonadelal, Cheackraze, CheddaCheez, cheesePizza2, CheesePlated, Chief-Engineer, chillyconmor, christhirtle, chromiumboy, Chronophylos, Chubbicous, Chubbygummibear, Ciac32, ciaran, citrea, civilCornball, claustro305, Clement-O, cloudyias, clyf, Clyybber, CMDR-Piboy314, cnv41, coco, cohanna, Cohnway, Cojoke-dot, ColdAutumnRain, Colin-Tel, collinlunn, ComicIronic, Compilatron144, CookieMasterT, coolboy911, CoolioDudio, coolmankid12345, Coolsurf6, cooperwallace, corentt, CormosLemming, CrafterKolyan, CraftyRenter, crazybrain23, Crazydave91920, CrazyPhantom779, creadth, CrigCrag, CroilBird, Crotalus, CrudeWax, cryals, CrzyPotato, cubixthree, cutemoongod, Cyberboss, d34d10cc, DadeKuma, Daemon, daerSeebaer, dahnte, dakamakat, DamianX, dan, dangerrevolution, daniel-cr, DanSAussieITS, Daracke, Darkie, david, DawBla, Daxxi3, dch-GH, ddeegan, de0rix, Deahaka, dean, DEATHB4DEFEAT, Deatherd, deathride58, debugok, Decappi, Decortex, Deeeeja, deepdarkdepths, DeepwaterCreations, Deerstop, degradka, Delete69, deltanedas, DenisShvalov, DerbyX, derek, dersheppard, Deserty0, Detintinto, DevilishMilk, devinschubert14, dexlerxd, dffdff2423, DieselMohawk, DieselMohawkTheSequel, digitalic, Dimastra, DinnerCalzone, DinoWattz, Disp-Dev, DisposableCrewmember42, dissidentbullet, DjfjdfofdjfjD, doc-michael, docnite, Doctor-Cpu, DogZeroX, dolgovmi, dontbetank, Doomsdrayk, Doru991, DoubleRiceEddiedd, DoutorWhite, DR-DOCTOR-EVIL-EVIL, Dragonjspider, dragonryan06, drakewill-CRL, Drayff, dreamlyjack, DrEnzyme, dribblydrone, DrMelon, drongood12, DrSingh, DrSmugleaf, drteaspoon420, DTanxxx, DubiousDoggo, DuckManZach, Duddino, dukevanity, duskyjay, Dutch-VanDerLinde, dvir001, dylanstrategie, dylanwhittingham, Dynexust, Easypoller, echo, EchoOfNothing, eclips_e, eden077, EEASAS, Efruit, efzapa, Ekkosangen, ElectroSR, elsie, elthundercloud, Elysium206, emberwinters, Emisse, emmafornash, EmoGarbage404, Endecc, EnrichedCaramel, Entvari, eoineoineoin, ephememory, eris, erohrs2, erorr404v1, Errant-4, ertanic, esguard, estacaoespacialpirata, eternally-confused, eugene, ewokswagger, exincore, exp111, f0x-n3rd, F1restar4, FacePluslll, Fahasor, FairlySadPanda, farrellka-dev, FATFSAAM2, Feluk6174, ficcialfaint, Fiftyllama, Fildrance, fillervk, FinnishPaladin, firenamefn, Firewars763, FirinMaLazors, Fishfish458, fl-oz, Flareguy, flashgnash, FlipBrooke, FluffiestFloof, FluffMe, FluidRock, flymo5678, foboscheshir, FoLoKe, fooberticus, ForestNoises, forgotmyotheraccount, forkeyboards, forthbridge, Fortune117, foxhorn, freeman2651, freeze2222, frobnic8, Froffy025, Fromoriss, froozigiusz, FrostMando, FrostRibbon, Fruitsalad, Funce, FungiFellow, FunkySphere, FunTust, Futuristic-OK, GalacticChimp, gamer3107, Gamewar360, gansulalan, GaussiArson, Gaxeer, gbasood, gcoremans, Geekyhobo, genderGeometries, GeneralGaws, Genkail, Gentleman-Bird, geraeumig, Ghagliiarghii, Git-Nivrak, githubuser508, GitHubUser53123, gituhabu, GlassEclipse, GnarpGnarp, GNF54, godisdeadLOL, goet, GoldenCan, Goldminermac, Golinth, golubgik, GoodWheatley, Gorox221, GR1231, gradientvera, graevy, GraniteSidewalk, GreaseMonk, greenrock64, GreyMario, GrownSamoyedDog, GTRsound, gusxyz, Gyrandola, h3half, hamurlik, Hanzdegloker, HappyRoach, happyrobot33, Hardly3D, harikattar, Hayden, he1acdvv, Hebi, Helix-ctrl, helm4142, Henry, HerCoyote23, Hi-Im-Shot, HighTechPuddle, Hitlinemoss, hiucko, hivehum, Hmeister-fake, Hmeister-real, Hobbitmax, hobnob, HoidC, Holinka4ever, holyssss, HoofedEar, Hoolny, hord-brayden, hoshizora-sayo, Hreno, Hrosts, htmlsystem, Huaqas, hubismal, Hugal31, Hyenh, hyperb1, hyperDelegate, hyphenationc, i-justuser-i, iaada, iacore, IamVelcroboy, Ian321, icekot8, icesickleone, iczero, iglov, IgorAnt028, igorsaux, ike709, illersaver, Illiux, Ilushkins33, Ilya246, IlyaElDunaev, imatsoup, IMCB, impubbi, imrenq, imweax, indeano, Injazz, Insineer, insoPL, IntegerTempest, Interrobang01, Intoxicating-Innocence, IProduceWidgets, itsmethom, Itzbenz, iztokbajcar, Jackal298, Jackrost, JackRyd3r, jacksonzck, JackspajfMain, Jacktastic09, Jackw2As, jacob, jamessimo, janekvap-havok, Jark255, Jarmer123, Jaskanbe, JasperJRoth, jbox144, JCGWE30, JerryImMouse, jerryimmouse, Jessetriesagain, jessicamaybe, JesterX666, Jewelots, Jezithyr, jicksaw, JiimBob, JimGamemaster, jimmy12or, JIPDawg, jjtParadox, jkwookee, jmcb, JohnGinnane, johnjjohn, johnku1, Jophire, Jopogrechkin, joshepvodka, JpegOfAFrog, jproads, JrInventor05, Jrpl, jukereise, juliangiebel, JustArt1m, JustCone14, justdie12, justin, justintether, JustinTrotter, JustinWinningham, justtne, K-Dynamic, k3yw, Kadeo64, Kaga-404, kaiserbirch, KaiShibaa, kalane15, kalanosh, KamTheSythe, Kanashi-Panda, katzenminer, kbailey-git, Keelin, Keer-Sar, KEEYNy, keikiru, Kelrak, kerisargit, keronshb, KIBORG04, KieueCaprie, Kimpes, kin98, KingFroozy, kipdotnet, kira-er, kiri-yoshikage, Kirillcas, Kirus59, Kistras, Kit, Kit0vras, KittenColony, Kittygyat, klaypexx, kleinerstation13, Kmc2000, Ko4ergaPunk, kognise, kokoc9n, komunre, KonstantinAngelov, kontakt, korczoczek, kosticia, koteq, kotobdev, Kowlin, KrasnoshchekovPavel, Krosus777, Krunklehorn, Kryyto, Kupie, kxvvv, Kyoth25f, kyupolaris, kzhanik, LaCumbiaDelCoronavirus, lajolico, Lamrr, lanedon, LankLTE, laok233, lapatison, larryrussian, lawdog4817, Lazzi0706, leah, leander-0, leonardo-dabepis, leonidussaks, leonsfriedrich, LeoSantich, lettern, LetterN, Level10Cybermancer, LEVELcat, lever1209, LevitatingTree, Lgibb18, lgruthes, LightVillet, lilazero, liltenhead, linkbro1, linkuyx, Litraxx, little-meow-meow, LittleBuilderJane, LittleNorthStar, LittleNyanCat, lizelive, ljm862, lmsnoise, localcc, lokachop, lolman360, Lomcastar, Lordbrandon12, LordCarve, LordEclipse, lucas, LucasTheDrgn, luckyshotpictures, LudwigVonChesterfield, luegamer, luizwritescode, LukaSlade, luminight, lunarcomets, Lusatia, Luxeator, lvvova1, Lyndomen, lyroth001, lzimann, lzk228, M1tht1c, M3739, M4rchy-S, M87S, mac6na6na, MACMAN2003, Macoron, magicalus, magmodius, magnuscrowe, maland1, malchanceux, MaloTV, manelnavola, ManelNavola, Mangohydra, marboww, Markek1, MarkerWicker, marlyn, matt, Matz05, max, MaxNox7, maylokana, MDuch369, meara1179, meganerobot, MehimoNemo, Mehnix, MeltedPixel, memeproof, MendaxxDev, Menshin, Mephisto72, MerrytheManokit, Mervill, metalgearsloth, MetalSage, MFMessage, mhamsterr, michaelcu, micheel665, mifia, mikeysaurus, MilenVolf, MilonPL, Minemoder5000, Minty642, minus1over12, Mirino97, mirrorcult, misandrie, MishaUnity, MissKay1994, MisterImp, MisterMecky, Mith-randalf, Mixelz, mjarduk, MjrLandWhale, mkanke-real, MLGTASTICa, mnva0, moderatelyaware, modern-nm, mohamedwidar, mokiros, momo, Moneyl, monotheonist, Moomoobeef, moony, Morb0, MossyGreySlope, mqole, mr-bo-jangles, Mr0maks, MrFippik, MrPersival, mrrobdemo, mtrs163, muburu, MureixloI, murolem, murphyneko, musicmanvr, MWKane, Myakot, Myctai, N3X15, nabegator, nails-n-tape, Nairodian, Naive817, NakataRin, namespace-Memory, Nannek, NazrinNya, neborsh, neomoth, neutrino-laser, NickPowers43, nikitosych, nikthechampiongr, Nimfar11, ninruB, Nirnael, NIXC, nkokic, NkoKirkto, nmajask, noctyrnal, noelkathegod, noirogen, nok-ko, NonchalantNoob, NoobyLegion, Nopey, NoreUhh, not-gavnaed, notafet, notquitehadouken, notsodana, noudoit, noverd, Nox38, NuclearWinter, Nuggets219, nukashimika, nuke-haus, NULL882, nullarmo, nyeogmi, Nylux, Nyranu, Nyxilath, och-och, OctoRocket, OldDanceJacket, OliverOtter, onesch, OneZerooo0, OnsenCapy, OnyxTheBrave, opl-, Orange-Winds, OrangeMoronage9622, OrbitSystem07, Orsoniks, osjarw, Ostaf, othymer, OttoMaticode, Owai-Seek, packmore, PAFFhassoocks, paige404, paigemaeforrest, pali6, Palladinium, Pangogie, panzer-iv1, partyaddict, patrikturi, PaulRitter, pavlockblaine03, peccneck, Peptide90, peptron1, perryprog, PeterFuto, PetMudstone, pewter-wiz, PGrayCS, pgraycs, Pgriha, phantom-lily, pheenty, philingham, Phill101, Phooooooooooooooooooooooooooooooosphate, phunnyguy, PicklOH, PilgrimViis, Pill-U, pinkbat5, Piras314, Pireax, Pissachu, pissdemon, Pixel8-dev, PixeltheAertistContrib, PixelTheKermit, PJB3005, Plasmaguy, plinyvic, Plykiya, poeMota, pofitlo, pointer-to-null, Pok27, poklj, PolterTzi, PoorMansDreams, PopGamer45, portfiend, potato1234x, PotentiallyTom, PotRoastPiggy, Princess-Cheeseballs, ProfanedBane, Prole0, ProPandaBear, PrPleGoo, ps3moira, Pspritechologist, Psychpsyo, psykana, psykzz, PuceTint, pumkin69, PuroSlavKing, PursuitInAshes, Putnam3145, py01, Pyrovi, qrtDaniil, qrwas, Quantum-cross, quasr-9, quatre, QueerNB, QuietlyWhisper, qwerltaz, Radezolid, RadioMull, Radosvik, Radrark, Rainbeon, Rainfey, Raitononai, Ramlik, RamZ, randy10122, Rane, Ranger6012, Rapidgame7, ravage123321, rbertoche, RedBookcase, Redfire1331, Redict, RedlineTriad, redmushie, RednoWCirabrab, redspyy, ReeZer2, RemberBM, RemieRichards, RemTim, rene-descartes2021, Renlou, retequizzle, rewafflution, rhailrake, rhsvenson, rich-dunne, RieBi, riggleprime, RIKELOLDABOSS, rinary1, Rinkashikachi, riolume, rlebell33, RobbyTheFish, robinthedragon, Rockdtben, Rohesie, rok-povsic, rokudara-sen, rolfero, RomanNovo, rosieposieeee, Roudenn, router, ruddygreat, rumaks-xyz, RumiTiger, Ruzihm, rwrv, S1rFl0, S1ss3l, Saakra, SabreML, Sadie-silly, saga3152, saintmuntzer, Salex08, sam, samgithubaccount, Samuka-C, SaphireLattice, SapphicOverload, sarahon, sativaleanne, SaveliyM360, sBasalto, ScalyChimp, ScarKy0, ScholarNZL, schrodinger71, scrato, Scribbles0, scrivoy, scruq445, scuffedjays, ScumbagDog, SeamLesss, Segonist, semensponge, sephtasm, ser1-1y, Serkket, sewerpig, SG6732, sh18rw, Shaddap1, ShadeAware, ShadowCommander, shadowtheprotogen546, shaeone, shampunj, shariathotpatrol, SharkSnake98, shibechef, Siginanto, signalsender, SignalWalker, siigiil, silicon14wastaken, Silverfur-underscore, Simyon264, sirdragooon, Sirionaut, SirWarock, Sk1tch, SkaldetSkaeg, Skarletto, skeeka-dev, skrybl, Skybailey-dev, skye, Skyedra, SlamBamActionman, slarticodefast, Slava0135, sleepyyapril, slimmslamm, Slyfox333, Smugman, SnappingOpossum, snebl, snicket, sniperchance, Snowni, snowsignal, SolidSyn, SolidusSnek, solstar2, SomegnihT, SonarZeBat, SonicHDC, SoulFN, SoulSloth, Soundwavesghost, soupkilove, southbridge-fur, sowelipililimute, Soydium, SpaceLizard24, SpaceLizardSky, SpaceManiac, SpaceRox1244, SpaceyLady, Spangs04, spanky-spanky, Sparlight, spartak, SpartanKadence, spderman3333, SpeltIncorrectyl, Spessmann, SphiraI, SplinterGP, spoogemonster, sporekto, sporkyz, ssdaniel24, stalengd, stanberytrask, Stanislav4ix, StanTheCarpenter, starbuckss14, Stealthbomber16, steel, Steffo99, stellar-novas, stewie523, stomf, Stop-Signs, stopbreaking, stopka-html, StrawberryMoses, Stray-Pyramid, strO0pwafel, Strol20, StStevens, Subversionary, sunbear-dev, SuperGDPWYL, superjj18, Supernorn, SurrealShibe, SweetAplle, SweptWasTaken, SyaoranFox, Sybil, SYNCHRONIC, Szunti, t, Tainakov, takemysoult, taonewt, tap, TaralGit, Taran, taurie, Tayrtahn, tday93, teamaki, TeenSarlacc, TekuNut, telavivgamers, telyonok, temm1ie, TemporalOroboros, tentekal, terezi4real, Terraspark4941, texcruize, Tezzaide, TGODiamond, TGRCdev, tgrkzus, thanosdegraf, ThatGuyUSA, ThatOneGoblin25, thatrandomcanadianguy, TheArturZh, TheBlueYowie, thecopbennet, TheCze, TheDarkElites, thedraccx, TheEmber, theexetron, TheFlyingSentry, thefoty, TheGrimbeeper, TheIntoxicatedCat, thekilk, themias, theomund, TheProNoob678, TherapyGoth, ThereDrD0, TheSecondLord, TheShuEd, thetolbean, thevinter, TheWaffleJesus, thinbug0, ThunderBear2006, timothyteakettle, TimrodDX, timurjavid, tin-man-tim, TiniestShark, Titian3, tk-a369, tkdrg, tmtmtl30, ToastEnjoyer, Toby222, TokenStyle, Tollhouse, Toly65, tom-leys, tomasalves8, Tomeno, Tonydatguy, topy, tornado-technology, TornadoTechnology, tosatur, TotallyLemon, ToxicSonicFan04, Tr1bute, travis-g-reid, treytipton, TriviaSolari, trixxedbit, TrixxedHeart, tropicalhibi, truepaintgit, Truoizys, Tryded, TsjipTsjip, tuchila-adi-bogdan, Tunguso4ka, TurboTrackerss14, TVK-04, tyashley, Tyler-IN, TytosB, Tyzemol, UbaserB, Uberration, ubis1, UBlueberry, uhbg, UKNOWH, UltimateJester, Unbelievable-Salmon, underscorex5, UnicornOnLSD, Unisol, Unkn0wnGh0st333, unusualcrow, Uriende, UristMcDorf, user424242420, Utmanarn, Vaaankas, valentfingerov, valquaint, Varen, Vasilis, VasilisThePikachu, veliebm, Velken, VelonacepsCalyxEggs, veprolet, VerinSenpai, veritable-calamity, Veritius, Vermidia, vero5123, verslebas, vexerot, vgskye, viceemargo, VigersRay, violet754, Visne, vitopigno, vitusveit, vlad, vlados1408, VMSolidus, vmzd, VoidMeticulous, voidnull000, volotomite, volundr-, Voomra, Vordenburg, vorkathbruh, Vortebo, vulppine, wachte1, wafehling, walksanatora, Warentan, WarMechanic, Watermelon914, weaversam8, wertanchik, whateverusername0, whatston3, widgetbeck, Will-Oliver-Br, Willhelm53, WilliamECrew, willicassi, Winkarst-cpu, wirdal, wixoaGit, WlarusFromDaSpace, Wolfkey-SomeoneElseTookMyUsername, Worldwaker, wrexbe, wtcwr68, xeri7, xkreksx, xprospero, xRiriq, xsainteer, YanehCheck, yathxyz, Ygg01, YotaXP, youarereadingthis, YoungThugSS14, Yousifb26, youtissoum, yunii, YuriyKiss, yuriykiss, zach-hill, Zadeon, Zalycon, zamp, Zandario, Zap527, Zealith-Gamer, ZelteHonor, zero, ZeroDiamond, ZeWaka, zHonys, zionnBE, ZNixian, Zokkie, ZoldorfTheWizard, zonespace27, Zylofan, Zymem, zzylex From 1682ad243fff96282cd3b3d91a19299140838e8d Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Sun, 25 Jan 2026 20:09:24 +0100 Subject: [PATCH 15/47] Update RT to 271.2.0 (#42646) --- Content.Server/Entry/EntryPoint.cs | 3 +++ RobustToolbox | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Content.Server/Entry/EntryPoint.cs b/Content.Server/Entry/EntryPoint.cs index 4d22dfbeb0..d6095d9994 100644 --- a/Content.Server/Entry/EntryPoint.cs +++ b/Content.Server/Entry/EntryPoint.cs @@ -32,6 +32,7 @@ using Robust.Server.ServerStatus; using Robust.Shared.Configuration; using Robust.Shared.ContentPack; using Robust.Shared.Prototypes; +using Robust.Shared.Serialization; using Robust.Shared.Timing; using Robust.Shared.Utility; @@ -88,6 +89,8 @@ namespace Content.Server.Entry var cast = (ServerModuleTestingCallbacks)callback; cast.ServerBeforeIoC?.Invoke(); } + + Dependencies.Resolve().FloatFlags = SerializerFloatFlags.RemoveReadNan; } /// diff --git a/RobustToolbox b/RobustToolbox index 40b10f0dcc..0b93a1b7e2 160000 --- a/RobustToolbox +++ b/RobustToolbox @@ -1 +1 @@ -Subproject commit 40b10f0dccfe568ccd7dc3c6f6ee87a63bad97ee +Subproject commit 0b93a1b7e20acaa4540061d084bf750dbcb1ae46 From ae5f8d0a6c77b736917c9eed261e254dfc26b777 Mon Sep 17 00:00:00 2001 From: B_Kirill <153602297+B-Kirill@users.noreply.github.com> Date: Mon, 26 Jan 2026 07:59:50 +1000 Subject: [PATCH 16/47] Fix emergency shuttle authorization bypass via ID rename (#42640) --- .../Systems/EmergencyShuttleSystem.Console.cs | 12 +++++++----- .../Components/EmergencyShuttleConsoleComponent.cs | 6 +++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/Content.Server/Shuttles/Systems/EmergencyShuttleSystem.Console.cs b/Content.Server/Shuttles/Systems/EmergencyShuttleSystem.Console.cs index c8cdf17d30..95d638dec7 100644 --- a/Content.Server/Shuttles/Systems/EmergencyShuttleSystem.Console.cs +++ b/Content.Server/Shuttles/Systems/EmergencyShuttleSystem.Console.cs @@ -260,8 +260,7 @@ public sealed partial class EmergencyShuttleSystem return; } - // TODO: This is fucking bad - if (!component.AuthorizedEntities.Remove(MetaData(idCard).EntityName)) + if (!component.AuthorizedEntities.Remove(idCard.Owner)) return; _logger.Add(LogType.EmergencyShuttle, LogImpact.High, $"Emergency shuttle early launch REPEAL by {args.Actor:user}"); @@ -281,10 +280,13 @@ public sealed partial class EmergencyShuttleSystem return; } - // TODO: This is fucking bad - if (!component.AuthorizedEntities.Add(MetaData(idCard).EntityName)) + var idCardUid = idCard.Owner; + + if (component.AuthorizedEntities.ContainsKey(idCardUid)) return; + component.AuthorizedEntities[idCardUid] = MetaData(idCard).EntityName; + _logger.Add(LogType.EmergencyShuttle, LogImpact.High, $"Emergency shuttle early launch AUTH by {args.Actor:user}"); var remaining = component.AuthorizationsRequired - component.AuthorizedEntities.Count; @@ -327,7 +329,7 @@ public sealed partial class EmergencyShuttleSystem { var auths = new List(); - foreach (var auth in component.AuthorizedEntities) + foreach (var auth in component.AuthorizedEntities.Values) { auths.Add(auth); } diff --git a/Content.Shared/Shuttles/Components/EmergencyShuttleConsoleComponent.cs b/Content.Shared/Shuttles/Components/EmergencyShuttleConsoleComponent.cs index d851056208..bec9b09466 100644 --- a/Content.Shared/Shuttles/Components/EmergencyShuttleConsoleComponent.cs +++ b/Content.Shared/Shuttles/Components/EmergencyShuttleConsoleComponent.cs @@ -5,13 +5,13 @@ namespace Content.Shared.Shuttles.Components; [RegisterComponent, NetworkedComponent] public sealed partial class EmergencyShuttleConsoleComponent : Component { - // TODO: Okay doing it by string is kinda suss but also ID card tracking doesn't seem to be robust enough - /// /// ID cards that have been used to authorize an early launch. + /// Key is the UID of the ID card, + /// value is the card's name at the time of authorization. /// [ViewVariables(VVAccess.ReadWrite), DataField("authorized")] - public HashSet AuthorizedEntities = new(); + public Dictionary AuthorizedEntities = new(); [ViewVariables(VVAccess.ReadWrite), DataField("authorizationsRequired")] public int AuthorizationsRequired = 3; From 6daca9bd9665bc4e85c7dfe78cf9732f7f055f9e Mon Sep 17 00:00:00 2001 From: B_Kirill <153602297+B-Kirill@users.noreply.github.com> Date: Mon, 26 Jan 2026 08:06:03 +1000 Subject: [PATCH 17/47] GasLeak and PowerGridCheck rules components cleanup (#42624) --- .../Components/GasLeakRuleComponent.cs | 85 ++++++++++++++++--- .../Components/PowerGridCheckRuleComponent.cs | 42 ++++++++- 2 files changed, 110 insertions(+), 17 deletions(-) diff --git a/Content.Server/StationEvents/Components/GasLeakRuleComponent.cs b/Content.Server/StationEvents/Components/GasLeakRuleComponent.cs index 171aca12c2..22164b9c9c 100644 --- a/Content.Server/StationEvents/Components/GasLeakRuleComponent.cs +++ b/Content.Server/StationEvents/Components/GasLeakRuleComponent.cs @@ -7,41 +7,100 @@ namespace Content.Server.StationEvents.Components; [RegisterComponent, Access(typeof(GasLeakRule))] public sealed partial class GasLeakRuleComponent : Component { - public readonly Gas[] LeakableGases = + /// + /// Gas types that can be selected for the leak event. + /// + [DataField] + public Gas[] LeakableGases = { Gas.Ammonia, Gas.Plasma, Gas.Tritium, Gas.Frezon, - Gas.WaterVapor, // the fog + Gas.WaterVapor, }; /// - /// Running cooldown of how much time until another leak. + /// Time remaining until the next gas addition to the leak tile. /// + [DataField] public float TimeUntilLeak; /// - /// How long between more gas being added to the tile. + /// Fixed interval in seconds between gas additions to the leak tile. /// + [DataField] public float LeakCooldown = 1.0f; - // Event variables + /// + /// The station where the leak is located. + /// + [DataField] public EntityUid TargetStation; - public EntityUid TargetGrid; - public Vector2i TargetTile; - public EntityCoordinates TargetCoords; - public bool FoundTile; - public Gas LeakGas; - public float MolesPerSecond; - public readonly int MinimumMolesPerSecond = 80; /// - /// Don't want to make it too fast to give people time to flee. + /// The specific grid where the leak is located. /// + [DataField] + public EntityUid TargetGrid; + + /// + /// The tile coordinates where the leak is located. + /// + [DataField] + public Vector2i TargetTile; + + /// + /// The world coordinates of the leak location. + /// + [DataField] + public EntityCoordinates TargetCoords; + + /// + /// Whether a suitable tile for leaking has been found. + /// + [DataField] + public bool FoundTile; + + /// + /// The specific gas type currently leaking. + /// + [DataField] + public Gas LeakGas; + + /// + /// Current leak rate in moles per second. + /// + [DataField] + public float MolesPerSecond; + + /// + /// Minimum leak rate in moles per second. + /// + [DataField] + public int MinimumMolesPerSecond = 80; + + /// + /// Maximum leak rate in moles per second. Limited to give people time to flee. + /// + [DataField] public int MaximumMolesPerSecond = 200; + /// + /// Minimum total amount of gas to leak over the entire event duration. + /// + [DataField] public int MinimumGas = 1000; + + /// + /// Maximum total amount of gas to leak over the entire event duration. + /// + [DataField] public int MaximumGas = 4000; + + /// + /// Chance to create an ignition spark when the event ends. + /// + [DataField] public float SparkChance = 0.05f; } diff --git a/Content.Server/StationEvents/Components/PowerGridCheckRuleComponent.cs b/Content.Server/StationEvents/Components/PowerGridCheckRuleComponent.cs index 757a4f18b5..fcf0d483ab 100644 --- a/Content.Server/StationEvents/Components/PowerGridCheckRuleComponent.cs +++ b/Content.Server/StationEvents/Components/PowerGridCheckRuleComponent.cs @@ -9,25 +9,59 @@ namespace Content.Server.StationEvents.Components; public sealed partial class PowerGridCheckRuleComponent : Component { /// - /// Default sound of the announcement when power is back on. + /// Default sound for power restoration announcement. /// private static readonly ProtoId DefaultPowerOn = new("PowerOn"); /// - /// Sound of the announcement to play when power is back on. + /// Sound to play when power is restored. /// [DataField] public SoundSpecifier PowerOnSound = new SoundCollectionSpecifier(DefaultPowerOn, AudioParams.Default.WithVolume(-4f)); + /// + /// Token source for cancelling the power restoration announcement. + /// public CancellationTokenSource? AnnounceCancelToken; + /// + /// Station affected by the power grid event. + /// + [DataField] public EntityUid AffectedStation; - public readonly List Powered = new(); - public readonly List Unpowered = new(); + /// + /// List of APC entities that will be sequentially turned off during the event. + /// + [DataField] + public List Powered = new(); + + /// + /// List of APC entities that have been turned off. + /// + [DataField] + public List Unpowered = new(); + + /// + /// Time delay in seconds before starting to turn off APCs. + /// + [DataField] public float SecondsUntilOff = 30.0f; + /// + /// Number of APC toggles to process per second during the shutdown phase. + /// Dynamically calculated based on total APC count and . + /// public int NumberPerSecond = 0; + + /// + /// Computed time interval between APC toggles. + /// public float UpdateRate => 1.0f / NumberPerSecond; + + /// + /// Accumulated frame time to track when to process the next APC toggle. + /// + [DataField] public float FrameTimeAccumulator = 0.0f; } From f6a06db1fc47324d626bb4e3e225286b529af854 Mon Sep 17 00:00:00 2001 From: InsoPL Date: Sun, 25 Jan 2026 23:11:00 +0100 Subject: [PATCH 18/47] Translation fix: insulated verbs (#42617) * feat * only clothing * fix * Revert "fix" This reverts commit 858c3f882718cd003391e0fde6b2711b31e6ab54. * fix * reversal * removed double namespace * ehhh * wrong robust toolbox * typo --- Content.Shared/Electrocution/InsulatedSystem.cs | 4 ++-- .../Locale/en-US/clothing/components/insulated-component.ftl | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Content.Shared/Electrocution/InsulatedSystem.cs b/Content.Shared/Electrocution/InsulatedSystem.cs index 7cd4913f7d..1e6ec6a149 100644 --- a/Content.Shared/Electrocution/InsulatedSystem.cs +++ b/Content.Shared/Electrocution/InsulatedSystem.cs @@ -25,8 +25,8 @@ public sealed class InsulatedSystem : EntitySystem _examine.AddHoverExamineVerb(args, component, - Loc.GetString("identity-block-examinable-verb-text"), - Loc.GetString("identity-block-examinable-verb-text-message"), + Loc.GetString("insulated-examinable-verb-text"), + Loc.GetString("insulated-examinable-verb-text-message"), iconTexture ); } diff --git a/Resources/Locale/en-US/clothing/components/insulated-component.ftl b/Resources/Locale/en-US/clothing/components/insulated-component.ftl index 30c7e98a52..feecb0ded5 100644 --- a/Resources/Locale/en-US/clothing/components/insulated-component.ftl +++ b/Resources/Locale/en-US/clothing/components/insulated-component.ftl @@ -1,2 +1,2 @@ -identity-block-examinable-verb-text = Insulatated -identity-block-examinable-verb-text-message = This item appears to be electrically insulated. It should protect the wearer from shocks. +insulated-examinable-verb-text = Insulated +insulated-examinable-verb-text-message = This item appears to be electrically insulated. It should protect the wearer from shocks. From 87895770856245557d9d55b9dad08cf6aab67f96 Mon Sep 17 00:00:00 2001 From: SlamBamActionman <83650252+SlamBamActionman@users.noreply.github.com> Date: Sun, 25 Jan 2026 23:15:33 +0100 Subject: [PATCH 19/47] Add an option for hold-to-attack in settings (#42596) * Initial commit * Separate ranged and melee * Move to controls tab --- .../Options/UI/Tabs/AccessibilityTab.xaml | 4 ++-- .../Options/UI/Tabs/KeyRebindTab.xaml.cs | 23 +++++++++++++------ .../Weapons/Melee/MeleeWeaponSystem.cs | 5 +++- .../Weapons/Ranged/Systems/GunSystem.cs | 5 ++++ Content.Shared/CCVar/CCVars.Interactions.cs | 13 +++++++++++ .../Ranged/Events/RequestShootEvent.cs | 17 ++++++++++++++ .../Weapons/Ranged/Systems/SharedGunSystem.cs | 2 ++ .../en-US/escape-menu/ui/options-menu.ftl | 3 +++ 8 files changed, 62 insertions(+), 10 deletions(-) diff --git a/Content.Client/Options/UI/Tabs/AccessibilityTab.xaml b/Content.Client/Options/UI/Tabs/AccessibilityTab.xaml index 41fac83c59..72df18390f 100644 --- a/Content.Client/Options/UI/Tabs/AccessibilityTab.xaml +++ b/Content.Client/Options/UI/Tabs/AccessibilityTab.xaml @@ -15,8 +15,8 @@ - public static readonly CVarDef NestedStorage = CVarDef.Create("control.nested_storage", true, CVar.REPLICATED | CVar.SERVER); + + /// + /// If enabled, melee weapons that have click-to-attack patterns (unarmed, slow weapons) will continue attacking if the button is held. + /// + public static readonly CVarDef ControlHoldToAttackMelee = + CVarDef.Create("control.hold_to_attack_melee", false, CVar.CLIENTONLY | CVar.ARCHIVE); + + /// + /// If enabled, ranged weapons that have click-to-attack patterns (burst and semi-auto guns) will continue attacking if the button is held. + /// + public static readonly CVarDef ControlHoldToAttackRanged = + CVarDef.Create("control.hold_to_attack_ranged", false, CVar.CLIENTONLY | CVar.ARCHIVE); + } diff --git a/Content.Shared/Weapons/Ranged/Events/RequestShootEvent.cs b/Content.Shared/Weapons/Ranged/Events/RequestShootEvent.cs index f5c4dd72b4..7e521f0bae 100644 --- a/Content.Shared/Weapons/Ranged/Events/RequestShootEvent.cs +++ b/Content.Shared/Weapons/Ranged/Events/RequestShootEvent.cs @@ -9,7 +9,24 @@ namespace Content.Shared.Weapons.Ranged.Events; [Serializable, NetSerializable] public sealed class RequestShootEvent : EntityEventArgs { + /// + /// The gun shooting. + /// public NetEntity Gun; + + /// + /// The location the player is shooting at. + /// public NetCoordinates Coordinates; + + /// + /// The target the player is shooting at, if any. + /// public NetEntity? Target; + + /// + /// If the client wants to continuously shoot. + /// If true, the gun will continue firing until a stop event is sent from the client. + /// + public bool Continuous; } diff --git a/Content.Shared/Weapons/Ranged/Systems/SharedGunSystem.cs b/Content.Shared/Weapons/Ranged/Systems/SharedGunSystem.cs index e79b26f89d..f5ccb4ae51 100644 --- a/Content.Shared/Weapons/Ranged/Systems/SharedGunSystem.cs +++ b/Content.Shared/Weapons/Ranged/Systems/SharedGunSystem.cs @@ -158,6 +158,8 @@ public abstract partial class SharedGunSystem : EntitySystem gun.Comp.ShootCoordinates = GetCoordinates(msg.Coordinates); gun.Comp.Target = GetEntity(msg.Target); AttemptShoot(user.Value, gun); + if (msg.Continuous) + gun.Comp.ShotCounter = 0; } private void OnStopShootRequest(RequestStopShootEvent ev, EntitySessionEventArgs args) diff --git a/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl b/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl index 7c13f01952..489a346f2a 100644 --- a/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl +++ b/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl @@ -108,6 +108,9 @@ ui-options-hud-layout = HUD layout: ## Controls menu +ui-options-hold-to-attack-melee = Hold to attack (melee) +ui-options-hold-to-attack-ranged = Hold to attack (ranged) + ui-options-binds-reset-all = Reset ALL keybinds ui-options-binds-explanation = Click to change binding, right-click to clear ui-options-unbound = Unbound From f94e809f5387f88b0410052d404ef2f25ab1a0d7 Mon Sep 17 00:00:00 2001 From: PJBot Date: Sun, 25 Jan 2026 22:32:03 +0000 Subject: [PATCH 20/47] Automatic changelog update --- Resources/Changelog/Changelog.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Resources/Changelog/Changelog.yml b/Resources/Changelog/Changelog.yml index d616065c0c..4f56db7423 100644 --- a/Resources/Changelog/Changelog.yml +++ b/Resources/Changelog/Changelog.yml @@ -1,11 +1,4 @@ Entries: -- author: ToastEnjoyer - changes: - - message: Fixed the laser carbine not being labeled as contraband - type: Fix - id: 8951 - time: '2025-09-11T02:37:10.0000000+00:00' - url: https://github.com/space-wizards/space-station-14/pull/40253 - author: IProduceWidgets changes: - message: Tennis balls! Found in arcade machines, maintenance and the cargo toy @@ -4009,3 +4002,10 @@ id: 9457 time: '2026-01-24T21:06:28.0000000+00:00' url: https://github.com/space-wizards/space-station-14/pull/42630 +- author: SlamBamActionman + changes: + - message: Added an toggle for "hold-to-attack" under the accessibility tab. + type: Add + id: 9458 + time: '2026-01-25T22:30:52.0000000+00:00' + url: https://github.com/space-wizards/space-station-14/pull/42596 From e6dedb87a64a91d47e01c633cfffdb33c607b629 Mon Sep 17 00:00:00 2001 From: Connor Huffine Date: Sun, 25 Jan 2026 17:17:44 -0500 Subject: [PATCH 21/47] Add EditorHidden member to ContentTileDefinition (#42564) Add EditorHidden member --- Content.Shared/Maps/ContentTileDefinition.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Content.Shared/Maps/ContentTileDefinition.cs b/Content.Shared/Maps/ContentTileDefinition.cs index 672eb95911..1473c03067 100644 --- a/Content.Shared/Maps/ContentTileDefinition.cs +++ b/Content.Shared/Maps/ContentTileDefinition.cs @@ -129,6 +129,11 @@ namespace Content.Shared.Maps /// [DataField("indestructible")] public bool Indestructible = false; + /// + /// Hide this tile in the tile placement editor. + /// + [DataField] public bool EditorHidden { get; private set; } = false; + public void AssignTileId(ushort id) { TileId = id; From 11ac6966d9812e6460b1ba31c5c75e987ba4434d Mon Sep 17 00:00:00 2001 From: Winkarst-cpu <74284083+Winkarst-cpu@users.noreply.github.com> Date: Mon, 26 Jan 2026 01:22:12 +0300 Subject: [PATCH 22/47] Fix: Make votes force select maps (#42426) * Fix * Add safe guard --- Content.Server/Voting/Managers/VoteManager.DefaultVotes.cs | 7 ++++++- Resources/Locale/en-US/voting/managers/vote-manager.ftl | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Content.Server/Voting/Managers/VoteManager.DefaultVotes.cs b/Content.Server/Voting/Managers/VoteManager.DefaultVotes.cs index a34c90c363..aba5011391 100644 --- a/Content.Server/Voting/Managers/VoteManager.DefaultVotes.cs +++ b/Content.Server/Voting/Managers/VoteManager.DefaultVotes.cs @@ -306,10 +306,15 @@ namespace Content.Server.Voting.Managers var ticker = _entityManager.EntitySysManager.GetEntitySystem(); if (ticker.CanUpdateMap()) { - if (_gameMapManager.TrySelectMapIfEligible(picked.ID)) + if (_gameMapManager.CheckMapExists(picked.ID)) { + _gameMapManager.SelectMap(picked.ID); ticker.UpdateInfoText(); } + else + { + _chatManager.DispatchServerAnnouncement(Loc.GetString("ui-vote-map-invalid", ("winner", maps[picked]))); + } } else { diff --git a/Resources/Locale/en-US/voting/managers/vote-manager.ftl b/Resources/Locale/en-US/voting/managers/vote-manager.ftl index 7fd534db30..98ce42bdd6 100644 --- a/Resources/Locale/en-US/voting/managers/vote-manager.ftl +++ b/Resources/Locale/en-US/voting/managers/vote-manager.ftl @@ -20,7 +20,7 @@ ui-vote-map-tie = Tie for map vote! Picking... { $picked } ui-vote-map-win = { $winner } won the map vote! ui-vote-map-notlobby = Voting for maps is only valid in the pre-round lobby! ui-vote-map-notlobby-time = Voting for maps is only valid in the pre-round lobby with { $time } remaining! - +ui-vote-map-invalid = { $winner } became invalid after the map vote! It will not be selected! # Votekick votes ui-vote-votekick-unknown-initiator = A player From 65b8aafed8aa87e4104efd9b4c726e1eed7fe0bc Mon Sep 17 00:00:00 2001 From: B_Kirill <153602297+B-Kirill@users.noreply.github.com> Date: Mon, 26 Jan 2026 08:25:06 +1000 Subject: [PATCH 23/47] Improve sandbox window toggle buttons state handling (#42281) --- .../Systems/Sandbox/SandboxUIController.cs | 15 ----------- .../Sandbox/Windows/SandboxWindow.xaml.cs | 27 ++++++++++++++++--- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/Content.Client/UserInterface/Systems/Sandbox/SandboxUIController.cs b/Content.Client/UserInterface/Systems/Sandbox/SandboxUIController.cs index 037cc99754..8315ad1ded 100644 --- a/Content.Client/UserInterface/Systems/Sandbox/SandboxUIController.cs +++ b/Content.Client/UserInterface/Systems/Sandbox/SandboxUIController.cs @@ -1,16 +1,12 @@ using System.Numerics; using Content.Client.Administration.Managers; using Content.Client.Gameplay; -using Content.Client.Markers; using Content.Client.Sandbox; -using Content.Client.SubFloor; using Content.Client.UserInterface.Controls; using Content.Client.UserInterface.Systems.DecalPlacer; using Content.Client.UserInterface.Systems.Sandbox.Windows; using Content.Shared.Input; using JetBrains.Annotations; -using Robust.Client.Debugging; -using Robust.Client.Graphics; using Robust.Client.Input; using Robust.Client.Player; using Robust.Client.UserInterface; @@ -30,14 +26,10 @@ namespace Content.Client.UserInterface.Systems.Sandbox; public sealed class SandboxUIController : UIController, IOnStateChanged, IOnSystemChanged { [Dependency] private readonly IConsoleHost _console = default!; - [Dependency] private readonly IEyeManager _eye = default!; [Dependency] private readonly IInputManager _input = default!; - [Dependency] private readonly ILightManager _light = default!; [Dependency] private readonly IClientAdminManager _admin = default!; [Dependency] private readonly IPlayerManager _player = default!; - [UISystemDependency] private readonly DebugPhysicsSystem _debugPhysics = default!; - [UISystemDependency] private readonly MarkerSystem _marker = default!; [UISystemDependency] private readonly SandboxSystem _sandbox = default!; private SandboxWindow? _window; @@ -117,13 +109,6 @@ public sealed class SandboxUIController : UIController, IOnStateChanged { SandboxButton!.Pressed = true; }; _window.OnClose += () => { SandboxButton!.Pressed = false; }; - // TODO: These need moving to opened so at least if they're not synced properly on open they work. - _window.ToggleLightButton.Pressed = !_light.Enabled; - _window.ToggleFovButton.Pressed = !_eye.CurrentEye.DrawFov; - _window.ToggleShadowsButton.Pressed = !_light.DrawShadows; - _window.ShowMarkersButton.Pressed = _marker.MarkersVisible; - _window.ShowBbButton.Pressed = (_debugPhysics.Flags & PhysicsDebugFlags.Shapes) != 0x0; - _window.AiOverlayButton.OnPressed += args => { var player = _player.LocalEntity; diff --git a/Content.Client/UserInterface/Systems/Sandbox/Windows/SandboxWindow.xaml.cs b/Content.Client/UserInterface/Systems/Sandbox/Windows/SandboxWindow.xaml.cs index 107ed7e7c6..efd71e8e7b 100644 --- a/Content.Client/UserInterface/Systems/Sandbox/Windows/SandboxWindow.xaml.cs +++ b/Content.Client/UserInterface/Systems/Sandbox/Windows/SandboxWindow.xaml.cs @@ -1,6 +1,11 @@ -using Content.Client.SubFloor; +using Content.Client.Markers; +using Content.Client.SubFloor; using Content.Client.Stylesheets; +using Content.Shared.Silicons.StationAi; using Robust.Client.AutoGenerated; +using Robust.Client.Debugging; +using Robust.Client.Graphics; +using Robust.Client.Player; using Robust.Client.UserInterface.CustomControls; using Robust.Client.UserInterface.XAML; @@ -10,17 +15,33 @@ namespace Content.Client.UserInterface.Systems.Sandbox.Windows; public sealed partial class SandboxWindow : DefaultWindow { [Dependency] private readonly IEntityManager _entManager = null!; + [Dependency] private readonly IEyeManager _eyeManager = null!; + [Dependency] private readonly ILightManager _lightManager = null!; + [Dependency] private readonly IPlayerManager _playerManager = null!; + private readonly DebugPhysicsSystem _debugPhysicsSystem; + private readonly MarkerSystem _markerSystem; + private readonly SubFloorHideSystem _subFloorSystem; public SandboxWindow() { RobustXamlLoader.Load(this); IoCManager.InjectDependencies(this); + + _debugPhysicsSystem = _entManager.System(); + _markerSystem = _entManager.System(); + _subFloorSystem = _entManager.System(); } protected override void Opened() { base.Opened(); - // Make sure state is up to date. - ToggleSubfloorButton.Pressed = _entManager.System().ShowAll; + + ToggleSubfloorButton.Pressed = _subFloorSystem.ShowAll; + ToggleLightButton.Pressed = !_lightManager.Enabled; + ToggleFovButton.Pressed = !_eyeManager.CurrentEye.DrawFov; + ToggleShadowsButton.Pressed = !_lightManager.DrawShadows; + ShowMarkersButton.Pressed = _markerSystem.MarkersVisible; + ShowBbButton.Pressed = (_debugPhysicsSystem.Flags & PhysicsDebugFlags.Shapes) != 0x0; + AiOverlayButton.Pressed = _playerManager.LocalEntity is { } player && _entManager.HasComponent(player); } } From 7b1ed2bd29eb797c594e9354747f5564d0138cfd Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Mon, 26 Jan 2026 00:02:57 +0100 Subject: [PATCH 24/47] Remove duplicate loc getstring calls (#42648) guh --- Content.Server/Actions/Commands/AddActionCommand.cs | 2 +- Content.Server/Actions/Commands/RemoveActionCommand.cs | 2 +- Content.Server/Forensics/Systems/ForensicsSystem.cs | 4 ++-- Content.Server/GameTicking/Commands/ForceMapCommand.cs | 2 +- Content.Server/Nuke/Commands/ToggleNukeCommand.cs | 2 +- Content.Server/Objectives/Commands/AddObjectiveCommand.cs | 4 ++-- Content.Server/Objectives/Commands/RemoveObjectiveCommand.cs | 2 +- Content.Server/StationEvents/Events/BreakerFlipRule.cs | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Content.Server/Actions/Commands/AddActionCommand.cs b/Content.Server/Actions/Commands/AddActionCommand.cs index 5b80c92aa9..10b53ad023 100644 --- a/Content.Server/Actions/Commands/AddActionCommand.cs +++ b/Content.Server/Actions/Commands/AddActionCommand.cs @@ -21,7 +21,7 @@ public sealed class AddActionCommand : LocalizedEntityCommands { if (args.Length != 2) { - shell.WriteError(Loc.GetString(Loc.GetString("cmd-addaction-invalid-args"))); + shell.WriteError(Loc.GetString("cmd-addaction-invalid-args")); return; } diff --git a/Content.Server/Actions/Commands/RemoveActionCommand.cs b/Content.Server/Actions/Commands/RemoveActionCommand.cs index 541f8f88f9..084a4b5de6 100644 --- a/Content.Server/Actions/Commands/RemoveActionCommand.cs +++ b/Content.Server/Actions/Commands/RemoveActionCommand.cs @@ -17,7 +17,7 @@ public sealed class RemoveActionCommand : LocalizedEntityCommands { if (args.Length != 2) { - shell.WriteError(Loc.GetString(Loc.GetString("cmd-rmaction-invalid-args"))); + shell.WriteError(Loc.GetString("cmd-rmaction-invalid-args")); return; } diff --git a/Content.Server/Forensics/Systems/ForensicsSystem.cs b/Content.Server/Forensics/Systems/ForensicsSystem.cs index 078ce44e2b..7d700df02d 100644 --- a/Content.Server/Forensics/Systems/ForensicsSystem.cs +++ b/Content.Server/Forensics/Systems/ForensicsSystem.cs @@ -197,8 +197,8 @@ namespace Content.Server.Forensics { Act = () => TryStartCleaning(entity, user, target), Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/VerbIcons/bubbles.svg.192dpi.png")), - Text = Loc.GetString(Loc.GetString("forensics-verb-text")), - Message = Loc.GetString(Loc.GetString("forensics-verb-message")), + Text = Loc.GetString("forensics-verb-text"), + Message = Loc.GetString("forensics-verb-message"), // This is important because if its true using the cleaning device will count as touching the object. DoContactInteraction = false }; diff --git a/Content.Server/GameTicking/Commands/ForceMapCommand.cs b/Content.Server/GameTicking/Commands/ForceMapCommand.cs index f8553a9244..ec42d97179 100644 --- a/Content.Server/GameTicking/Commands/ForceMapCommand.cs +++ b/Content.Server/GameTicking/Commands/ForceMapCommand.cs @@ -23,7 +23,7 @@ namespace Content.Server.GameTicking.Commands { if (args.Length != 1) { - shell.WriteLine(Loc.GetString(Loc.GetString($"shell-need-exactly-one-argument"))); + shell.WriteLine(Loc.GetString("shell-need-exactly-one-argument")); return; } diff --git a/Content.Server/Nuke/Commands/ToggleNukeCommand.cs b/Content.Server/Nuke/Commands/ToggleNukeCommand.cs index e499afb225..53bdbd646d 100644 --- a/Content.Server/Nuke/Commands/ToggleNukeCommand.cs +++ b/Content.Server/Nuke/Commands/ToggleNukeCommand.cs @@ -64,7 +64,7 @@ public sealed class ToggleNukeCommand : LocalizedCommands { if (args.Length == 1) { - return CompletionResult.FromHint(Loc.GetString(Loc.GetString("cmd-nukearm-1-help"))); + return CompletionResult.FromHint(Loc.GetString("cmd-nukearm-1-help")); } if (args.Length == 2) diff --git a/Content.Server/Objectives/Commands/AddObjectiveCommand.cs b/Content.Server/Objectives/Commands/AddObjectiveCommand.cs index 3eddd45208..e2f7229fca 100644 --- a/Content.Server/Objectives/Commands/AddObjectiveCommand.cs +++ b/Content.Server/Objectives/Commands/AddObjectiveCommand.cs @@ -24,7 +24,7 @@ public sealed class AddObjectiveCommand : LocalizedEntityCommands { if (args.Length != 2) { - shell.WriteError(Loc.GetString(Loc.GetString("cmd-addobjective-invalid-args"))); + shell.WriteError(Loc.GetString("cmd-addobjective-invalid-args")); return; } @@ -68,6 +68,6 @@ public sealed class AddObjectiveCommand : LocalizedEntityCommands return CompletionResult.FromHintOptions( _objectives.Objectives(), - Loc.GetString(Loc.GetString("cmd-add-objective-obj-completion"))); + Loc.GetString("cmd-add-objective-obj-completion")); } } diff --git a/Content.Server/Objectives/Commands/RemoveObjectiveCommand.cs b/Content.Server/Objectives/Commands/RemoveObjectiveCommand.cs index 4b300cd195..0624b79f3d 100644 --- a/Content.Server/Objectives/Commands/RemoveObjectiveCommand.cs +++ b/Content.Server/Objectives/Commands/RemoveObjectiveCommand.cs @@ -19,7 +19,7 @@ namespace Content.Server.Objectives.Commands { if (args.Length != 2) { - shell.WriteError(Loc.GetString(Loc.GetString("cmd-rmobjective-invalid-args"))); + shell.WriteError(Loc.GetString("cmd-rmobjective-invalid-args")); return; } diff --git a/Content.Server/StationEvents/Events/BreakerFlipRule.cs b/Content.Server/StationEvents/Events/BreakerFlipRule.cs index 8a25d40abb..3ab9fbd209 100644 --- a/Content.Server/StationEvents/Events/BreakerFlipRule.cs +++ b/Content.Server/StationEvents/Events/BreakerFlipRule.cs @@ -17,7 +17,7 @@ public sealed class BreakerFlipRule : StationEventSystem(uid, out var stationEvent)) return; - var str = Loc.GetString("station-event-breaker-flip-announcement", ("data", Loc.GetString(Loc.GetString($"random-sentience-event-data-{RobustRandom.Next(1, 6)}")))); + var str = Loc.GetString("station-event-breaker-flip-announcement", ("data", Loc.GetString($"random-sentience-event-data-{RobustRandom.Next(1, 6)}"))); stationEvent.StartAnnouncement = str; base.Added(uid, component, gameRule, args); From ab2cefaa7f72142a85fe1b9e0e818870c53e1cdf Mon Sep 17 00:00:00 2001 From: roryflowers Date: Sun, 25 Jan 2026 18:24:22 -0600 Subject: [PATCH 25/47] restore tritium fire energy to reenable maxcaps (#42641) Co-authored-by: Rory Flowers --- Content.Shared/Atmos/Atmospherics.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Content.Shared/Atmos/Atmospherics.cs b/Content.Shared/Atmos/Atmospherics.cs index 5539ccbac8..4f5c0939ea 100644 --- a/Content.Shared/Atmos/Atmospherics.cs +++ b/Content.Shared/Atmos/Atmospherics.cs @@ -217,7 +217,7 @@ namespace Content.Shared.Atmos /// /// Amount of heat released per mole of burnt hydrogen or tritium (hydrogen isotope) /// - public const float FireHydrogenEnergyReleased = 284e3f; // hydrogen is 284 kJ/mol + public const float FireHydrogenEnergyReleased = 284e4f; public const float FireMinimumTemperatureToExist = T0C + 100f; public const float FireMinimumTemperatureToSpread = T0C + 150f; public const float FireSpreadRadiosityScale = 0.85f; From 18bf23dc7a927b5a2efc835861674345571d4cb2 Mon Sep 17 00:00:00 2001 From: PJBot Date: Mon, 26 Jan 2026 00:41:04 +0000 Subject: [PATCH 26/47] Automatic changelog update --- Resources/Changelog/Changelog.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Resources/Changelog/Changelog.yml b/Resources/Changelog/Changelog.yml index 4f56db7423..e837e96c82 100644 --- a/Resources/Changelog/Changelog.yml +++ b/Resources/Changelog/Changelog.yml @@ -1,12 +1,4 @@ Entries: -- author: IProduceWidgets - changes: - - message: Tennis balls! Found in arcade machines, maintenance and the cargo toy - crate! - type: Add - id: 8952 - time: '2025-09-11T10:57:31.0000000+00:00' - url: https://github.com/space-wizards/space-station-14/pull/40143 - author: 5tickman changes: - message: Food and ingredient item sizes have been adjusted. @@ -4009,3 +4001,11 @@ id: 9458 time: '2026-01-25T22:30:52.0000000+00:00' url: https://github.com/space-wizards/space-station-14/pull/42596 +- author: roryflowers + changes: + - message: Tritium's thermal output is increased to its previous value, to reenable + maxcap explosives and make the TEG easier to run. + type: Fix + id: 9459 + time: '2026-01-26T00:39:55.0000000+00:00' + url: https://github.com/space-wizards/space-station-14/pull/42641 From 737889c22f4651d5aa1d3cee53d44218234604bd Mon Sep 17 00:00:00 2001 From: SlamBamActionman <83650252+SlamBamActionman@users.noreply.github.com> Date: Mon, 26 Jan 2026 02:20:22 +0100 Subject: [PATCH 27/47] Make crowbars consistent with 1x2 item storage (#42585) Like scratching an itch --- Resources/Prototypes/Entities/Objects/Tools/crowbars.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Resources/Prototypes/Entities/Objects/Tools/crowbars.yml b/Resources/Prototypes/Entities/Objects/Tools/crowbars.yml index d9f519d6d8..ec4896c066 100644 --- a/Resources/Prototypes/Entities/Objects/Tools/crowbars.yml +++ b/Resources/Prototypes/Entities/Objects/Tools/crowbars.yml @@ -39,9 +39,7 @@ - Belt - type: Item sprite: Objects/Tools/crowbar.rsi - size: Normal - shape: - - 0,0,0,1 + size: Small # Standard (grey) Crowbar - type: entity From 3afdaaaa5ae9f802b94b23b0b9a484f6fb495c2c Mon Sep 17 00:00:00 2001 From: PJBot Date: Mon, 26 Jan 2026 01:36:56 +0000 Subject: [PATCH 28/47] Automatic changelog update --- Resources/Changelog/Changelog.yml | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/Resources/Changelog/Changelog.yml b/Resources/Changelog/Changelog.yml index e837e96c82..fadef1ddc6 100644 --- a/Resources/Changelog/Changelog.yml +++ b/Resources/Changelog/Changelog.yml @@ -1,15 +1,4 @@ Entries: -- author: 5tickman - changes: - - message: Food and ingredient item sizes have been adjusted. - type: Tweak - - message: Mimes now start with a Nutribrick instead of a Baguette. - type: Tweak - - message: The combat bakery kit is now a 4x4 sized box. - type: Tweak - id: 8953 - time: '2025-09-11T11:37:25.0000000+00:00' - url: https://github.com/space-wizards/space-station-14/pull/39203 - author: aada changes: - message: Cups, bottles, mugs, and other drinks have had minor changes. Most are @@ -4009,3 +3998,11 @@ id: 9459 time: '2026-01-26T00:39:55.0000000+00:00' url: https://github.com/space-wizards/space-station-14/pull/42641 +- author: SlamBamActionman + changes: + - message: Crowbars are now consistent with other 1x2 items in terms of storage, + e.g. in pockets. + type: Tweak + id: 9460 + time: '2026-01-26T01:35:49.0000000+00:00' + url: https://github.com/space-wizards/space-station-14/pull/42585 From 044aa4c8dc4d28af3493f984964801c1c456a63b Mon Sep 17 00:00:00 2001 From: ScholarNZL Date: Mon, 26 Jan 2026 18:55:54 +1300 Subject: [PATCH 29/47] Add Part Assembly and Temprature Construction Validations for Dev builds (#41396) Resolves 43194; part ass. and temp con. validation --- .../Construction/ConstructionSystem.Interactions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Content.Server/Construction/ConstructionSystem.Interactions.cs b/Content.Server/Construction/ConstructionSystem.Interactions.cs index 77a1a63e02..f855d26f4f 100644 --- a/Content.Server/Construction/ConstructionSystem.Interactions.cs +++ b/Content.Server/Construction/ConstructionSystem.Interactions.cs @@ -410,7 +410,7 @@ namespace Content.Server.Construction if ((!temperatureChangeStep.MinTemperature.HasValue || temp >= temperatureChangeStep.MinTemperature.Value) && (!temperatureChangeStep.MaxTemperature.HasValue || temp <= temperatureChangeStep.MaxTemperature.Value)) { - return HandleResult.True; + return validation ? HandleResult.Validated : HandleResult.True; } return HandleResult.False; @@ -422,7 +422,7 @@ namespace Content.Server.Construction break; if (partAssemblyStep.Condition(uid, EntityManager)) - return HandleResult.True; + return validation ? HandleResult.Validated : HandleResult.True; return HandleResult.False; } From 202b844967de80b4aed8bfc6c536d2820d876383 Mon Sep 17 00:00:00 2001 From: PJBot Date: Mon, 26 Jan 2026 06:13:09 +0000 Subject: [PATCH 30/47] Automatic changelog update --- Resources/Changelog/Changelog.yml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Resources/Changelog/Changelog.yml b/Resources/Changelog/Changelog.yml index fadef1ddc6..a4639586a1 100644 --- a/Resources/Changelog/Changelog.yml +++ b/Resources/Changelog/Changelog.yml @@ -1,12 +1,4 @@ Entries: -- author: aada - changes: - - message: Cups, bottles, mugs, and other drinks have had minor changes. Most are - now destructible. - type: Tweak - id: 8954 - time: '2025-09-11T15:59:11.0000000+00:00' - url: https://github.com/space-wizards/space-station-14/pull/39221 - author: luegamer changes: - message: SmartFridge Circuitboards are now printable, SmartFridge destruction @@ -4006,3 +3998,10 @@ id: 9460 time: '2026-01-26T01:35:49.0000000+00:00' url: https://github.com/space-wizards/space-station-14/pull/42585 +- author: ScholarNZL + changes: + - message: Fixed a dev environment crash related to construction event validation. + type: Fix + id: 9461 + time: '2026-01-26T06:12:02.0000000+00:00' + url: https://github.com/space-wizards/space-station-14/pull/41396 From e4ce0a7fbe1fc73946db995cc85c272e6e0eb927 Mon Sep 17 00:00:00 2001 From: EchoOfNothing <52498373+EchoOfNothing@users.noreply.github.com> Date: Mon, 26 Jan 2026 09:28:52 +0200 Subject: [PATCH 31/47] Fix dev map med APC overload (#42157) --- Resources/Maps/Test/dev_map.yml | 251 +++++++++----------------------- 1 file changed, 66 insertions(+), 185 deletions(-) diff --git a/Resources/Maps/Test/dev_map.yml b/Resources/Maps/Test/dev_map.yml index 738940ac71..b42d2b1cf3 100644 --- a/Resources/Maps/Test/dev_map.yml +++ b/Resources/Maps/Test/dev_map.yml @@ -1,11 +1,11 @@ meta: format: 7 category: Map - engineVersion: 266.0.0 + engineVersion: 270.0.0 forkId: "" forkVersion: "" - time: 08/31/2025 05:06:28 - entityCount: 3156 + time: 12/29/2025 08:52:17 + entityCount: 3158 maps: - 23 grids: @@ -1580,143 +1580,47 @@ entities: uniqueMixes: - volume: 2500 immutable: True - moles: - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 + moles: {} - volume: 2500 temperature: 293.15 moles: - - 21.824879 - - 82.10312 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 + Oxygen: 21.824879 + Nitrogen: 82.10312 - volume: 2500 temperature: 235 moles: - - 21.824879 - - 82.10312 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 + Oxygen: 21.824879 + Nitrogen: 82.10312 - volume: 2500 temperature: 293.14975 moles: - - 20.078888 - - 75.53487 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 + Oxygen: 20.078888 + Nitrogen: 75.53487 + - volume: 2500 + temperature: 293.15 + moles: {} - volume: 2500 temperature: 293.15 moles: - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 + Nitrogen: 6666.982 - volume: 2500 temperature: 293.15 moles: - - 0 - - 6666.982 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 + Oxygen: 6666.982 - volume: 2500 temperature: 293.15 moles: - - 6666.982 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - volume: 2500 - temperature: 293.15 - moles: - - 0 - - 0 - - 0 - - 6666.982 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 + Plasma: 6666.982 - volume: 2500 temperature: 5000 moles: - - 6666.982 - - 0 - - 0 - - 6666.982 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 + Oxygen: 6666.982 + Plasma: 6666.982 chunkSize: 4 - type: BecomesStation id: Dev - type: ImplicitRoof + - type: ExplosionAirtightGrid - uid: 23 components: - type: MetaData @@ -1809,37 +1713,16 @@ entities: - volume: 2500 temperature: 293.15 moles: - - 21.824879 - - 82.10312 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 + Oxygen: 21.824879 + Nitrogen: 82.10312 - volume: 2500 immutable: True - moles: - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 + moles: {} chunkSize: 4 - type: GasTileOverlay - type: RadiationGridResistance - type: ImplicitRoof + - type: ExplosionAirtightGrid - uid: 2869 components: - type: MetaData @@ -2106,14 +1989,6 @@ entities: parent: 1 - type: Fixtures fixtures: {} - - uid: 1220 - components: - - type: Transform - rot: 3.141592653589793 rad - pos: 51.5,37.5 - parent: 1 - - type: Fixtures - fixtures: {} - uid: 1394 components: - type: Transform @@ -3064,6 +2939,15 @@ entities: parent: 1 - type: Fixtures fixtures: {} +- proto: BaseAPC + entities: + - uid: 2753 + components: + - type: Transform + pos: -0.5,1.5 + parent: 2709 + - type: Fixtures + fixtures: {} - proto: BaseBallBat entities: - uid: 2686 @@ -6404,6 +6288,11 @@ entities: - type: Transform pos: 77.5,11.5 parent: 1 + - uid: 3158 + components: + - type: Transform + pos: 52.5,37.5 + parent: 1 - proto: CableApcStack entities: - uid: 3139 @@ -9831,18 +9720,8 @@ entities: immutable: False temperature: 293.14673 moles: - - 1.7459903 - - 6.568249 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 + Oxygen: 1.7459903 + Nitrogen: 6.568249 - type: ContainerContainer containers: entity_storage: !type:Container @@ -9923,13 +9802,25 @@ entities: - type: Transform pos: 70.5,15.5 parent: 1 -- proto: DebugAPC +- proto: DebugAPCRecharging entities: - - uid: 2753 + - uid: 1220 components: - type: Transform - pos: -0.5,1.5 - parent: 2709 + rot: 3.141592653589793 rad + pos: 51.5,37.5 + parent: 1 + - type: AccessReader + accessListsOriginal: + - - Engineering + - type: Fixtures + fixtures: {} + - uid: 2115 + components: + - type: Transform + rot: 3.141592653589793 rad + pos: 52.5,37.5 + parent: 1 - type: Fixtures fixtures: {} - proto: DebugGenerator @@ -9943,15 +9834,6 @@ entities: supplyRampRate: 500000 supplyRampTolerance: 5000 supplyRate: 3000000 -- proto: DebugSubstation - entities: - - uid: 2296 - components: - - type: Transform - pos: 43.5,32.5 - parent: 1 - - type: Battery - maxCharge: 25000000 - proto: DefaultStationBeaconMedical entities: - uid: 2882 @@ -11884,18 +11766,8 @@ entities: immutable: False temperature: 293.14673 moles: - - 1.7459903 - - 6.568249 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 - - 0 + Oxygen: 1.7459903 + Nitrogen: 6.568249 - proto: LockerChiefEngineerFilledHardsuit entities: - uid: 1332 @@ -15902,6 +15774,15 @@ entities: - type: Transform pos: 77.5,6.5 parent: 1 +- proto: SubstationBasicEmpty + entities: + - uid: 2296 + components: + - type: Transform + pos: 43.5,32.5 + parent: 1 + - type: Battery + maxCharge: 25000000 - proto: SubstationWallBasic entities: - uid: 2800 From a35a48c351c24ea2230e8646d38da942880407ee Mon Sep 17 00:00:00 2001 From: Princess Cheeseballs <66055347+Princess-Cheeseballs@users.noreply.github.com> Date: Mon, 26 Jan 2026 03:54:45 -0800 Subject: [PATCH 32/47] Fix sound issues with arti crusher. (#42406) AAAAAAAAAAAAAAAAAAAAA Co-authored-by: Princess Cheeseballs <66055347+Pronana@users.noreply.github.com> --- .../Equipment/SharedArtifactCrusherSystem.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Content.Shared/Xenoarchaeology/Equipment/SharedArtifactCrusherSystem.cs b/Content.Shared/Xenoarchaeology/Equipment/SharedArtifactCrusherSystem.cs index 2807fd9250..5051f1e4ff 100644 --- a/Content.Shared/Xenoarchaeology/Equipment/SharedArtifactCrusherSystem.cs +++ b/Content.Shared/Xenoarchaeology/Equipment/SharedArtifactCrusherSystem.cs @@ -121,7 +121,7 @@ public abstract class SharedArtifactCrusherSystem : EntitySystem crusher.Crushing = true; crusher.NextSecond = _timing.CurTime + TimeSpan.FromSeconds(1); crusher.CrushEndTime = _timing.CurTime + crusher.CrushDuration; - crusher.CrushingSoundEntity = AudioSystem.PlayPvs(crusher.CrushingSound, ent)?.Entity; + crusher.CrushingSoundEntity = AudioSystem.PlayPredicted(crusher.CrushingSound, ent, user)?.Entity ?? crusher.CrushingSoundEntity; _appearance.SetData(ent, ArtifactCrusherVisuals.Crushing, true); Dirty(ent, ent.Comp1); } @@ -135,10 +135,7 @@ public abstract class SharedArtifactCrusherSystem : EntitySystem _appearance.SetData(ent, ArtifactCrusherVisuals.Crushing, false); if (early) - { - AudioSystem.Stop(ent.Comp.CrushingSoundEntity); - ent.Comp.CrushingSoundEntity = null; - } + ent.Comp.CrushingSoundEntity = AudioSystem.Stop(ent.Comp.CrushingSoundEntity); Dirty(ent, ent.Comp); } From a237493841100673de05dc05c018fc0d02afd3a0 Mon Sep 17 00:00:00 2001 From: B_Kirill <153602297+B-Kirill@users.noreply.github.com> Date: Mon, 26 Jan 2026 23:48:16 +1000 Subject: [PATCH 33/47] Prevent picking up chameleon projector disguises via context menu (#42656) --- .../Polymorph/Systems/SharedChameleonProjectorSystem.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Content.Shared/Polymorph/Systems/SharedChameleonProjectorSystem.cs b/Content.Shared/Polymorph/Systems/SharedChameleonProjectorSystem.cs index bf6fea5b47..33763529d3 100644 --- a/Content.Shared/Polymorph/Systems/SharedChameleonProjectorSystem.cs +++ b/Content.Shared/Polymorph/Systems/SharedChameleonProjectorSystem.cs @@ -46,6 +46,7 @@ public abstract class SharedChameleonProjectorSystem : EntitySystem SubscribeLocalEvent(OnDisguiseDamaged); SubscribeLocalEvent(OnDisguiseInsertAttempt); SubscribeLocalEvent(OnDisguiseShutdown); + SubscribeLocalEvent(OnDisguiseBeforeEquippedHand); SubscribeLocalEvent(OnDisguisedInserted); @@ -86,6 +87,12 @@ public abstract class SharedChameleonProjectorSystem : EntitySystem _actions.RemoveProvidedActions(ent.Comp.User, ent.Comp.Projector); } + private void OnDisguiseBeforeEquippedHand(Entity ent, ref BeforeGettingEquippedHandEvent args) + { + args.Cancelled = true; + TryReveal(ent.Comp.User); + } + #endregion #region Disguised player From 235ad21f22edf2d02c59c86fafdc1e2daee5d300 Mon Sep 17 00:00:00 2001 From: PJBot Date: Mon, 26 Jan 2026 14:04:47 +0000 Subject: [PATCH 34/47] Automatic changelog update --- Resources/Changelog/Changelog.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Resources/Changelog/Changelog.yml b/Resources/Changelog/Changelog.yml index a4639586a1..0a00d7bee2 100644 --- a/Resources/Changelog/Changelog.yml +++ b/Resources/Changelog/Changelog.yml @@ -1,12 +1,4 @@ Entries: -- author: luegamer - changes: - - message: SmartFridge Circuitboards are now printable, SmartFridge destruction - no longer deletes all contents - type: Add - id: 8955 - time: '2025-09-11T19:59:21.0000000+00:00' - url: https://github.com/space-wizards/space-station-14/pull/39879 - author: SurrealShibe changes: - message: Toilet seats are now displayed on the correct layer. @@ -4005,3 +3997,11 @@ id: 9461 time: '2026-01-26T06:12:02.0000000+00:00' url: https://github.com/space-wizards/space-station-14/pull/41396 +- author: B_Kirill + changes: + - message: Fixed being able to pick up chameleon projector disguises via context + menu. + type: Fix + id: 9462 + time: '2026-01-26T14:03:39.0000000+00:00' + url: https://github.com/space-wizards/space-station-14/pull/42656 From 256ecd3c468e023ae5f4071e62b6b0f2e356c999 Mon Sep 17 00:00:00 2001 From: Ohelig <5841980+Ohelig@users.noreply.github.com> Date: Mon, 26 Jan 2026 08:47:04 -0600 Subject: [PATCH 35/47] Refresh gas canister UI on canister startup (#42616) * Refresh UI on canister startup * Rework DirtyUI to not fail during tests on fake canisters * Feedback from Discord --- .../Unary/EntitySystems/GasCanisterSystem.cs | 2 +- .../Unary/Systems/SharedGasCanisterSystem.cs | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Content.Server/Atmos/Piping/Unary/EntitySystems/GasCanisterSystem.cs b/Content.Server/Atmos/Piping/Unary/EntitySystems/GasCanisterSystem.cs index 14c85620b1..9b377dfddf 100644 --- a/Content.Server/Atmos/Piping/Unary/EntitySystems/GasCanisterSystem.cs +++ b/Content.Server/Atmos/Piping/Unary/EntitySystems/GasCanisterSystem.cs @@ -48,7 +48,7 @@ public sealed class GasCanisterSystem : SharedGasCanisterSystem protected override void DirtyUI(EntityUid uid, GasCanisterComponent? canister = null, NodeContainerComponent? nodeContainer = null) { - if (!Resolve(uid, ref canister, ref nodeContainer)) + if (!Resolve(uid, ref canister, ref nodeContainer, logMissing: false)) return; var portStatus = false; diff --git a/Content.Shared/Atmos/Piping/Unary/Systems/SharedGasCanisterSystem.cs b/Content.Shared/Atmos/Piping/Unary/Systems/SharedGasCanisterSystem.cs index e7a8ed5131..a7562689ca 100644 --- a/Content.Shared/Atmos/Piping/Unary/Systems/SharedGasCanisterSystem.cs +++ b/Content.Shared/Atmos/Piping/Unary/Systems/SharedGasCanisterSystem.cs @@ -23,6 +23,8 @@ public abstract class SharedGasCanisterSystem : EntitySystem SubscribeLocalEvent(OnCanisterContainerModified); SubscribeLocalEvent(OnCanisterInsertAttempt); SubscribeLocalEvent(OnCanisterStartup); + SubscribeLocalEvent(OnCanisterMapInit); + SubscribeLocalEvent(OnCanisterUIOpened); // Bound UI subscriptions SubscribeLocalEvent(OnHoldingTankEjectMessage); @@ -30,6 +32,19 @@ public abstract class SharedGasCanisterSystem : EntitySystem SubscribeLocalEvent(OnCanisterChangeReleaseValve); } + private void OnCanisterUIOpened(Entity ent, ref BoundUIOpenedEvent args) + { + // Fixes all canisters not populating UI elements before MapInit. Mappers rejoice + // We still need to DirtyUI after MapInit because this has latency, bad UX for players. + DirtyUI(ent.Owner, ent); + } + + private void OnCanisterMapInit(Entity ent, ref MapInitEvent args) + { + // Fixes empty canisters not populating UI elements + DirtyUI(ent.Owner, ent); + } + private void OnCanisterStartup(Entity ent, ref ComponentStartup args) { // Ensure container From fdd8c2a1f6db2fe5c8d178b9f4353e407b11b05a Mon Sep 17 00:00:00 2001 From: PJBot Date: Mon, 26 Jan 2026 15:03:28 +0000 Subject: [PATCH 36/47] Automatic changelog update --- Resources/Changelog/Changelog.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Resources/Changelog/Changelog.yml b/Resources/Changelog/Changelog.yml index 0a00d7bee2..f8ffc0d438 100644 --- a/Resources/Changelog/Changelog.yml +++ b/Resources/Changelog/Changelog.yml @@ -1,11 +1,4 @@ Entries: -- author: SurrealShibe - changes: - - message: Toilet seats are now displayed on the correct layer. - type: Fix - id: 8956 - time: '2025-09-12T22:47:21.0000000+00:00' - url: https://github.com/space-wizards/space-station-14/pull/40313 - author: Princess-Cheeseballs changes: - message: Chameleon Projector will no longer cause you to be permanently slowed @@ -4005,3 +3998,10 @@ id: 9462 time: '2026-01-26T14:03:39.0000000+00:00' url: https://github.com/space-wizards/space-station-14/pull/42656 +- author: Ohelig + changes: + - message: Empty gas canisters now have a populated UI. + type: Fix + id: 9463 + time: '2026-01-26T15:02:20.0000000+00:00' + url: https://github.com/space-wizards/space-station-14/pull/42616 From 52155802e38c10612ca8197ebe98feec7b334053 Mon Sep 17 00:00:00 2001 From: War__Prophet Date: Mon, 26 Jan 2026 08:59:41 -0600 Subject: [PATCH 37/47] Doors can now close on clown spider webs (#42589) buh --- .../Prototypes/Entities/Structures/spider_web.yml | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/Resources/Prototypes/Entities/Structures/spider_web.yml b/Resources/Prototypes/Entities/Structures/spider_web.yml index b09e022a3b..1049129c7b 100644 --- a/Resources/Prototypes/Entities/Structures/spider_web.yml +++ b/Resources/Prototypes/Entities/Structures/spider_web.yml @@ -103,20 +103,14 @@ intersectRatio: 0.2 - type: Fixtures fixtures: - slips: - shape: - !type:PhysShapeAabb - bounds: "-0.4,-0.4,0.4,0.4" - hard: false - layer: - - SlipLayer fix1: + hard: false + density: 7 shape: !type:PhysShapeAabb bounds: "-0.4,-0.4,0.4,0.4" - density: 1000 - mask: - - ItemMask + layer: + - MidImpassable - type: Destructible thresholds: - trigger: From b33c780a6c5888db376a5aaa92e8472f5ed27c04 Mon Sep 17 00:00:00 2001 From: PJBot Date: Mon, 26 Jan 2026 15:20:48 +0000 Subject: [PATCH 38/47] Automatic changelog update --- Resources/Changelog/Changelog.yml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Resources/Changelog/Changelog.yml b/Resources/Changelog/Changelog.yml index f8ffc0d438..e3839a435f 100644 --- a/Resources/Changelog/Changelog.yml +++ b/Resources/Changelog/Changelog.yml @@ -1,12 +1,4 @@ Entries: -- author: Princess-Cheeseballs - changes: - - message: Chameleon Projector will no longer cause you to be permanently slowed - down - type: Fix - id: 8957 - time: '2025-09-12T23:23:58.0000000+00:00' - url: https://github.com/space-wizards/space-station-14/pull/37960 - author: FungiFellow changes: - message: Cockroaches can Gib when Stepped on @@ -4005,3 +3997,10 @@ id: 9463 time: '2026-01-26T15:02:20.0000000+00:00' url: https://github.com/space-wizards/space-station-14/pull/42616 +- author: notquitehadouken + changes: + - message: Doors close on clown spider webs now + type: Fix + id: 9464 + time: '2026-01-26T15:19:39.0000000+00:00' + url: https://github.com/space-wizards/space-station-14/pull/42589 From fd0f52592788f0e1b0d7485c8c4dd83161d905f2 Mon Sep 17 00:00:00 2001 From: Marlyn Date: Mon, 26 Jan 2026 16:30:21 +0100 Subject: [PATCH 39/47] Fixes Opporozidone Instarot issues (#42472) Removes a vestigial shutdown handler When opporozidone removes the RottingComponent, the PerishableComponent's RotNextUpdate is set to 0 - and then it undergoes a period of catch-up to the current time. If the entity is susceptible to rot in that interval, it'll accumulate 2 minutes 30 seconds worth of rot, per second. This gets worse the longer the server has been running, as there's a greater value in _timing.CurTime to catch up to. This handler seems to be vestigial from the MiasmaSystem and does not seem to be necessary for anything anymore. --- Content.Shared/Atmos/Rotting/SharedRottingSystem.cs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/Content.Shared/Atmos/Rotting/SharedRottingSystem.cs b/Content.Shared/Atmos/Rotting/SharedRottingSystem.cs index 5fd9aaf3e2..3007da55df 100644 --- a/Content.Shared/Atmos/Rotting/SharedRottingSystem.cs +++ b/Content.Shared/Atmos/Rotting/SharedRottingSystem.cs @@ -25,7 +25,6 @@ public abstract class SharedRottingSystem : EntitySystem SubscribeLocalEvent(OnMobStateChanged); SubscribeLocalEvent(OnPerishableExamined); - SubscribeLocalEvent(OnShutdown); SubscribeLocalEvent(OnRottingMobStateChanged); SubscribeLocalEvent(OnRejuvenate); SubscribeLocalEvent(OnExamined); @@ -63,14 +62,6 @@ public abstract class SharedRottingSystem : EntitySystem args.PushMarkup(Loc.GetString(description, ("target", Identity.Entity(perishable, EntityManager)))); } - private void OnShutdown(Entity ent, ref ComponentShutdown args) - { - if (TryComp(ent, out var perishable)) - { - perishable.RotNextUpdate = TimeSpan.Zero; - } - } - private void OnRottingMobStateChanged(EntityUid uid, RottingComponent component, MobStateChangedEvent args) { if (args.NewMobState == MobState.Dead) From 36d09f982b40e692851ada4c1b4ba7cf455c16c9 Mon Sep 17 00:00:00 2001 From: PJBot Date: Mon, 26 Jan 2026 15:46:32 +0000 Subject: [PATCH 40/47] Automatic changelog update --- Resources/Changelog/Changelog.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Resources/Changelog/Changelog.yml b/Resources/Changelog/Changelog.yml index e3839a435f..3c391f6c4a 100644 --- a/Resources/Changelog/Changelog.yml +++ b/Resources/Changelog/Changelog.yml @@ -1,11 +1,4 @@ Entries: -- author: FungiFellow - changes: - - message: Cockroaches can Gib when Stepped on - type: Add - id: 8958 - time: '2025-09-13T07:01:14.0000000+00:00' - url: https://github.com/space-wizards/space-station-14/pull/40103 - author: Huaqas changes: - message: Vulpkanin now have Undergarments. @@ -4004,3 +3997,11 @@ id: 9464 time: '2026-01-26T15:19:39.0000000+00:00' url: https://github.com/space-wizards/space-station-14/pull/42589 +- author: Marlyn + changes: + - message: Opporozidone no longer has instarot issues that get worse the longer + the server has been running + type: Fix + id: 9465 + time: '2026-01-26T15:45:24.0000000+00:00' + url: https://github.com/space-wizards/space-station-14/pull/42472 From 5b9ff83ce5ed68aeda6f2b0b17d273e42c5030a9 Mon Sep 17 00:00:00 2001 From: Winkarst-cpu <74284083+Winkarst-cpu@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:38:09 +0300 Subject: [PATCH 41/47] Fix: Make vote call button toggable (#42450) Fix --- Content.Client/Voting/UI/VoteCallMenuButton.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/Content.Client/Voting/UI/VoteCallMenuButton.cs b/Content.Client/Voting/UI/VoteCallMenuButton.cs index d934ab8b97..93bf823185 100644 --- a/Content.Client/Voting/UI/VoteCallMenuButton.cs +++ b/Content.Client/Voting/UI/VoteCallMenuButton.cs @@ -12,18 +12,28 @@ namespace Content.Client.Voting.UI { [Dependency] private readonly IVoteManager _voteManager = default!; + private VoteCallMenu? _voteCallMenu; + public VoteCallMenuButton() { IoCManager.InjectDependencies(this); Text = Loc.GetString("ui-vote-menu-button"); + ToggleMode = true; OnPressed += OnOnPressed; } private void OnOnPressed(ButtonEventArgs obj) { - var menu = new VoteCallMenu(); - menu.OpenCentered(); + if (_voteCallMenu is { IsOpen: true }) + { + _voteCallMenu.Close(); + return; + } + + _voteCallMenu = new VoteCallMenu(); + _voteCallMenu.OnClose += () => Pressed = false; + _voteCallMenu.OpenCentered(); } protected override void EnteredTree() @@ -38,6 +48,9 @@ namespace Content.Client.Voting.UI { base.ExitedTree(); + if (_voteCallMenu is { IsOpen: true }) + _voteCallMenu.Close(); + _voteManager.CanCallVoteChanged += UpdateCanCall; } From 093257280bd7ea71516553d825aa581f598da570 Mon Sep 17 00:00:00 2001 From: B_Kirill <153602297+B-Kirill@users.noreply.github.com> Date: Tue, 27 Jan 2026 01:52:56 +1000 Subject: [PATCH 42/47] Fix InstrumentSystem.Update exception when deleting band lead (#42331) --- Content.Server/Instruments/InstrumentSystem.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Content.Server/Instruments/InstrumentSystem.cs b/Content.Server/Instruments/InstrumentSystem.cs index 56b64576ca..c7b888114e 100644 --- a/Content.Server/Instruments/InstrumentSystem.cs +++ b/Content.Server/Instruments/InstrumentSystem.cs @@ -438,20 +438,22 @@ public sealed partial class InstrumentSystem : SharedInstrumentSystem if (Deleted(master)) { Clean(uid, instrument); + continue; } var masterActive = activeQuery.CompOrNull(master); if (masterActive == null) { Clean(uid, instrument); + continue; } var trans = transformQuery.GetComponent(uid); var masterTrans = transformQuery.GetComponent(master); - if (!_transform.InRange(masterTrans.Coordinates, trans.Coordinates, 10f) -) + if (!_transform.InRange(masterTrans.Coordinates, trans.Coordinates, 10f)) { Clean(uid, instrument); + continue; } } From 9a5c2793261d8f2150ab0272845c9d8d4a3c7a2c Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Mon, 26 Jan 2026 17:00:24 +0100 Subject: [PATCH 43/47] Move job weh plushies to locker loot (#42545) This is an alternative to https://github.com/space-wizards/space-station-14/pull/42540 that doesn't remove them from the game entirely. I don't personally take issue with this kind of content being in the game, but do think the loadout bloat is a bit extreme. They now have a small chance (2%) to show up in lockers around the station. They generally spawn in job-appropriate lockers. Intern jobs spawn in their "main" job's lockers. Misc jobs without lockers (e.g. reporter) have been moved to a maints loot table. --- .../Catalog/Fills/Lockers/cargo.yml | 2 + .../Catalog/Fills/Lockers/engineer.yml | 6 + .../Catalog/Fills/Lockers/heads.yml | 14 + .../Catalog/Fills/Lockers/medical.yml | 10 + .../Prototypes/Catalog/Fills/Lockers/misc.yml | 17 + .../Catalog/Fills/Lockers/science.yml | 4 + .../Catalog/Fills/Lockers/security.yml | 8 + .../Catalog/Fills/Lockers/service.yml | 16 + .../Catalog/Fills/Lockers/wardrobe_colors.yml | 2 + .../Catalog/Fills/Lockers/wardrobe_job.yml | 4 + .../Loadouts/LoadoutGroups/loadout_groups.yml | 271 +++++------ .../Loadouts/LoadoutGroups/medical.yml | 55 +-- .../Loadouts/Miscellaneous/jobtrinkets.yml | 432 ------------------ .../Loadouts/RoleLoadouts/medical.yml | 10 +- .../Loadouts/RoleLoadouts/role_loadouts.yml | 48 +- 15 files changed, 257 insertions(+), 642 deletions(-) diff --git a/Resources/Prototypes/Catalog/Fills/Lockers/cargo.yml b/Resources/Prototypes/Catalog/Fills/Lockers/cargo.yml index 427123c35f..1e4a47250c 100644 --- a/Resources/Prototypes/Catalog/Fills/Lockers/cargo.yml +++ b/Resources/Prototypes/Catalog/Fills/Lockers/cargo.yml @@ -21,6 +21,8 @@ prob: 0.3 rolls: !type:ConstantNumberSelector value: 3 + - id: PlushieLizardJobSalvagespecialist + prob: 0.02 - type: entity id: LockerSalvageSpecialistFilledHardsuit diff --git a/Resources/Prototypes/Catalog/Fills/Lockers/engineer.yml b/Resources/Prototypes/Catalog/Fills/Lockers/engineer.yml index 27e856b0cd..6a85befab1 100644 --- a/Resources/Prototypes/Catalog/Fills/Lockers/engineer.yml +++ b/Resources/Prototypes/Catalog/Fills/Lockers/engineer.yml @@ -159,6 +159,8 @@ - id: RCD - id: RCDAmmo - id: AirGrenade + - id: PlushieLizardJobAtmospherictechnician + prob: 0.02 - type: entityTable id: FillAtmosphericsHardsuit @@ -202,6 +204,10 @@ - id: RCD - id: RCDAmmo - id: MetalFoamGrenade + - id: PlushieLizardJobTechnicalassistant + prob: 0.02 + - id: PlushieLizardJobStationengineer + prob: 0.02 - type: entityTable id: FillEngineerHardsuit diff --git a/Resources/Prototypes/Catalog/Fills/Lockers/heads.yml b/Resources/Prototypes/Catalog/Fills/Lockers/heads.yml index 7074f65796..b005c3e06d 100644 --- a/Resources/Prototypes/Catalog/Fills/Lockers/heads.yml +++ b/Resources/Prototypes/Catalog/Fills/Lockers/heads.yml @@ -12,6 +12,8 @@ - id: DoorRemoteCargo - id: AstroNavCartridge - id: ClothingHandsKnuckleDustersQM + - id: PlushieLizardJobQuartermaster + prob: 0.02 - type: entity id: LockerQuarterMasterFilled @@ -49,6 +51,8 @@ conditions: - !type:PlayerCountCondition max: 15 + - id: PlushieLizardJobCaptain + prob: 0.02 # No laser table + Laser table - type: entityTable @@ -130,6 +134,8 @@ - id: HoPIDCard - id: WeaponDisabler - id: ClothingEyesHudCommand + - id: PlushieLizardJobHeadofpersonnel + prob: 0.02 - type: entity id: LockerHeadOfPersonnelFilled @@ -160,6 +166,8 @@ - id: RCDAmmo - id: RubberStampCE - id: MetalFoamGrenade + - id: PlushieLizardJobChiefengineer + prob: 0.02 # Hardsuit table, used for suit storage as well - type: entityTable @@ -214,6 +222,8 @@ - id: DoorRemoteMedical - id: BoxEncryptionKeyMedical - id: BoxCMOCircuitboards + - id: PlushieLizardJobChiefmedicalofficer + prob: 0.02 # Hardsuit table, used for suit storage as well - type: entityTable @@ -263,6 +273,8 @@ - id: DoorRemoteResearch - id: HandTeleporter - id: RubberStampRd + - id: PlushieLizardJobResearchdirector + prob: 0.02 # Hardsuit table, used for suit storage as well - type: entityTable @@ -323,6 +335,8 @@ - id: WeaponTaser - id: WantedListCartridge - id: DrinkHosFlask + - id: PlushieLizardJobHeadofsecurity + prob: 0.02 # Hardsuit table, used for suit storage as well - type: entityTable diff --git a/Resources/Prototypes/Catalog/Fills/Lockers/medical.yml b/Resources/Prototypes/Catalog/Fills/Lockers/medical.yml index b38b900983..d59f24217e 100644 --- a/Resources/Prototypes/Catalog/Fills/Lockers/medical.yml +++ b/Resources/Prototypes/Catalog/Fills/Lockers/medical.yml @@ -49,6 +49,12 @@ - id: HandheldHealthAnalyzer - id: ChemistryBottleLaughter # Widely recognized as the best medicine :o) prob: 0.01 + - id: PlushieLizardJobMedicaldoctor + prob: 0.02 + - id: PlushieLizardJobMedicalintern + prob: 0.02 + - id: PlushieLizardJobPsychologist + prob: 0.02 - type: entity parent: LockerMedical @@ -94,6 +100,8 @@ - id: SprayBottle - id: Bucket - id: DrinkCartonMilk + - id: PlushieLizardJobChemist + prob: 0.02 - type: entity parent: LockerChemistry @@ -120,6 +128,8 @@ - id: RollerBedSpawnFolded - id: CheapRollerBedSpawnFolded - id: EmergencyRollerBedSpawnFolded + - id: PlushieLizardJobParamedic + prob: 0.02 - type: entity parent: LockerParamedic diff --git a/Resources/Prototypes/Catalog/Fills/Lockers/misc.yml b/Resources/Prototypes/Catalog/Fills/Lockers/misc.yml index 229491ca3b..1e0c7d73fe 100644 --- a/Resources/Prototypes/Catalog/Fills/Lockers/misc.yml +++ b/Resources/Prototypes/Catalog/Fills/Lockers/misc.yml @@ -206,6 +206,10 @@ prob: 0.10 rolls: !type:RangeNumberSelector range: 1, 2 + - !type:NestedSelector + tableId: MaintenancePlushies + prob: 0.02 + # Weapons - !type:NestedSelector tableId: MaintWeaponTable @@ -218,6 +222,19 @@ - id: ClosetMaintenanceFilledRandom prob: 0.01 +- type: entityTable + id: MaintenancePlushies + table: !type:GroupSelector + children: + - id: PlushieLizardJobBoxer + - id: PlushieLizardJobClown + - id: PlushieLizardJobLibrarian + - id: PlushieLizardJobMime + - id: PlushieLizardJobMusician + - id: PlushieLizardJobPassenger + - id: PlushieLizardJobReporter + - id: PlushieLizardJobZookeeper + - type: entity id: ClosetMaintenanceFilledRandom suffix: Filled, Random diff --git a/Resources/Prototypes/Catalog/Fills/Lockers/science.yml b/Resources/Prototypes/Catalog/Fills/Lockers/science.yml index 318206b27f..51851078a0 100644 --- a/Resources/Prototypes/Catalog/Fills/Lockers/science.yml +++ b/Resources/Prototypes/Catalog/Fills/Lockers/science.yml @@ -20,3 +20,7 @@ - id: NodeScanner - id: NetworkConfigurator prob: 0.5 + - id: PlushieLizardJobResearchassistant + prob: 0.02 + - id: PlushieLizardJobScientist + prob: 0.02 diff --git a/Resources/Prototypes/Catalog/Fills/Lockers/security.yml b/Resources/Prototypes/Catalog/Fills/Lockers/security.yml index a6add7f4b7..d9ddf31e15 100644 --- a/Resources/Prototypes/Catalog/Fills/Lockers/security.yml +++ b/Resources/Prototypes/Catalog/Fills/Lockers/security.yml @@ -46,6 +46,8 @@ amount: 2 - id: NetworkConfigurator - id: Binoculars + - id: PlushieLizardJobWarden + prob: 0.02 - type: entityTable id: FillLockerWardenHarduit @@ -90,6 +92,10 @@ prob: 0.6 - id: BookSpaceLaw prob: 0.5 + - id: PlushieLizardJobSecuritycadet + prob: 0.02 + - id: PlushieLizardJobSecurityofficer + prob: 0.02 - type: entity id: LockerBrigmedicFilled @@ -166,6 +172,8 @@ - id: HoloprojectorSecurity - id: BoxEvidenceMarkers - id: HandLabeler + - id: PlushieLizardJobDetective + prob: 0.02 - type: entity id: ClosetBombFilled diff --git a/Resources/Prototypes/Catalog/Fills/Lockers/service.yml b/Resources/Prototypes/Catalog/Fills/Lockers/service.yml index e486545ce3..31f4d1d12c 100644 --- a/Resources/Prototypes/Catalog/Fills/Lockers/service.yml +++ b/Resources/Prototypes/Catalog/Fills/Lockers/service.yml @@ -25,6 +25,10 @@ children: - id: BoxBeanbag - id: RagItem + - id: PlushieLizardJobBartender + prob: 0.02 + - id: PlushieLizardJobServiceworker + prob: 0.02 #- type: entity # id: LockerFormalFilled @@ -64,6 +68,10 @@ children: - id: FoodCondimentPacketSalt - id: FoodCondimentPacketPepper + - id: PlushieLizardJobChef + prob: 0.02 + - id: PlushieLizardJobServiceworker + prob: 0.02 - type: entity id: ClosetJanitorFilled @@ -94,6 +102,8 @@ - id: FlashlightLantern - id: Plunger - id: WireBrush + - id: PlushieLizardJobJanitor + prob: 0.02 - type: entity id: ClosetLegalFilled @@ -112,6 +122,8 @@ - id: BriefcaseBrownFilled prob: 0.80 - id: ClothingOuterRobesJudge + - id: PlushieLizardJobLawyer + prob: 0.02 - type: entity id: LockerBotanistFilled @@ -140,6 +152,10 @@ - id: ClothingUniformOveralls - id: ClothingHeadHatTrucker prob: 0.1 + - id: PlushieLizardJobBotanist + prob: 0.02 + - id: PlushieLizardJobServiceworker + prob: 0.02 - type: entity id: LockerBotanistLoot diff --git a/Resources/Prototypes/Catalog/Fills/Lockers/wardrobe_colors.yml b/Resources/Prototypes/Catalog/Fills/Lockers/wardrobe_colors.yml index d65469ccd2..bdeb37821e 100644 --- a/Resources/Prototypes/Catalog/Fills/Lockers/wardrobe_colors.yml +++ b/Resources/Prototypes/Catalog/Fills/Lockers/wardrobe_colors.yml @@ -33,6 +33,8 @@ prob: 0.5 - id: ClothingOuterCoatBomber prob: 0.3 + - id: PlushieLizardJobPassenger + prob: 0.02 - type: entity id: WardrobeMixedFilled diff --git a/Resources/Prototypes/Catalog/Fills/Lockers/wardrobe_job.yml b/Resources/Prototypes/Catalog/Fills/Lockers/wardrobe_job.yml index dc944a803c..5962a5ad3b 100644 --- a/Resources/Prototypes/Catalog/Fills/Lockers/wardrobe_job.yml +++ b/Resources/Prototypes/Catalog/Fills/Lockers/wardrobe_job.yml @@ -267,6 +267,8 @@ - id: ClothingUniformJumpsuitChaplain - id: ClothingShoesColorBlack - id: ClothingUniformJumpskirtChaplain + - id: PlushieLizardJobChaplain + prob: 0.02 - type: entity id: WardrobeSecurityFilled @@ -313,6 +315,8 @@ - id: ClothingUniformJumpskirtCargo - id: ClothingHandsGlovesFingerless - id: AppraisalTool + - id: PlushieLizardJobCargotechnician + prob: 0.02 - type: entity id: WardrobeSalvageFilled diff --git a/Resources/Prototypes/Loadouts/LoadoutGroups/loadout_groups.yml b/Resources/Prototypes/Loadouts/LoadoutGroups/loadout_groups.yml index 209da82e5f..ef97ca1918 100644 --- a/Resources/Prototypes/Loadouts/LoadoutGroups/loadout_groups.yml +++ b/Resources/Prototypes/Loadouts/LoadoutGroups/loadout_groups.yml @@ -141,12 +141,11 @@ - CaptainOuterClothing - CaptainWintercoat -- type: loadoutGroup - id: CaptainJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieCaptain +#- type: loadoutGroup +# id: CaptainJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: [] - type: loadoutGroup id: HoPHead @@ -186,12 +185,11 @@ loadouts: - HoPWintercoat -- type: loadoutGroup - id: HoPJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieHeadOfPersonnel +#- type: loadoutGroup +# id: HoPJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: [] # Civilian - type: loadoutGroup @@ -247,12 +245,11 @@ - BlackShoes - WinterBoots -- type: loadoutGroup - id: PassengerJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushiePassenger +#- type: loadoutGroup +# id: PassengerJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: [] - type: loadoutGroup id: BartenderHead @@ -284,15 +281,13 @@ name: loadout-group-jobtrinkets minLimit: 0 loadouts: - - LizardPlushieBartender - BartenderGoldenShaker -- type: loadoutGroup - id: ServiceWorkerJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieServiceWorker +#- type: loadoutGroup +# id: ServiceWorkerJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: - type: loadoutGroup id: ChefHead @@ -324,12 +319,11 @@ - ChefJacket - ChefWintercoat -- type: loadoutGroup - id: ChefJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieChef +#- type: loadoutGroup +# id: ChefJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: - type: loadoutGroup id: LibrarianJumpsuit @@ -340,12 +334,11 @@ - CuratorJumpsuit - CuratorJumpskirt -- type: loadoutGroup - id: LibrarianJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieLibrarian +#- type: loadoutGroup +# id: LibrarianJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: - type: loadoutGroup id: LawyerJumpsuit @@ -369,12 +362,11 @@ loadouts: - LawyerNeck -- type: loadoutGroup - id: LawyerJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieLawyer +#- type: loadoutGroup +# id: LawyerJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: - type: loadoutGroup id: ChaplainHead @@ -420,12 +412,11 @@ loadouts: - ChaplainNeck -- type: loadoutGroup - id: ChaplainJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieChaplain +#- type: loadoutGroup +# id: ChaplainJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: - type: loadoutGroup id: ChaplainBible @@ -474,7 +465,6 @@ name: loadout-group-jobtrinkets minLimit: 0 loadouts: - - LizardPlushieJanitor - JanitorGoldenPlunger - type: loadoutGroup @@ -509,12 +499,11 @@ - BotanistApron - BotanistWintercoat -- type: loadoutGroup - id: BotanistJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieBotanist +#- type: loadoutGroup +# id: BotanistJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: - type: loadoutGroup id: ClownHead @@ -571,7 +560,6 @@ name: loadout-group-jobtrinkets minLimit: 0 loadouts: - - LizardPlushieClown - FlowerWaterClown - type: loadoutGroup @@ -631,12 +619,11 @@ - EmergencySpeciesMothMime - LoadoutSpeciesVoxNitrogen -- type: loadoutGroup - id: MimeJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieMime +#- type: loadoutGroup +# id: MimeJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: - type: loadoutGroup id: MusicianJumpsuit @@ -687,12 +674,11 @@ - Ocarina - Bagpipe -- type: loadoutGroup - id: MusicianJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieMusician +#- type: loadoutGroup +# id: MusicianJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: # Cargo - type: loadoutGroup @@ -735,12 +721,11 @@ - BrownShoes - CargoWinterBoots -- type: loadoutGroup - id: QuartermasterJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieQuartermaster +#- type: loadoutGroup +# id: QuartermasterJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: - type: loadoutGroup id: CargoTechnicianHead @@ -785,12 +770,11 @@ - CargoTechnicianPDA - SeniorCourierPDA -- type: loadoutGroup - id: CargoTechnicianJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieCargoTechnician +#- type: loadoutGroup +# id: CargoTechnicianJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: - type: loadoutGroup id: SalvageSpecialistBackpack @@ -814,12 +798,11 @@ - SalvageBoots - CargoWinterBoots -- type: loadoutGroup - id: SalvageSpecialistJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieSalvageSpecialist +#- type: loadoutGroup +# id: SalvageSpecialistJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: # Engineering - type: loadoutGroup @@ -861,12 +844,11 @@ - BrownShoes - EngineeringWinterBoots -- type: loadoutGroup - id: ChiefEngineerJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieChiefEngineer +#- type: loadoutGroup +# id: ChiefEngineerJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: - type: loadoutGroup id: TechnicalAssistantJumpsuit @@ -875,12 +857,11 @@ - YellowJumpsuit - YellowJumpskirt -- type: loadoutGroup - id: TechnicalAssistantJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieTechnicalAssistant +#- type: loadoutGroup +# id: TechnicalAssistantJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: - type: loadoutGroup id: StationEngineerHead @@ -932,12 +913,11 @@ - StationEngineerPDA - SeniorEngineerPDA -- type: loadoutGroup - id: StationEngineerJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieStationEngineer +#- type: loadoutGroup +# id: StationEngineerJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: - type: loadoutGroup id: AtmosphericTechnicianJumpsuit @@ -969,12 +949,11 @@ - WhiteShoes - EngineeringWinterBoots -- type: loadoutGroup - id: AtmosphericTechnicianJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieAtmosphericTechnician +#- type: loadoutGroup +# id: AtmosphericTechnicianJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: - type: loadoutGroup id: SurvivalExtended @@ -1024,12 +1003,11 @@ - BrownShoes - ScienceWinterBoots -- type: loadoutGroup - id: ResearchDirectorJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieResearchDirector +#- type: loadoutGroup +# id: ResearchDirectorJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: - type: loadoutGroup id: ScientistHead @@ -1102,12 +1080,11 @@ - ScientistPDA - SeniorResearcherPDA -- type: loadoutGroup - id: ScientistJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieScientist +#- type: loadoutGroup +# id: ScientistJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: - type: loadoutGroup id: ResearchAssistantJumpsuit @@ -1116,12 +1093,11 @@ - WhiteJumpsuit - WhiteJumpskirt -- type: loadoutGroup - id: ResearchAssistantJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieResearchAssistant +#- type: loadoutGroup +# id: ResearchAssistantJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: # Security - type: loadoutGroup @@ -1163,7 +1139,6 @@ name: loadout-group-jobtrinkets minLimit: 0 loadouts: - - LizardPlushieHeadofSecurity - SecStar - type: loadoutGroup @@ -1193,7 +1168,6 @@ name: loadout-group-jobtrinkets minLimit: 0 loadouts: - - LizardPlushieWarden - SecStar - type: loadoutGroup @@ -1260,7 +1234,6 @@ name: loadout-group-jobtrinkets minLimit: 0 loadouts: - - LizardPlushieSecurity - SecStar - type: loadoutGroup @@ -1300,7 +1273,6 @@ name: loadout-group-jobtrinkets minLimit: 0 loadouts: - - LizardPlushieDetective - SecStar - type: loadoutGroup @@ -1310,12 +1282,11 @@ - RedJumpsuit - RedJumpskirt -- type: loadoutGroup - id: SecurityCadetJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieSecurityCadet +#- type: loadoutGroup +# id: SecurityCadetJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: - type: loadoutGroup id: SurvivalSecurity @@ -1335,12 +1306,11 @@ - ReporterJumpsuit - JournalistJumpsuit -- type: loadoutGroup - id: ReporterJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieReporter +#- type: loadoutGroup +# id: ReporterJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: - type: loadoutGroup id: PsychologistJumpsuit @@ -1349,12 +1319,11 @@ - PsychologistJumpsuit - PsychologistJumpskirt -- type: loadoutGroup - id: PsychologistJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushiePsychologist +#- type: loadoutGroup +# id: PsychologistJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: # Other - type: loadoutGroup diff --git a/Resources/Prototypes/Loadouts/LoadoutGroups/medical.yml b/Resources/Prototypes/Loadouts/LoadoutGroups/medical.yml index 5cb77dce1e..424748753d 100644 --- a/Resources/Prototypes/Loadouts/LoadoutGroups/medical.yml +++ b/Resources/Prototypes/Loadouts/LoadoutGroups/medical.yml @@ -74,12 +74,11 @@ - BrownShoes - MedicalWinterBoots -- type: loadoutGroup - id: ChiefMedicalOfficerJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieChiefMedicalOfficer +#- type: loadoutGroup +# id: ChiefMedicalOfficerJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: # Medical Doctor @@ -124,12 +123,11 @@ - MedicalDoctorPDA - SeniorPhysicianPDA -- type: loadoutGroup - id: MedicalDoctorJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieMedicalDoctor +#- type: loadoutGroup +# id: MedicalDoctorJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: # Medical Intern @@ -140,12 +138,11 @@ - WhiteJumpsuit - WhiteJumpskirt -- type: loadoutGroup - id: MedicalInternJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieMedicalIntern +#- type: loadoutGroup +# id: MedicalInternJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: # Chemist @@ -173,12 +170,11 @@ - ChemistSatchel - ChemistDuffel -- type: loadoutGroup - id: ChemistJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieChemist +#- type: loadoutGroup +# id: ChemistJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: # Paramedic @@ -212,9 +208,8 @@ - WhiteShoes - MedicalWinterBoots -- type: loadoutGroup - id: ParamedicJobTrinkets - name: loadout-group-jobtrinkets - minLimit: 0 - loadouts: - - LizardPlushieParamedic +#- type: loadoutGroup +# id: ParamedicJobTrinkets +# name: loadout-group-jobtrinkets +# minLimit: 0 +# loadouts: diff --git a/Resources/Prototypes/Loadouts/Miscellaneous/jobtrinkets.yml b/Resources/Prototypes/Loadouts/Miscellaneous/jobtrinkets.yml index 9da2b02f2b..27d266c5bb 100644 --- a/Resources/Prototypes/Loadouts/Miscellaneous/jobtrinkets.yml +++ b/Resources/Prototypes/Loadouts/Miscellaneous/jobtrinkets.yml @@ -1,125 +1,5 @@ # Job-specific trinkets -- type: loadout - id: LizardPlushieCaptain - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobCaptain - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobCaptain - -- type: loadout - id: LizardPlushieHeadOfPersonnel - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobHeadOfPersonnel - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobHeadofpersonnel - -- type: loadout - id: LizardPlushiePassenger - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobPassenger - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobPassenger - -- type: loadout - id: LizardPlushieBartender - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobBartender - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobBartender - -- type: loadout - id: LizardPlushieServiceWorker - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobServiceWorker - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobServiceworker - -- type: loadout - id: LizardPlushieChef - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobChef - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobChef - -- type: loadout - id: LizardPlushieLibrarian - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobLibrarian - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobLibrarian - -- type: loadout - id: LizardPlushieLawyer - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobLawyer - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobLawyer - -- type: loadout - id: LizardPlushieChaplain - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobChaplain - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobChaplain - -- type: loadout - id: LizardPlushieJanitor - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobJanitor - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobJanitor - - type: loadout id: JanitorGoldenPlunger effects: @@ -129,30 +9,6 @@ back: - GoldenPlunger -- type: loadout - id: LizardPlushieBotanist - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobBotanist - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobBotanist - -- type: loadout - id: LizardPlushieClown - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobClown - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobClown - - type: loadout id: FlowerWaterClown effects: @@ -165,198 +21,6 @@ back: - SprayFlowerPin -- type: loadout - id: LizardPlushieMime - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobMime - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobMime - -- type: loadout - id: LizardPlushieMusician - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobMusician - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobMusician - -- type: loadout - id: LizardPlushieQuartermaster - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobQuartermaster - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobQuartermaster - -- type: loadout - id: LizardPlushieCargoTechnician - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobCargoTechnician - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobCargotechnician - -- type: loadout - id: LizardPlushieSalvageSpecialist - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobSalvageSpecialist - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobSalvagespecialist - -- type: loadout - id: LizardPlushieChiefEngineer - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobChiefEngineer - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobChiefengineer - -- type: loadout - id: LizardPlushieTechnicalAssistant - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:DepartmentTimeRequirement - department: Engineering - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobTechnicalassistant - -- type: loadout - id: LizardPlushieStationEngineer - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobStationEngineer - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobStationengineer - -- type: loadout - id: LizardPlushieAtmosphericTechnician - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobAtmosphericTechnician - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobAtmospherictechnician - -- type: loadout - id: LizardPlushieResearchDirector - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobResearchDirector - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobResearchdirector - -- type: loadout - id: LizardPlushieScientist - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobScientist - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobScientist - -- type: loadout - id: LizardPlushieResearchAssistant - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:DepartmentTimeRequirement - department: Science - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobResearchassistant - -- type: loadout - id: LizardPlushieHeadofSecurity - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobHeadOfSecurity - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobHeadofsecurity - -- type: loadout - id: LizardPlushieWarden - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobWarden - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobWarden - -- type: loadout - id: LizardPlushieDetective - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobDetective - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobDetective - -- type: loadout - id: LizardPlushieSecurity - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobSecurityOfficer - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobSecurityofficer - - type: loadout id: SecStar effects: @@ -368,99 +32,3 @@ storage: back: - Dinkystar - -- type: loadout - id: LizardPlushieSecurityCadet - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:DepartmentTimeRequirement - department: Security - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobSecuritycadet - -- type: loadout - id: LizardPlushieChiefMedicalOfficer - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobChiefMedicalOfficer - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobChiefmedicalofficer - -- type: loadout - id: LizardPlushieMedicalDoctor - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobMedicalDoctor - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobMedicaldoctor - -- type: loadout - id: LizardPlushieMedicalIntern - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:DepartmentTimeRequirement - department: Medical - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobMedicalintern - -- type: loadout - id: LizardPlushieChemist - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobChemist - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobChemist - -- type: loadout - id: LizardPlushieParamedic - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobParamedic - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobParamedic - -- type: loadout - id: LizardPlushieReporter - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobReporter - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobReporter - -- type: loadout - id: LizardPlushiePsychologist - effects: - - !type:JobRequirementLoadoutEffect - requirement: - !type:RoleTimeRequirement - role: JobPsychologist - time: 72000 # 20hr - storage: - back: - - PlushieLizardJobPsychologist diff --git a/Resources/Prototypes/Loadouts/RoleLoadouts/medical.yml b/Resources/Prototypes/Loadouts/RoleLoadouts/medical.yml index 1cac8b94b9..469016fdc7 100644 --- a/Resources/Prototypes/Loadouts/RoleLoadouts/medical.yml +++ b/Resources/Prototypes/Loadouts/RoleLoadouts/medical.yml @@ -14,7 +14,7 @@ - Glasses - SurvivalMedical - Trinkets - - ChiefMedicalOfficerJobTrinkets +# - ChiefMedicalOfficerJobTrinkets - GroupSpeciesBreathToolMedical - type: roleLoadout @@ -31,7 +31,7 @@ - Glasses - SurvivalMedical - Trinkets - - MedicalDoctorJobTrinkets +# - MedicalDoctorJobTrinkets - GroupSpeciesBreathToolMedical - type: roleLoadout @@ -43,7 +43,7 @@ - Glasses - SurvivalMedical - Trinkets - - MedicalInternJobTrinkets +# - MedicalInternJobTrinkets - GroupSpeciesBreathToolMedical - type: roleLoadout @@ -58,7 +58,7 @@ - Glasses - SurvivalMedical - Trinkets - - ChemistJobTrinkets +# - ChemistJobTrinkets - GroupSpeciesBreathToolMedical - type: roleLoadout @@ -74,5 +74,5 @@ - Glasses - SurvivalMedical - Trinkets - - ParamedicJobTrinkets +# - ParamedicJobTrinkets - GroupSpeciesBreathToolMedical diff --git a/Resources/Prototypes/Loadouts/RoleLoadouts/role_loadouts.yml b/Resources/Prototypes/Loadouts/RoleLoadouts/role_loadouts.yml index 026cadfa46..84eec9f7cb 100644 --- a/Resources/Prototypes/Loadouts/RoleLoadouts/role_loadouts.yml +++ b/Resources/Prototypes/Loadouts/RoleLoadouts/role_loadouts.yml @@ -9,7 +9,7 @@ - CaptainOuterClothing - Survival - Trinkets - - CaptainJobTrinkets + #- CaptainJobTrinkets - GroupSpeciesBreathTool - type: roleLoadout @@ -24,7 +24,7 @@ - Glasses - Survival - Trinkets - - HoPJobTrinkets + #- HoPJobTrinkets - GroupSpeciesBreathTool # Silicons @@ -53,7 +53,7 @@ - Glasses - Survival - Trinkets - - PassengerJobTrinkets + #- PassengerJobTrinkets - GroupSpeciesBreathTool - type: roleLoadout @@ -79,7 +79,7 @@ - Glasses - Survival - Trinkets - - ServiceWorkerJobTrinkets + #- ServiceWorkerJobTrinkets - GroupSpeciesBreathTool - type: roleLoadout @@ -94,7 +94,7 @@ - Glasses - Survival - Trinkets - - ChefJobTrinkets + #- ChefJobTrinkets - GroupSpeciesBreathTool - type: roleLoadout @@ -106,7 +106,7 @@ - Glasses - Survival - Trinkets - - LibrarianJobTrinkets + #- LibrarianJobTrinkets - GroupSpeciesBreathTool - type: roleLoadout @@ -119,7 +119,7 @@ - Glasses - Survival - Trinkets - - LawyerJobTrinkets + #- LawyerJobTrinkets - GroupSpeciesBreathTool - type: roleLoadout @@ -136,7 +136,7 @@ - Glasses - Survival - Trinkets - - ChaplainJobTrinkets + #- ChaplainJobTrinkets - GroupSpeciesBreathTool - type: roleLoadout @@ -165,7 +165,7 @@ - Glasses - Survival - Trinkets - - BotanistJobTrinkets + #- BotanistJobTrinkets - GroupSpeciesBreathTool - type: roleLoadout @@ -197,7 +197,7 @@ - Glasses - SurvivalMime - Trinkets - - MimeJobTrinkets + #- MimeJobTrinkets - type: roleLoadout id: JobMusician @@ -210,7 +210,7 @@ - Glasses - Survival - Trinkets - - MusicianJobTrinkets + #- MusicianJobTrinkets - Instruments - GroupSpeciesBreathTool @@ -228,7 +228,7 @@ - Glasses - Survival - Trinkets - - QuartermasterJobTrinkets + #- QuartermasterJobTrinkets - GroupSpeciesBreathTool - type: roleLoadout @@ -244,7 +244,7 @@ - Glasses - Survival - Trinkets - - CargoTechnicianJobTrinkets + #- CargoTechnicianJobTrinkets - GroupSpeciesBreathTool - type: roleLoadout @@ -257,7 +257,7 @@ - Glasses - Survival - Trinkets - - SalvageSpecialistJobTrinkets + #- SalvageSpecialistJobTrinkets - GroupSpeciesBreathTool # Engineering @@ -273,7 +273,7 @@ - ChiefEngineerShoes - SurvivalExtended - Trinkets - - ChiefEngineerJobTrinkets + #- ChiefEngineerJobTrinkets - GroupSpeciesBreathTool - type: roleLoadout @@ -284,7 +284,7 @@ - StationEngineerBackpack - SurvivalExtended - Trinkets - - TechnicalAssistantJobTrinkets + #- TechnicalAssistantJobTrinkets - GroupSpeciesBreathTool - type: roleLoadout @@ -299,7 +299,7 @@ - StationEngineerID - SurvivalExtended - Trinkets - - StationEngineerJobTrinkets + #- StationEngineerJobTrinkets - GroupSpeciesBreathTool - type: roleLoadout @@ -312,7 +312,7 @@ - AtmosphericTechnicianShoes - SurvivalExtended - Trinkets - - AtmosphericTechnicianJobTrinkets + #- AtmosphericTechnicianJobTrinkets - GroupSpeciesBreathTool # Science @@ -330,7 +330,7 @@ - Glasses - Survival - Trinkets - - ResearchDirectorJobTrinkets + #- ResearchDirectorJobTrinkets - GroupSpeciesBreathTool - type: roleLoadout @@ -348,7 +348,7 @@ - Glasses - Survival - Trinkets - - ScientistJobTrinkets + #- ScientistJobTrinkets - GroupSpeciesBreathTool - type: roleLoadout @@ -360,7 +360,7 @@ - Glasses - Survival - Trinkets - - ResearchAssistantJobTrinkets + #- ResearchAssistantJobTrinkets - GroupSpeciesBreathTool # Security @@ -429,7 +429,7 @@ - SecurityBackpack - SurvivalSecurity - Trinkets - - SecurityCadetJobTrinkets + #- SecurityCadetJobTrinkets - GroupSpeciesBreathToolSecurity # Wildcards @@ -442,7 +442,7 @@ - Glasses - Survival - Trinkets - - ReporterJobTrinkets + #- ReporterJobTrinkets - GroupSpeciesBreathTool - type: roleLoadout @@ -454,7 +454,7 @@ - Glasses - Survival - Trinkets - - PsychologistJobTrinkets + #- PsychologistJobTrinkets - GroupSpeciesBreathTool # These loadouts are used for non-crew spawns, like off-station antags and event mobs From 4981392249bff86aa2ab2745b1ccb22c6382684d Mon Sep 17 00:00:00 2001 From: PJBot Date: Mon, 26 Jan 2026 16:17:02 +0000 Subject: [PATCH 44/47] Automatic changelog update --- Resources/Changelog/Changelog.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Resources/Changelog/Changelog.yml b/Resources/Changelog/Changelog.yml index 3c391f6c4a..26a84bb920 100644 --- a/Resources/Changelog/Changelog.yml +++ b/Resources/Changelog/Changelog.yml @@ -1,11 +1,4 @@ Entries: -- author: Huaqas - changes: - - message: Vulpkanin now have Undergarments. - type: Add - id: 8959 - time: '2025-09-13T17:36:28.0000000+00:00' - url: https://github.com/space-wizards/space-station-14/pull/40321 - author: MissKay1994 changes: - message: Greatly reduced lethality of Man-O-War shuttle @@ -4005,3 +3998,11 @@ id: 9465 time: '2026-01-26T15:45:24.0000000+00:00' url: https://github.com/space-wizards/space-station-14/pull/42472 +- author: PJB3005 + changes: + - message: Job lizard plushies can be found in various lockers around the station + instead of being loadout items. + type: Tweak + id: 9466 + time: '2026-01-26T16:15:54.0000000+00:00' + url: https://github.com/space-wizards/space-station-14/pull/42545 From 3172ada5636fc068ac1f85893ba4c4cab0615f26 Mon Sep 17 00:00:00 2001 From: Absotively Date: Mon, 26 Jan 2026 11:18:29 -0700 Subject: [PATCH 45/47] Move character preview handling into a specialized control (#41252) * Move character preview handling into a specialized control Co-authored-by: Quantum-cross <7065792+Quantum-cross@users.noreply.github.com> * Restore job name that I accidentally removed from character picker buttons * Just resolve dependencies the standard way --------- Co-authored-by: Quantum-cross <7065792+Quantum-cross@users.noreply.github.com> Co-authored-by: Janet Blackquill --- Content.Client/Lobby/LobbyUIController.cs | 177 +---------------- .../Lobby/UI/CharacterPickerButton.xaml | 11 +- .../Lobby/UI/CharacterPickerButton.xaml.cs | 29 +-- .../Lobby/UI/CharacterSetupGui.xaml.cs | 7 +- .../Lobby/UI/HumanoidProfileEditor.xaml | 3 +- .../Lobby/UI/HumanoidProfileEditor.xaml.cs | 30 +-- .../Lobby/UI/LobbyCharacterPreviewPanel.xaml | 10 +- .../UI/LobbyCharacterPreviewPanel.xaml.cs | 34 +--- .../ProfilePreviewSpriteView.Humanoid.cs | 182 ++++++++++++++++++ .../ProfilePreviewSpriteView.cs | 82 ++++++++ 10 files changed, 299 insertions(+), 266 deletions(-) create mode 100644 Content.Client/Lobby/UI/ProfileEditorControls/ProfilePreviewSpriteView.Humanoid.cs create mode 100644 Content.Client/Lobby/UI/ProfileEditorControls/ProfilePreviewSpriteView.cs diff --git a/Content.Client/Lobby/LobbyUIController.cs b/Content.Client/Lobby/LobbyUIController.cs index b76652f050..29d1933f7c 100644 --- a/Content.Client/Lobby/LobbyUIController.cs +++ b/Content.Client/Lobby/LobbyUIController.cs @@ -37,7 +37,6 @@ public sealed class LobbyUIController : UIController, IOnStateEntered - /// Applies the highest priority job's clothes to the dummy. - /// - public void GiveDummyJobClothesLoadout(EntityUid dummy, JobPrototype? jobProto, HumanoidCharacterProfile profile) - { - var job = jobProto ?? GetPreferredJob(profile); - GiveDummyJobClothes(dummy, profile, job); - - if (_prototypeManager.HasIndex(LoadoutSystem.GetJobPrototype(job.ID))) - { - var loadout = profile.GetLoadoutOrDefault(LoadoutSystem.GetJobPrototype(job.ID), _playerManager.LocalSession, profile.Species, EntityManager, _prototypeManager); - GiveDummyLoadout(dummy, loadout); - } - } - - /// - /// Gets the highest priority job for the profile. - /// - public JobPrototype GetPreferredJob(HumanoidCharacterProfile profile) - { - var highPriorityJob = profile.JobPriorities.FirstOrDefault(p => p.Value == JobPriority.High).Key; - // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract (what is resharper smoking?) - return _prototypeManager.Index(highPriorityJob.Id ?? SharedGameTicker.FallbackOverflowJob); - } - - public void GiveDummyLoadout(EntityUid uid, RoleLoadout? roleLoadout) - { - if (roleLoadout == null) - return; - - foreach (var group in roleLoadout.SelectedLoadouts.Values) - { - foreach (var loadout in group) - { - if (!_prototypeManager.Resolve(loadout.Prototype, out var loadoutProto)) - continue; - - _spawn.EquipStartingGear(uid, loadoutProto); - } - } - } - - /// - /// Applies the specified job's clothes to the dummy. - /// - public void GiveDummyJobClothes(EntityUid dummy, HumanoidCharacterProfile profile, JobPrototype job) - { - if (!_inventory.TryGetSlots(dummy, out var slots)) - return; - - // Apply loadout - if (profile.Loadouts.TryGetValue(job.ID, out var jobLoadout)) - { - foreach (var loadouts in jobLoadout.SelectedLoadouts.Values) - { - foreach (var loadout in loadouts) - { - if (!_prototypeManager.Resolve(loadout.Prototype, out var loadoutProto)) - continue; - - // TODO: Need some way to apply starting gear to an entity and replace existing stuff coz holy fucking shit dude. - foreach (var slot in slots) - { - // Try startinggear first - if (_prototypeManager.Resolve(loadoutProto.StartingGear, out var loadoutGear)) - { - var itemType = ((IEquipmentLoadout) loadoutGear).GetGear(slot.Name); - - if (_inventory.TryUnequip(dummy, slot.Name, out var unequippedItem, silent: true, force: true, reparent: false)) - { - EntityManager.DeleteEntity(unequippedItem.Value); - } - - if (itemType != string.Empty) - { - var item = EntityManager.SpawnEntity(itemType, MapCoordinates.Nullspace); - _inventory.TryEquip(dummy, item, slot.Name, true, true); - } - } - else - { - var itemType = ((IEquipmentLoadout) loadoutProto).GetGear(slot.Name); - - if (_inventory.TryUnequip(dummy, slot.Name, out var unequippedItem, silent: true, force: true, reparent: false)) - { - EntityManager.DeleteEntity(unequippedItem.Value); - } - - if (itemType != string.Empty) - { - var item = EntityManager.SpawnEntity(itemType, MapCoordinates.Nullspace); - _inventory.TryEquip(dummy, item, slot.Name, true, true); - } - } - } - } - } - } - - if (!_prototypeManager.Resolve(job.StartingGear, out var gear)) - return; - - foreach (var slot in slots) - { - var itemType = ((IEquipmentLoadout) gear).GetGear(slot.Name); - - if (_inventory.TryUnequip(dummy, slot.Name, out var unequippedItem, silent: true, force: true, reparent: false)) - { - EntityManager.DeleteEntity(unequippedItem.Value); - } - - if (itemType != string.Empty) - { - var item = EntityManager.SpawnEntity(itemType, MapCoordinates.Nullspace); - _inventory.TryEquip(dummy, item, slot.Name, true, true); - } - } - } - - /// - /// Loads the profile onto a dummy entity. - /// - public EntityUid LoadProfileEntity(HumanoidCharacterProfile? humanoid, JobPrototype? job, bool jobClothes) - { - EntityUid dummyEnt; - - EntProtoId? previewEntity = null; - if (humanoid != null && jobClothes) - { - job ??= GetPreferredJob(humanoid); - - previewEntity = job.JobPreviewEntity ?? (EntProtoId?)job?.JobEntity; - } - - if (previewEntity != null) - { - // Special type like borg or AI, do not spawn a human just spawn the entity. - dummyEnt = EntityManager.SpawnEntity(previewEntity, MapCoordinates.Nullspace); - return dummyEnt; - } - else if (humanoid is not null) - { - var dummy = _prototypeManager.Index(humanoid.Species).DollPrototype; - dummyEnt = EntityManager.SpawnEntity(dummy, MapCoordinates.Nullspace); - _visualBody.ApplyProfileTo(dummyEnt, humanoid); - } - else - { - dummyEnt = EntityManager.SpawnEntity(_prototypeManager.Index(HumanoidCharacterProfile.DefaultSpecies).DollPrototype, MapCoordinates.Nullspace); - } - - if (humanoid != null && jobClothes) - { - DebugTools.Assert(job != null); - - GiveDummyJobClothes(dummyEnt, humanoid, job); - - if (_prototypeManager.HasIndex(LoadoutSystem.GetJobPrototype(job.ID))) - { - var loadout = humanoid.GetLoadoutOrDefault(LoadoutSystem.GetJobPrototype(job.ID), _playerManager.LocalSession, humanoid.Species, EntityManager, _prototypeManager); - GiveDummyLoadout(dummyEnt, loadout); - } - } - - return dummyEnt; - } - - #endregion } diff --git a/Content.Client/Lobby/UI/CharacterPickerButton.xaml b/Content.Client/Lobby/UI/CharacterPickerButton.xaml index 723da433a1..c1c15322c0 100644 --- a/Content.Client/Lobby/UI/CharacterPickerButton.xaml +++ b/Content.Client/Lobby/UI/CharacterPickerButton.xaml @@ -1,15 +1,16 @@ - + public event Action? Save; - /// - /// Entity used for the profile editor preview - /// - public EntityUid PreviewDummy; - /// /// Temporary override of their selected job, used to preview roles. /// @@ -620,15 +615,10 @@ namespace Content.Client.Lobby.UI /// private void ReloadPreview() { - _entManager.DeleteEntity(PreviewDummy); - PreviewDummy = EntityUid.Invalid; - - if (Profile == null || !_prototypeManager.HasIndex(Profile.Species)) + if (Profile == null) return; - PreviewDummy = _controller.LoadProfileEntity(Profile, JobOverride, ShowClothes.Pressed); - SpriteView.SetEntity(PreviewDummy); - _entManager.System().SetEntityName(PreviewDummy, Profile.Name); + SpriteView.LoadPreview(Profile, JobOverride, ShowClothes.Pressed); // Check and set the dirty flag to enable the save/reset buttons as appropriate. SetDirty(); @@ -679,16 +669,15 @@ namespace Content.Client.Lobby.UI } } - /// /// A slim reload that only updates the entity itself and not any of the job entities, etc. /// private void ReloadProfilePreview() { - if (Profile == null || !_entManager.EntityExists(PreviewDummy)) + if (Profile == null) return; - _entManager.System().ApplyProfileTo(PreviewDummy, Profile); + SpriteView.ReloadProfilePreview(Profile); // Check and set the dirty flag to enable the save/reset buttons as appropriate. SetDirty(); @@ -1040,13 +1029,6 @@ namespace Content.Client.Lobby.UI ReloadPreview(); } - protected override void ExitedTree() - { - base.ExitedTree(); - _entManager.DeleteEntity(PreviewDummy); - PreviewDummy = EntityUid.Invalid; - } - private void SetAge(int newAge) { Profile = Profile?.WithAge(newAge); @@ -1104,7 +1086,7 @@ namespace Content.Client.Lobby.UI if (!IsDirty) return; - _entManager.System().SetEntityName(PreviewDummy, newName); + SpriteView.SetName(newName); } private void SetSpawnPriority(SpawnPriorityPreference newSpawnPriority) @@ -1324,7 +1306,7 @@ namespace Content.Client.Lobby.UI // I tried disabling the button but it looks sorta goofy as it only takes a frame or two to save _imaging = true; - await _entManager.System().Export(PreviewDummy, dir, includeId: false); + await _entManager.System().Export(SpriteView.PreviewDummy, dir, includeId: false); _imaging = false; } diff --git a/Content.Client/Lobby/UI/LobbyCharacterPreviewPanel.xaml b/Content.Client/Lobby/UI/LobbyCharacterPreviewPanel.xaml index 997507414c..f1f9627d2c 100644 --- a/Content.Client/Lobby/UI/LobbyCharacterPreviewPanel.xaml +++ b/Content.Client/Lobby/UI/LobbyCharacterPreviewPanel.xaml @@ -1,7 +1,8 @@ + xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls" + xmlns:profile="clr-namespace:Content.Client.Lobby.UI.ProfileEditorControls"> @@ -10,7 +11,12 @@ Visible="False">