diff --git a/Content.Client/Audio/ContentAudioSystem.cs b/Content.Client/Audio/ContentAudioSystem.cs
index f62b34b492c..181edb8a46d 100644
--- a/Content.Client/Audio/ContentAudioSystem.cs
+++ b/Content.Client/Audio/ContentAudioSystem.cs
@@ -29,7 +29,8 @@ public sealed partial class ContentAudioSystem : SharedContentAudioSystem
public const float AmbientMusicMultiplier = 3f;
public const float LobbyMultiplier = 3f;
public const float InterfaceMultiplier = 2f;
-
+ public const float TTSMultiplier = 3f; // OpenSpace TTS
+
public override void Initialize()
{
base.Initialize();
diff --git a/Content.Client/Credits/CreditsWindow.xaml.cs b/Content.Client/Credits/CreditsWindow.xaml.cs
index c068eb5a8cd..b5b136de265 100644
--- a/Content.Client/Credits/CreditsWindow.xaml.cs
+++ b/Content.Client/Credits/CreditsWindow.xaml.cs
@@ -359,6 +359,7 @@ void AddSection(string title, string path, bool markup = false)
ss14ContributorsContainer.AddChild(label);
}
+ AddSection(Loc.GetString("credits-window-tts-title"), "TTS.txt"); // OpenSpace-TTS
AddSection(Loc.GetString("credits-window-contributors-section-title"), "GitHub.txt");
AddSection(Loc.GetString("credits-window-codebases-section-title"), "SpaceStation13.txt");
AddSection(Loc.GetString("credits-window-original-remake-team-section-title"), "OriginalRemake.txt");
diff --git a/Content.Client/Entry/EntryPoint.cs b/Content.Client/Entry/EntryPoint.cs
index e0358d54e75..f5753b9d493 100644
--- a/Content.Client/Entry/EntryPoint.cs
+++ b/Content.Client/Entry/EntryPoint.cs
@@ -24,6 +24,7 @@
using Content.Client.UserInterface;
using Content.Client.Viewport;
using Content.Client.Voting;
+using Content.Client._OpenSpace.TTS; // OpenSpace TTS
using Content.Shared.Ame.Components;
using Content.Shared.FeedbackSystem;
using Content.Shared.Gravity;
diff --git a/Content.Client/IoC/ClientContentIoC.cs b/Content.Client/IoC/ClientContentIoC.cs
index efaf88b0522..d876b418d90 100644
--- a/Content.Client/IoC/ClientContentIoC.cs
+++ b/Content.Client/IoC/ClientContentIoC.cs
@@ -22,6 +22,7 @@
using Content.Shared.Administration.Logs;
using Content.Client.Lobby;
using Content.Client.Players.RateLimiting;
+using Content.Client._OpenSpace.TTS; // OpenSpace TTS
using Content.Shared.Administration.Managers;
using Content.Shared.Chat;
using Content.Shared.FeedbackSystem;
diff --git a/Content.Client/Lobby/UI/HumanoidProfileEditor.Appearance.cs b/Content.Client/Lobby/UI/HumanoidProfileEditor.Appearance.cs
index ddc9752b1ed..1b0a661a5e1 100644
--- a/Content.Client/Lobby/UI/HumanoidProfileEditor.Appearance.cs
+++ b/Content.Client/Lobby/UI/HumanoidProfileEditor.Appearance.cs
@@ -1,5 +1,6 @@
using System.Linq;
using Content.Client.UserInterface.Systems.Guidebook;
+using Content.Shared._OpenSpace.TTS; // OpenSpace-TTS
using Content.Shared.Guidebook;
using Content.Shared.Humanoid;
using Content.Shared.Humanoid.Prototypes;
@@ -226,6 +227,14 @@ private void SetGender(Gender newGender)
ReloadPreview();
}
+ // OpenSpace-TTS Start
+ private void SetVoice(string newVoice)
+ {
+ Profile = Profile?.WithVoice(newVoice);
+ IsDirty = true;
+ }
+ // OpenSpace-TTS End
+
private void SetSpawnPriority(SpawnPriorityPreference newSpawnPriority)
{
Profile = Profile?.WithSpawnPriorityPreference(newSpawnPriority);
diff --git a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml
index 84c5b75d1cb..f31edc1cb2b 100644
--- a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml
+++ b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml
@@ -92,6 +92,14 @@
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
index 3b444bf4162..2c8522e214f 100644
--- a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
+++ b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
@@ -2,6 +2,7 @@
using Content.Client.Message;
using Content.Client.Players.PlayTimeTracking;
using Content.Client.Sprite;
+using Content.Shared._OpenSpace.OpenCVars; // OpenSpace-TTS
using Content.Shared.CCVar;
using Content.Shared.GameTicking;
using Content.Shared.Humanoid;
@@ -191,6 +192,18 @@ public HumanoidProfileEditor(
#endregion Gender
+ // OpenSpace-TTS Start
+ #region Voice
+
+ if (configurationManager.GetCVar(OpenCVars.TTSEnabled))
+ {
+ TTSContainer.Visible = true;
+ InitializeVoice();
+ }
+
+ #endregion
+ // OpenSpace-TTS End
+
RefreshSpecies();
SpeciesButton.OnItemSelected += args =>
@@ -288,6 +301,8 @@ public HumanoidProfileEditor(
RefreshFlavorText();
+ UpdateTtsVoicesControls(); // OpenSpace-TTS
+
#region Dummy
SpriteRotateLeft.OnPressed += _ =>
@@ -375,6 +390,7 @@ public void SetProfile(HumanoidCharacterProfile? profile, int? slot)
UpdateAgeEdit();
UpdateEyePickers();
UpdateSaveButton();
+ UpdateTtsVoicesControls(); // OpenSpace-TTS
UpdateMarkings();
RefreshAntags();
diff --git a/Content.Client/Options/UI/Tabs/AudioTab.xaml b/Content.Client/Options/UI/Tabs/AudioTab.xaml
index 5764755bb9a..565d3d1f545 100644
--- a/Content.Client/Options/UI/Tabs/AudioTab.xaml
+++ b/Content.Client/Options/UI/Tabs/AudioTab.xaml
@@ -6,6 +6,8 @@
+
diff --git a/Content.Client/Options/UI/Tabs/AudioTab.xaml.cs b/Content.Client/Options/UI/Tabs/AudioTab.xaml.cs
index d57f36e74f8..cb9928ba9aa 100644
--- a/Content.Client/Options/UI/Tabs/AudioTab.xaml.cs
+++ b/Content.Client/Options/UI/Tabs/AudioTab.xaml.cs
@@ -1,5 +1,6 @@
using Content.Client.Administration.Managers;
using Content.Client.Audio;
+using Content.Shared._OpenSpace.OpenCVars; // OpenSpace-TTS
using Content.Shared.CCVar;
using Robust.Client.Audio;
using Robust.Client.AutoGenerated;
@@ -53,6 +54,13 @@ public AudioTab()
SliderVolumeInterface,
scale: ContentAudioSystem.InterfaceMultiplier);
+ // OpenSpace-TTS Start
+ Control.AddOptionPercentSlider(
+ OpenCVars.TTSVolume,
+ SliderVolumeTTS,
+ scale: ContentAudioSystem.TTSMultiplier);
+ // OpenSpace-TTS End
+
Control.AddOptionSlider(
CCVars.MaxAmbientSources,
SliderMaxAmbienceSounds,
diff --git a/Content.Client/VoiceMask/VoiceMaskBoundUserInterface.cs b/Content.Client/VoiceMask/VoiceMaskBoundUserInterface.cs
index b2b374cac5b..07a85825c58 100644
--- a/Content.Client/VoiceMask/VoiceMaskBoundUserInterface.cs
+++ b/Content.Client/VoiceMask/VoiceMaskBoundUserInterface.cs
@@ -28,6 +28,7 @@ protected override void Open()
_window.OnVerbChange += verb => SendMessage(new VoiceMaskChangeVerbMessage(verb));
_window.OnToggle += OnToggle;
_window.OnAccentToggle += OnAccentToggle;
+ _window.OnVoiceChange += voice => SendMessage(new VoiceMaskChangeVoiceMessage(voice)); // OpenSpace-TTS
}
private void OnNameSelected(string name)
@@ -52,7 +53,8 @@ protected override void UpdateState(BoundUserInterfaceState state)
return;
}
- _window.UpdateState(cast.Name, cast.Verb, cast.Active, cast.AccentHide);
+ _window.UpdateState(cast.Name, cast.Verb, cast.Active, cast.AccentHide, cast.Voice); //cast.Voice OpenSpace-TTS
+
}
protected override void Dispose(bool disposing)
diff --git a/Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml b/Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml
index 18416757b9e..5c81c693371 100644
--- a/Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml
+++ b/Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml
@@ -14,5 +14,11 @@
+
+
+
+
+
+
diff --git a/Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml.cs b/Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml.cs
index a5e70362831..698be989dbb 100644
--- a/Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml.cs
+++ b/Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml.cs
@@ -1,8 +1,12 @@
+using System.Linq; // OpenSpace-TTS
+using Content.Shared._OpenSpace.TTS; // OpenSpace-TTS
using Content.Client.UserInterface.Controls;
+using Content.Shared._OpenSpace.OpenCVars; // OpenSpace-TTS
using Content.Shared.Speech;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Configuration; // OpenSpace-TTS
using Robust.Shared.Prototypes;
namespace Content.Client.VoiceMask;
@@ -14,8 +18,10 @@ public sealed partial class VoiceMaskNameChangeWindow : FancyWindow
public Action? OnVerbChange;
public Action? OnToggle;
public Action? OnAccentToggle;
+ public Action? OnVoiceChange; // OpenSpace-TTS
private List<(string, string)> _verbs = new();
+ private List _voices = new(); // OpenSpace-TTS
private string? _verb;
@@ -36,6 +42,14 @@ public VoiceMaskNameChangeWindow()
ToggleButton.OnPressed += args => OnToggle?.Invoke();
ToggleAccentButton.OnPressed += args => OnAccentToggle?.Invoke();
+
+ // OpenSpace-TTS Start
+ if (IoCManager.Resolve().GetCVar(OpenCVars.TTSEnabled))
+ {
+ TTSContainer.Visible = true;
+ ReloadVoices(IoCManager.Resolve());
+ }
+ // OpenSpace-TTS End
}
public void ReloadVerbs(IPrototypeManager proto)
@@ -69,7 +83,30 @@ private void AddVerb(string name, string? verb)
SpeechVerbSelector.SelectId(id);
}
- public void UpdateState(string name, string? verb, bool active, bool accentHide)
+ // OpenSpace-TTS Start
+ private void ReloadVoices(IPrototypeManager proto)
+ {
+ VoiceSelector.OnItemSelected += args =>
+ {
+ VoiceSelector.SelectId(args.Id);
+ if (VoiceSelector.SelectedMetadata != null)
+ OnVoiceChange!((string)VoiceSelector.SelectedMetadata);
+ };
+ _voices = proto
+ .EnumeratePrototypes()
+ .Where(o => o.RoundStart)
+ .OrderBy(o => Loc.GetString(o.Name))
+ .ToList();
+ for (var i = 0; i < _voices.Count; i++)
+ {
+ var name = Loc.GetString(_voices[i].Name);
+ VoiceSelector.AddItem(name);
+ VoiceSelector.SetItemMetadata(i, _voices[i].ID);
+ }
+ }
+ // OpenSpace-TTS End
+
+ public void UpdateState(string name, string? verb, bool active, bool accentHide, string voice) // OpenSpace-TTS
{
NameSelector.Text = name;
_verb = verb;
@@ -84,5 +121,11 @@ public void UpdateState(string name, string? verb, bool active, bool accentHide)
break;
}
}
+
+ // OpenSpace-TTS Start
+ var voiceIdx = _voices.FindIndex(v => v.ID == voice);
+ if (voiceIdx != -1)
+ VoiceSelector.Select(voiceIdx);
+ // OpenSpace-TTS End
}
}
diff --git a/Content.Client/_OpenSpace/TTS/HumanoidProfileEditor.TTS.cs b/Content.Client/_OpenSpace/TTS/HumanoidProfileEditor.TTS.cs
new file mode 100644
index 00000000000..ef06b74f78c
--- /dev/null
+++ b/Content.Client/_OpenSpace/TTS/HumanoidProfileEditor.TTS.cs
@@ -0,0 +1,67 @@
+using System.Linq;
+using Content.Client._OpenSpace.TTS;
+using Content.Client.Lobby;
+// using Content._OpenSpace.Interfaces.Shared;
+using Content.Shared._OpenSpace.TTS;
+using Content.Shared.Preferences;
+
+namespace Content.Client.Lobby.UI;
+
+public sealed partial class HumanoidProfileEditor
+{
+ private List _voiceList = new();
+
+ private void InitializeVoice()
+ {
+ _voiceList = _prototypeManager
+ .EnumeratePrototypes()
+ .Where(o => o.RoundStart)
+ .OrderBy(o => Loc.GetString(o.Name))
+ .ToList();
+
+ VoiceButton.OnItemSelected += args =>
+ {
+ VoiceButton.SelectId(args.Id);
+ SetVoice(_voiceList[args.Id].ID);
+ };
+
+ VoicePlayButton.OnPressed += _ => PlayPreviewTTS();
+ }
+
+ private void UpdateTtsVoicesControls()
+ {
+ if (Profile is null)
+ return;
+
+ VoiceButton.Clear();
+
+ var firstVoiceChoiceId = 1;
+ for (var i = 0; i < _voiceList.Count; i++)
+ {
+ var voice = _voiceList[i];
+ if (!HumanoidCharacterProfile.CanHaveVoice(voice, Profile.Sex))
+ continue;
+
+ var name = Loc.GetString(voice.Name);
+ VoiceButton.AddItem(name, i);
+
+ if (firstVoiceChoiceId == 1)
+ firstVoiceChoiceId = i;
+ }
+
+ var voiceChoiceId = _voiceList.FindIndex(x => x.ID == Profile.Voice);
+ if (!VoiceButton.TrySelectId(voiceChoiceId) &&
+ VoiceButton.TrySelectId(firstVoiceChoiceId))
+ {
+ SetVoice(_voiceList[firstVoiceChoiceId].ID);
+ }
+ }
+
+ private void PlayPreviewTTS()
+ {
+ if (Profile is null)
+ return;
+
+ _entManager.System().RequestPreviewTTS(Profile.Voice);
+ }
+}
diff --git a/Content.Client/_OpenSpace/TTS/TTSSystem.cs b/Content.Client/_OpenSpace/TTS/TTSSystem.cs
new file mode 100644
index 00000000000..5d230f90644
--- /dev/null
+++ b/Content.Client/_OpenSpace/TTS/TTSSystem.cs
@@ -0,0 +1,124 @@
+using Content.Shared.Chat;
+using Content.Shared._OpenSpace.OpenCVars;
+using Content.Shared._OpenSpace.TTS;
+using Robust.Client.Audio;
+using Robust.Client.ResourceManagement;
+using Robust.Shared.Audio;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Configuration;
+using Robust.Shared.ContentPack;
+using Robust.Shared.Utility;
+
+namespace Content.Client._OpenSpace.TTS;
+
+///
+/// Plays TTS audio in world
+///
+// ReSharper disable once InconsistentNaming
+public sealed class TTSSystem : EntitySystem
+{
+ [Dependency] private readonly IConfigurationManager _cfg = default!;
+ [Dependency] private readonly IResourceManager _res = default!;
+ [Dependency] private readonly AudioSystem _audio = default!;
+
+ private ISawmill _sawmill = default!;
+ private static MemoryContentRoot _contentRoot = new();
+ private static readonly ResPath Prefix = ResPath.Root / "TTS";
+
+ private static bool _contentRootAdded;
+
+ ///
+ /// Reducing the volume of the TTS when whispering. Will be converted to logarithm.
+ ///
+ private const float WhisperFade = 4f;
+
+ ///
+ /// The volume at which the TTS sound will not be heard.
+ ///
+ private const float MinimalVolume = -10f;
+
+ private float _volume = 0.0f;
+ private int _fileIdx = 0;
+
+ public override void Initialize()
+ {
+ if (!_contentRootAdded)
+ {
+ _contentRootAdded = true;
+ _res.AddRoot(Prefix, _contentRoot);
+ }
+
+ _sawmill = Logger.GetSawmill("tts");
+ _cfg.OnValueChanged(OpenCVars.TTSVolume, OnTtsVolumeChanged, true);
+ SubscribeNetworkEvent(OnPlayTTS);
+ }
+
+ public override void Shutdown()
+ {
+ base.Shutdown();
+ _cfg.UnsubValueChanged(OpenCVars.TTSVolume, OnTtsVolumeChanged);
+ }
+
+ public void RequestPreviewTTS(string voiceId)
+ {
+ RaiseNetworkEvent(new RequestPreviewTTSEvent(voiceId));
+ }
+
+ private void OnTtsVolumeChanged(float volume)
+ {
+ _volume = volume;
+ }
+
+ private void OnPlayTTS(PlayTTSEvent ev)
+ {
+ _sawmill.Verbose($"Play TTS audio {ev.Data.Length} bytes from {ev.SourceUid} entity");
+
+ var filePath = new ResPath($"{_fileIdx++}.ogg");
+ _contentRoot.AddOrUpdateFile(filePath, ev.Data);
+
+ var audioResource = new AudioResource();
+ audioResource.Load(IoCManager.Instance!, Prefix / filePath);
+
+ var audioParams = AudioParams.Default
+ .WithVolume(AdjustVolume(ev.IsWhisper))
+ .WithMaxDistance(AdjustDistance(ev.IsWhisper));
+
+ var soundSpecifier = new ResolvedPathSpecifier(Prefix / filePath);
+
+ if (ev.SourceUid != null)
+ {
+ var sourceUid = GetEntity(ev.SourceUid.Value);
+
+ if (!Exists(sourceUid) || Deleted(sourceUid))
+ {
+ _contentRoot.RemoveFile(filePath);
+ return;
+ }
+
+ _audio.PlayEntity(audioResource.AudioStream, sourceUid, soundSpecifier, audioParams);
+ }
+ else
+ {
+ _audio.PlayGlobal(audioResource.AudioStream, soundSpecifier, audioParams);
+ }
+
+ _contentRoot.RemoveFile(filePath);
+ }
+
+ private float AdjustVolume(bool isWhisper)
+ {
+ var volume = MinimalVolume + SharedAudioSystem.GainToVolume(_volume);
+
+ if (isWhisper)
+ {
+ volume -= SharedAudioSystem.GainToVolume(WhisperFade);
+ }
+
+ return volume;
+ }
+
+ private float AdjustDistance(bool isWhisper)
+ {
+ return isWhisper ? SharedChatSystem.WhisperMuffledRange : SharedChatSystem.VoiceRange;
+ }
+}
diff --git a/Content.IntegrationTests/Tests/Preferences/ServerDbSqliteTests.cs b/Content.IntegrationTests/Tests/Preferences/ServerDbSqliteTests.cs
index 9d237ef7f3c..5da2f76dde6 100644
--- a/Content.IntegrationTests/Tests/Preferences/ServerDbSqliteTests.cs
+++ b/Content.IntegrationTests/Tests/Preferences/ServerDbSqliteTests.cs
@@ -50,6 +50,7 @@ private static HumanoidCharacterProfile CharlieCharlieson()
Name = "Charlie Charlieson",
FlavorText = "The biggest boy around.",
Species = "Human",
+ Voice = "moriarti", // OpenSpace-TTS
Age = 21,
Appearance = new(
Color.Azure,
diff --git a/Content.Server.Database/Migrations/Sqlite/20260312190915_TTSVoice.Designer.cs b/Content.Server.Database/Migrations/Sqlite/20260312190915_TTSVoice.Designer.cs
new file mode 100644
index 00000000000..b77b8ca3e32
--- /dev/null
+++ b/Content.Server.Database/Migrations/Sqlite/20260312190915_TTSVoice.Designer.cs
@@ -0,0 +1,2053 @@
+//
+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("20260312190915_TTSVoice")]
+ partial class TTSVoice
+ {
+ ///
+ 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("OrganMarkings")
+ .HasColumnType("jsonb")
+ .HasColumnName("organ_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.Property("Voice")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("voice");
+
+ 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/20260312190915_TTSVoice.cs b/Content.Server.Database/Migrations/Sqlite/20260312190915_TTSVoice.cs
new file mode 100644
index 00000000000..0a1a753f083
--- /dev/null
+++ b/Content.Server.Database/Migrations/Sqlite/20260312190915_TTSVoice.cs
@@ -0,0 +1,40 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Content.Server.Database.Migrations.Sqlite
+{
+ ///
+ public partial class TTSVoice : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "voice",
+ table: "job");
+
+ migrationBuilder.AddColumn(
+ name: "voice",
+ table: "profile",
+ type: "TEXT",
+ nullable: false,
+ defaultValue: "");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "voice",
+ table: "profile");
+
+ migrationBuilder.AddColumn(
+ name: "voice",
+ table: "job",
+ type: "text",
+ nullable: false,
+ defaultValue: "");
+ }
+ }
+}
diff --git a/Content.Server.Database/Migrations/Sqlite/SqliteServerDbContextModelSnapshot.cs b/Content.Server.Database/Migrations/Sqlite/SqliteServerDbContextModelSnapshot.cs
index b7ae8c5d1fb..723f8728fd9 100644
--- a/Content.Server.Database/Migrations/Sqlite/SqliteServerDbContextModelSnapshot.cs
+++ b/Content.Server.Database/Migrations/Sqlite/SqliteServerDbContextModelSnapshot.cs
@@ -1062,6 +1062,11 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasColumnType("TEXT")
.HasColumnName("species");
+ b.Property("Voice")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("voice");
+
b.HasKey("Id")
.HasName("PK_profile");
diff --git a/Content.Server.Database/Model.cs b/Content.Server.Database/Model.cs
index f54bba7e44c..9ec08da4d26 100644
--- a/Content.Server.Database/Model.cs
+++ b/Content.Server.Database/Model.cs
@@ -331,6 +331,7 @@ public class Profile
public string Sex { get; set; } = null!;
public string Gender { get; set; } = null!;
public string Species { get; set; } = null!;
+ public string Voice { get; set; } = null!; // OpenSpace-TTS
[Column(TypeName = "jsonb")] public JsonDocument? OrganMarkings { get; set; } = null!;
[Column(TypeName = "jsonb")] public JsonDocument? Markings { get; set; } = null!;
public string HairName { get; set; } = null!;
diff --git a/Content.Server/Chat/Systems/ChatSystem.cs b/Content.Server/Chat/Systems/ChatSystem.cs
index 9ea04ca3bce..42b8bf7ee86 100644
--- a/Content.Server/Chat/Systems/ChatSystem.cs
+++ b/Content.Server/Chat/Systems/ChatSystem.cs
@@ -414,7 +414,7 @@ private void SendEntitySpeak(
SendInVoiceRange(ChatChannel.Local, message, wrappedMessage, source, range);
- var ev = new EntitySpokeEvent(source, message, null, null);
+ var ev = new EntitySpokeEvent(source, message, originalMessage, null, null); //originalMessage OpenSpace-TTS
RaiseLocalEvent(source, ev, true);
// To avoid logging any messages sent by entities that are not players, like vendors, cloning, etc.
@@ -508,7 +508,7 @@ private void SendEntityWhisper(
_replay.RecordServerMessage(new ChatMessage(ChatChannel.Whisper, message, wrappedMessage, GetNetEntity(source), null, MessageRangeHideChatForReplay(range)));
- var ev = new EntitySpokeEvent(source, message, channel, obfuscatedMessage);
+ var ev = new EntitySpokeEvent(source, message, originalMessage, channel, obfuscatedMessage); //originalMessage OpenSpace-TTS
RaiseLocalEvent(source, ev, true);
if (!hideLog)
if (originalMessage == message)
diff --git a/Content.Server/Content.Server.csproj b/Content.Server/Content.Server.csproj
index 10d4bd56ab8..d3d51617eb9 100644
--- a/Content.Server/Content.Server.csproj
+++ b/Content.Server/Content.Server.csproj
@@ -11,6 +11,10 @@
+
diff --git a/Content.Server/Database/ServerDbBase.cs b/Content.Server/Database/ServerDbBase.cs
index 5ed8557c2a4..6f9da5437d0 100644
--- a/Content.Server/Database/ServerDbBase.cs
+++ b/Content.Server/Database/ServerDbBase.cs
@@ -212,6 +212,7 @@ private Profile ConvertProfiles(HumanoidCharacterProfile humanoid, int slot, Pro
profile.CharacterName = humanoid.Name;
profile.FlavorText = humanoid.FlavorText;
profile.Species = humanoid.Species;
+ profile.Voice = humanoid.Voice; // OpenSpace-TTS
profile.Age = humanoid.Age;
profile.Sex = humanoid.Sex.ToString();
profile.Gender = humanoid.Gender.ToString();
diff --git a/Content.Server/Entry/EntryPoint.cs b/Content.Server/Entry/EntryPoint.cs
index fb6d3e282d9..f32b2b44326 100644
--- a/Content.Server/Entry/EntryPoint.cs
+++ b/Content.Server/Entry/EntryPoint.cs
@@ -24,6 +24,7 @@
using Content.Server.ServerInfo;
using Content.Server.ServerUpdates;
using Content.Server.Voting.Managers;
+using Content.Server._OpenSpace.TTS; // OpenSpace-TTS
using Content.Shared.CCVar;
using Content.Shared.FeedbackSystem;
using Content.Shared.Kitchen;
@@ -137,6 +138,7 @@ public override void Init()
_watchlistWebhookManager.Initialize();
_job.Initialize();
_rateLimit.Initialize();
+ IoCManager.Resolve().Initialize(); // OpenSpace-TTS
}
public override void PostInit()
diff --git a/Content.Server/IoC/ServerContentIoC.cs b/Content.Server/IoC/ServerContentIoC.cs
index 1c6d940e20f..86069aad541 100644
--- a/Content.Server/IoC/ServerContentIoC.cs
+++ b/Content.Server/IoC/ServerContentIoC.cs
@@ -24,6 +24,7 @@
using Content.Server.ServerUpdates;
using Content.Server.Voting.Managers;
using Content.Server.Worldgen.Tools;
+using Content.Server._OpenSpace.TTS;
using Content.Shared.Administration.Logs;
using Content.Shared.Administration.Managers;
using Content.Shared.Chat;
@@ -84,5 +85,6 @@ public static void Register(IDependencyCollection deps)
deps.Register();
deps.Register();
deps.Register();
+ IoCManager.Register(); // OpenSpace-TTS
}
}
diff --git a/Content.Server/Preferences/Managers/ServerPreferencesManager.cs b/Content.Server/Preferences/Managers/ServerPreferencesManager.cs
index 5511375e5f7..d3cfc522c62 100644
--- a/Content.Server/Preferences/Managers/ServerPreferencesManager.cs
+++ b/Content.Server/Preferences/Managers/ServerPreferencesManager.cs
@@ -105,6 +105,11 @@ internal HumanoidCharacterProfile ConvertProfiles(Profile profile)
if (Enum.TryParse(profile.Gender, true, out var genderVal))
gender = genderVal;
+ // OpenSpace-TTS Start
+ var voice = profile.Voice;
+ if (voice == String.Empty)
+ voice = HumanoidCharacterProfile.DefaultSexVoice[sex];
+ // OpenSpace-TTS End
var markings =
new Dictionary, Dictionary>>();
@@ -171,6 +176,7 @@ internal HumanoidCharacterProfile ConvertProfiles(Profile profile)
profile.CharacterName,
profile.FlavorText,
species,
+ voice, // OpenSpace-TTS
profile.Age,
sex,
gender,
diff --git a/Content.Server/Telephone/TelephoneSystem.cs b/Content.Server/Telephone/TelephoneSystem.cs
index 0e3090c77eb..025883e4b75 100644
--- a/Content.Server/Telephone/TelephoneSystem.cs
+++ b/Content.Server/Telephone/TelephoneSystem.cs
@@ -13,6 +13,7 @@
using Content.Shared.Speech;
using Content.Shared.Speech.Components;
using Content.Shared.Telephone;
+using Content.Shared._OpenSpace.TTS;
using Robust.Server.GameObjects;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Timing;
@@ -113,7 +114,18 @@ private void OnTelephoneMessageReceived(Entity entity, ref T
var range = args.TelephoneSource.Comp.LinkedTelephones.Count > 1 ? ChatTransmitRange.HideChat : ChatTransmitRange.GhostRangeLimit;
var volume = entity.Comp.SpeakerVolume == TelephoneVolume.Speak ? InGameICChatType.Speak : InGameICChatType.Whisper;
-
+ // OpenSpace-TTS Start
+ // If speaker entity has TTS, the telephone will speak with the same voice
+ if(TryComp(args.MessageSource, out var ttsSpeaker))
+ {
+ EntityManager.EnsureComponent(entity, out var ttsTelephone);
+ ttsTelephone.VoicePrototypeId = ttsSpeaker.VoicePrototypeId;
+ }
+ else // Remove TTS if the speaker has no TTS
+ {
+ EntityManager.RemoveComponent(entity);
+ }
+ // OpenSpace-TTS End
_chat.TrySendInGameICMessage(speaker, args.Message, volume, range, nameOverride: name, checkRadioPrefix: false);
}
diff --git a/Content.Server/VoiceMask/VoiceMaskSystem.cs b/Content.Server/VoiceMask/VoiceMaskSystem.cs
index a67bfb8b669..0ca1ac4ca23 100644
--- a/Content.Server/VoiceMask/VoiceMaskSystem.cs
+++ b/Content.Server/VoiceMask/VoiceMaskSystem.cs
@@ -54,6 +54,7 @@ public override void Initialize()
SubscribeLocalEvent>(OnTransformSpeechImplant, before: [typeof(AccentSystem)]);
Subs.CVar(_cfgManager, CCVars.MaxNameLength, value => _maxNameLength = value, true);
+ InitializeTTS(); // OpenSpace-TTS
}
///
@@ -191,7 +192,7 @@ private void OpenUI(VoiceMaskSetNameEvent ev)
private void UpdateUI(Entity entity)
{
if (_uiSystem.HasUi(entity, VoiceMaskUIKey.Key))
- _uiSystem.SetUiState(entity.Owner, VoiceMaskUIKey.Key, new VoiceMaskBuiState(GetCurrentVoiceName(entity), entity.Comp.VoiceMaskSpeechVerb, entity.Comp.Active, entity.Comp.AccentHide));
+ _uiSystem.SetUiState(entity.Owner, VoiceMaskUIKey.Key, new VoiceMaskBuiState(GetCurrentVoiceName(entity), entity.Comp.VoiceMaskSpeechVerb, entity.Comp.Active, entity.Comp.AccentHide, entity.Comp.VoiceId)); //entity.Comp.VoiceId OpenSpace-TTS
}
#endregion
diff --git a/Content.Server/_OpenSpace/TTS/TTSManager.cs b/Content.Server/_OpenSpace/TTS/TTSManager.cs
new file mode 100644
index 00000000000..fd14f5ac5bb
--- /dev/null
+++ b/Content.Server/_OpenSpace/TTS/TTSManager.cs
@@ -0,0 +1,200 @@
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Web;
+using Content.Shared._OpenSpace.OpenCVars;
+using Prometheus;
+using Robust.Shared.Configuration;
+
+namespace Content.Server._OpenSpace.TTS;
+
+// ReSharper disable once InconsistentNaming
+public sealed class TTSManager
+{
+ private static readonly Histogram RequestTimings = Metrics.CreateHistogram(
+ "tts_req_timings",
+ "Timings of TTS API requests",
+ new HistogramConfiguration()
+ {
+ LabelNames = new[] {"type"},
+ Buckets = Histogram.ExponentialBuckets(.1, 1.5, 10),
+ });
+
+ private static readonly Counter WantedCount = Metrics.CreateCounter(
+ "tts_wanted_count",
+ "Amount of wanted TTS audio.");
+
+ private static readonly Counter ReusedCount = Metrics.CreateCounter(
+ "tts_reused_count",
+ "Amount of reused TTS audio from cache.");
+
+ [Dependency] private readonly IConfigurationManager _cfg = default!;
+
+ private readonly HttpClient _httpClient = new();
+
+ private ISawmill _sawmill = default!;
+ private readonly Dictionary _cache = new();
+ private readonly List _cacheKeysSeq = new();
+ private int _maxCachedCount = 200;
+ private string _apiUrl = string.Empty;
+ private string _apiToken = string.Empty;
+
+ public void Initialize()
+ {
+ _sawmill = Logger.GetSawmill("tts");
+ _cfg.OnValueChanged(OpenCVars.TTSMaxCache, val =>
+ {
+ _maxCachedCount = val;
+ ResetCache();
+ }, true);
+ _cfg.OnValueChanged(OpenCVars.TTSApiUrl, v => _apiUrl = v, true);
+ _cfg.OnValueChanged(OpenCVars.TTSApiToken, v =>
+ {
+ _httpClient.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", v);
+ _apiToken = v;
+ },
+ true);
+ }
+
+ ///
+ /// Generates audio with passed text by API
+ ///
+ /// Identifier of speaker
+ /// SSML formatted text
+ /// OGG audio bytes or null if failed
+ public async Task ConvertTextToSpeech(string speaker, string text)
+ {
+ WantedCount.Inc();
+ var cacheKey = GenerateCacheKey(speaker, text);
+ if (_cache.TryGetValue(cacheKey, out var data))
+ {
+ ReusedCount.Inc();
+ _sawmill.Verbose($"Use cached sound for '{text}' speech by '{speaker}' speaker");
+ return data;
+ }
+
+ _sawmill.Verbose($"Generate new audio for '{text}' speech by '{speaker}' speaker");
+
+ var body = new GenerateVoiceRequest
+ {
+ Text = text,
+ Speaker = speaker,
+ // Pitch = pitch,
+ // Rate = rate,
+ // Effect = effect // В будущем для эффекта рации
+ };
+
+ var request = CreateRequestLink(_apiUrl, body);
+
+ var reqTime = DateTime.UtcNow;
+ try
+ {
+ var timeout = _cfg.GetCVar(OpenCVars.TTSApiTimeout);
+ var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeout));
+ var response = await _httpClient.GetAsync(request, cts.Token);
+ if (!response.IsSuccessStatusCode)
+ {
+ if (response.StatusCode == HttpStatusCode.TooManyRequests)
+ {
+ _sawmill.Warning("TTS request was rate limited");
+ return null;
+ }
+
+ _sawmill.Error($"TTS request returned bad status code: {response.StatusCode}");
+ return null;
+ }
+
+ var soundData = await response.Content.ReadAsByteArrayAsync(cts.Token);
+
+ _sawmill.Debug(
+ $"Generated new sound for '{text}' speech by '{speaker}' speaker ({soundData.Length} bytes)");
+
+ RequestTimings.WithLabels("Success").Observe((DateTime.UtcNow - reqTime).TotalSeconds);
+
+ return soundData;
+ }
+ catch (TaskCanceledException)
+ {
+ RequestTimings.WithLabels("Timeout").Observe((DateTime.UtcNow - reqTime).TotalSeconds);
+ _sawmill.Error($"Timeout of request generation new audio for '{text}' speech by '{speaker}' speaker");
+ return null;
+ }
+ catch (Exception e)
+ {
+ RequestTimings.WithLabels("Error").Observe((DateTime.UtcNow - reqTime).TotalSeconds);
+ _sawmill.Error($"Failed of request generation new sound for '{text}' speech by '{speaker}' speaker\n{e}");
+ return null;
+ }
+ }
+
+ private static string CreateRequestLink(string _apiUrl, GenerateVoiceRequest body)
+ {
+ var uriBuilder = new UriBuilder(_apiUrl);
+ var query = HttpUtility.ParseQueryString(uriBuilder.Query);
+ query["speaker"] = body.Speaker;
+ query["text"] = body.Text;
+ query["pitch"] = body.Pitch;
+ query["rate"] = body.Rate;
+ query["file"] = "1";
+ query["ext"] = "ogg";
+ if (body.Effect != null)
+ query["effect"] = body.Effect;
+
+ uriBuilder.Query = query.ToString();
+ return uriBuilder.ToString();
+ }
+
+ public void ResetCache()
+ {
+ _cache.Clear();
+ _cacheKeysSeq.Clear();
+ }
+
+ private string GenerateCacheKey(string speaker, string text)
+ {
+ var key = $"{speaker}/{text}";
+ var keyData = Encoding.UTF8.GetBytes(key);
+ var sha256 = System.Security.Cryptography.SHA256.Create();
+ var bytes = sha256.ComputeHash(keyData);
+ return Convert.ToHexString(bytes);
+ }
+
+ private record GenerateVoiceRequest
+ {
+ [JsonPropertyName("text")]
+ public string Text { get; set; } = default!;
+
+ [JsonPropertyName("speaker")]
+ public string Speaker { get; set; } = default!;
+
+ [JsonPropertyName("pitch")]
+ public string Pitch { get; set; } = default!;
+
+ [JsonPropertyName("rate")]
+ public string Rate { get; set; } = default!;
+
+ [JsonPropertyName("effect")]
+ public string? Effect { get; set; }
+ }
+
+ private struct GenerateVoiceResponse
+ {
+ [JsonPropertyName("results")]
+ public List Results { get; set; }
+
+ [JsonPropertyName("original_sha1")]
+ public string Hash { get; set; }
+ }
+
+ private struct VoiceResult
+ {
+ [JsonPropertyName("audio")]
+ public string Audio { get; set; }
+ }
+}
diff --git a/Content.Server/_OpenSpace/TTS/TTSSystem.RateLimit.cs b/Content.Server/_OpenSpace/TTS/TTSSystem.RateLimit.cs
new file mode 100644
index 00000000000..b687d1e49e0
--- /dev/null
+++ b/Content.Server/_OpenSpace/TTS/TTSSystem.RateLimit.cs
@@ -0,0 +1,35 @@
+using Content.Server.Chat.Managers;
+using Content.Server.Players.RateLimiting;
+using Content.Shared._OpenSpace.OpenCVars;
+using Content.Shared.Players.RateLimiting;
+using Robust.Shared.Player;
+
+namespace Content.Server._OpenSpace.TTS;
+
+public sealed partial class TTSSystem
+{
+ [Dependency] private readonly PlayerRateLimitManager _rateLimitManager = default!;
+ [Dependency] private readonly IChatManager _chat = default!;
+
+ private const string RateLimitKey = "TTS";
+
+ private void RegisterRateLimits()
+ {
+ _rateLimitManager.Register(RateLimitKey,
+ new RateLimitRegistration(
+ OpenCVars.TTSRateLimitPeriod,
+ OpenCVars.TTSRateLimitCount,
+ RateLimitPlayerLimited)
+ );
+ }
+
+ private void RateLimitPlayerLimited(ICommonSession player)
+ {
+ _chat.DispatchServerMessage(player, Loc.GetString("tts-rate-limited"), suppressLog: true);
+ }
+
+ private RateLimitStatus HandleRateLimit(ICommonSession player)
+ {
+ return _rateLimitManager.CountAction(player, RateLimitKey);
+ }
+}
diff --git a/Content.Server/_OpenSpace/TTS/TTSSystem.SSML.cs b/Content.Server/_OpenSpace/TTS/TTSSystem.SSML.cs
new file mode 100644
index 00000000000..b102b40f28a
--- /dev/null
+++ b/Content.Server/_OpenSpace/TTS/TTSSystem.SSML.cs
@@ -0,0 +1,23 @@
+namespace Content.Server._OpenSpace.TTS;
+
+// ReSharper disable once InconsistentNaming
+public sealed partial class TTSSystem
+{
+ private string ToSsmlText(string text, SoundTraits traits = SoundTraits.None)
+ {
+ var result = text;
+ if (traits.HasFlag(SoundTraits.RateFast))
+ result = $"{result}";
+ if (traits.HasFlag(SoundTraits.PitchVerylow))
+ result = $"{result}";
+ return $"{result}";
+ }
+
+ [Flags]
+ private enum SoundTraits : ushort
+ {
+ None = 0,
+ RateFast = 1 << 0,
+ PitchVerylow = 1 << 1,
+ }
+}
diff --git a/Content.Server/_OpenSpace/TTS/TTSSystem.Sanitize.cs b/Content.Server/_OpenSpace/TTS/TTSSystem.Sanitize.cs
new file mode 100644
index 00000000000..499e687a5d0
--- /dev/null
+++ b/Content.Server/_OpenSpace/TTS/TTSSystem.Sanitize.cs
@@ -0,0 +1,312 @@
+using System.Text;
+using System.Text.RegularExpressions;
+using Content.Shared.Chat;
+
+namespace Content.Server._OpenSpace.TTS;
+
+// ReSharper disable once InconsistentNaming
+public sealed partial class TTSSystem
+{
+ private void OnTransformSpeech(TransformSpeechEvent args)
+ {
+ if (!_isEnabled) return;
+ args.Message = args.Message.Replace("+", "");
+ }
+
+ private string Sanitize(string text)
+ {
+ text = text.Trim();
+ // text = Regex.Replace(text, @"[^a-zA-Zа-яА-ЯёЁ0-9,\-+?!. ]", "");
+ // text = Regex.Replace(text, @"[a-zA-Z]", ReplaceLat2Cyr, RegexOptions.Multiline | RegexOptions.IgnoreCase);
+ // text = Regex.Replace(text, @"(? WordReplacement =
+ new Dictionary()
+ {
+ {"нт", "Эн Тэ"},
+ {"смо", "Эс Мэ О"},
+ {"гп", "Гэ Пэ"},
+ {"рд", "Эр Дэ"},
+ {"гсб", "Гэ Эс Бэ"},
+ {"гв", "Гэ Вэ"},
+ {"нр", "Эн Эр"},
+ {"нра", "Эн Эра"},
+ {"нру", "Эн Эру"},
+ {"км", "Кэ Эм"},
+ {"кма", "Кэ Эма"},
+ {"кму", "Кэ Эму"},
+ {"си", "Эс И"},
+ {"срп", "Эс Эр Пэ"},
+ {"цк", "Цэ Каа"},
+ {"сцк", "Эс Цэ Каа"},
+ {"пцк", "Пэ Цэ Каа"},
+ {"оцк", "О Цэ Каа"},
+ {"шцк", "Эш Цэ Каа"},
+ {"ншцк", "Эн Эш Цэ Каа"},
+ {"дсо", "Дэ Эс О"},
+ {"рнд", "Эр Эн Дэ"},
+ {"сб", "Эс Бэ"},
+ {"рцд", "Эр Цэ Дэ"},
+ {"брпд", "Бэ Эр Пэ Дэ"},
+ {"рпд", "Эр Пэ Дэ"},
+ {"рпед", "Эр Пед"},
+ {"тсф", "Тэ Эс Эф"},
+ {"срт", "Эс Эр Тэ"},
+ {"обр", "О Бэ Эр"},
+ {"кпк", "Кэ Пэ Каа"},
+ {"пда", "Пэ Дэ А"},
+ {"id", "Ай Ди"},
+ {"мщ", "Эм Ще"},
+ {"вт", "Вэ Тэ"},
+ {"wt", "Вэ Тэ"},
+ {"ерп", "Йе Эр Пэ"},
+ {"се", "Эс Йе"},
+ {"апц", "А Пэ Цэ"},
+ {"лкп", "Эл Ка Пэ"},
+ {"см", "Эс Эм"},
+ {"ека", "Йе Ка"},
+ {"ка", "Кэ А"},
+ {"бса", "Бэ Эс Аа"},
+ {"тк", "Тэ Ка"},
+ {"бфл", "Бэ Эф Эл"},
+ {"бщ", "Бэ Щэ"},
+ {"кк", "Кэ Ка"},
+ {"ск", "Эс Ка"},
+ {"зк", "Зэ Ка"},
+ {"ерт", "Йе Эр Тэ"},
+ {"вкд", "Вэ Ка Дэ"},
+ {"нтр", "Эн Тэ Эр"},
+ {"пнт", "Пэ Эн Тэ"},
+ {"авд", "А Вэ Дэ"},
+ {"пнв", "Пэ Эн Вэ"},
+ {"ссд", "Эс Эс Дэ"},
+ {"крс", "Ка Эр Эс"},
+ {"кпб", "Кэ Пэ Бэ"},
+ {"сссп", "Эс Эс Эс Пэ"},
+ {"крб", "Ка Эр Бэ"},
+ {"бд", "Бэ Дэ"},
+ {"сст", "Эс Эс Тэ"},
+ {"скс", "Эс Ка Эс"},
+ {"икн", "И Ка Эн"},
+ {"нсс", "Эн Эс Эс"},
+ {"емп", "Йе Эм Пэ"},
+ {"бс", "Бэ Эс"},
+ {"цкс", "Цэ Ка Эс"},
+ {"срд", "Эс Эр Дэ"},
+ {"жпс", "Джи Пи Эс"},
+ {"gps", "Джи Пи Эс"},
+ {"ннксс", "Эн Эн Ка Эс Эс"},
+ {"ss", "Эс Эс"},
+ {"тесла", "тэсла"},
+ {"трейзен", "трэйзэн"},
+ {"нанотрейзен", "нанотрэйзэн"},
+ {"рпзд", "Эр Пэ Зэ Дэ"},
+ {"кз", "Кэ Зэ"},
+ {"рхбз", "Эр Хэ Бэ Зэ"},
+ {"рхбзз", "Эр Хэ Бэ Зэ Зэ"},
+ {"днк", "Дэ Эн Ка"},
+ {"мк", "Эм Ка"},
+ {"mk", "Эм Ка"},
+ {"рпг", "Эр Пэ Гэ"},
+ {"с4", "Си 4"}, // cyrillic
+ {"c4", "Си 4"}, // latinic
+ {"бсс", "Бэ Эс Эс"},
+ {"сии", "Эс И И"},
+ {"ии", "И И"},
+ {"опз", "О Пэ Зэ"},
+ {"рпс", "Эр Пэ Эс"},
+ };
+
+ private static readonly IReadOnlyDictionary ReverseTranslit =
+ new Dictionary()
+ {
+ {"a", "а"},
+ {"b", "б"},
+ {"v", "в"},
+ {"g", "г"},
+ {"d", "д"},
+ {"e", "е"},
+ {"je", "ё"},
+ {"zh", "ж"},
+ {"z", "з"},
+ {"i", "и"},
+ {"y", "й"},
+ {"k", "к"},
+ {"l", "л"},
+ {"m", "м"},
+ {"n", "н"},
+ {"o", "о"},
+ {"p", "п"},
+ {"r", "р"},
+ {"s", "с"},
+ {"t", "т"},
+ {"u", "у"},
+ {"f", "ф"},
+ {"h", "х"},
+ {"c", "ц"},
+ {"x", "кс"},
+ {"ch", "ч"},
+ {"sh", "ш"},
+ {"jsh", "щ"},
+ {"hh", "ъ"},
+ {"ih", "ы"},
+ {"jh", "ь"},
+ {"eh", "э"},
+ {"ju", "ю"},
+ {"ja", "я"},
+ };
+}
+
+// Source: https://codelab.ru/s/csharp/digits2phrase
+public static class NumberConverter
+{
+ private static readonly string[] Frac20Male =
+ {
+ "", "один", "два", "три", "четыре", "пять", "шесть",
+ "семь", "восемь", "девять", "десять", "одиннадцать",
+ "двенадцать", "тринадцать", "четырнадцать", "пятнадцать",
+ "шестнадцать", "семнадцать", "восемнадцать", "девятнадцать"
+ };
+
+ private static readonly string[] Frac20Female =
+ {
+ "", "одна", "две", "три", "четыре", "пять", "шесть",
+ "семь", "восемь", "девять", "десять", "одиннадцать",
+ "двенадцать", "тринадцать", "четырнадцать", "пятнадцать",
+ "шестнадцать", "семнадцать", "восемнадцать", "девятнадцать"
+ };
+
+ private static readonly string[] Hunds =
+ {
+ "", "сто", "двести", "триста", "четыреста",
+ "пятьсот", "шестьсот", "семьсот", "восемьсот", "девятьсот"
+ };
+
+ private static readonly string[] Tens =
+ {
+ "", "десять", "двадцать", "тридцать", "сорок", "пятьдесят",
+ "шестьдесят", "семьдесят", "восемьдесят", "девяносто"
+ };
+
+ public static string NumberToText(long value, bool male = true)
+ {
+ if (value >= (long)Math.Pow(10, 15))
+ return String.Empty;
+
+ if (value == 0)
+ return "ноль";
+
+ var str = new StringBuilder();
+
+ if (value < 0)
+ {
+ str.Append("минус");
+ value = -value;
+ }
+
+ value = AppendPeriod(value, 1000000000000, str, "триллион", "триллиона", "триллионов", true);
+ value = AppendPeriod(value, 1000000000, str, "миллиард", "миллиарда", "миллиардов", true);
+ value = AppendPeriod(value, 1000000, str, "миллион", "миллиона", "миллионов", true);
+ value = AppendPeriod(value, 1000, str, "тысяча", "тысячи", "тысяч", false);
+
+ var hundreds = (int)(value / 100);
+ if (hundreds != 0)
+ AppendWithSpace(str, Hunds[hundreds]);
+
+ var less100 = (int)(value % 100);
+ var frac20 = male ? Frac20Male : Frac20Female;
+ if (less100 < 20)
+ AppendWithSpace(str, frac20[less100]);
+ else
+ {
+ var tens = less100 / 10;
+ AppendWithSpace(str, Tens[tens]);
+ var less10 = less100 % 10;
+ if (less10 != 0)
+ str.Append(" " + frac20[less100%10]);
+ }
+
+ return str.ToString();
+ }
+
+ private static void AppendWithSpace(StringBuilder stringBuilder, string str)
+ {
+ if (stringBuilder.Length > 0)
+ stringBuilder.Append(" ");
+ stringBuilder.Append(str);
+ }
+
+ private static long AppendPeriod(
+ long value,
+ long power,
+ StringBuilder str,
+ string declension1,
+ string declension2,
+ string declension5,
+ bool male)
+ {
+ var thousands = (int)(value / power);
+ if (thousands > 0)
+ {
+ AppendWithSpace(str, NumberToText(thousands, male, declension1, declension2, declension5));
+ return value % power;
+ }
+ return value;
+ }
+
+ private static string NumberToText(
+ long value,
+ bool male,
+ string valueDeclensionFor1,
+ string valueDeclensionFor2,
+ string valueDeclensionFor5)
+ {
+ return
+ NumberToText(value, male)
+ + " "
+ + GetDeclension((int)(value % 10), valueDeclensionFor1, valueDeclensionFor2, valueDeclensionFor5);
+ }
+
+ private static string GetDeclension(int val, string one, string two, string five)
+ {
+ var t = (val % 100 > 20) ? val % 10 : val % 20;
+
+ switch (t)
+ {
+ case 1:
+ return one;
+ case 2:
+ case 3:
+ case 4:
+ return two;
+ default:
+ return five;
+ }
+ }
+}
diff --git a/Content.Server/_OpenSpace/TTS/TTSSystem.cs b/Content.Server/_OpenSpace/TTS/TTSSystem.cs
new file mode 100644
index 00000000000..28f5305fa91
--- /dev/null
+++ b/Content.Server/_OpenSpace/TTS/TTSSystem.cs
@@ -0,0 +1,152 @@
+using System.Threading.Tasks;
+using Content.Shared.Chat;
+using Content.Server.Chat.Systems;
+using Content.Shared._OpenSpace.OpenCVars;
+using Content.Shared._OpenSpace.TTS;
+using Content.Shared.GameTicking;
+using Content.Shared.Players.RateLimiting;
+using Robust.Shared.Configuration;
+using Robust.Shared.Player;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+
+namespace Content.Server._OpenSpace.TTS;
+
+// ReSharper disable once InconsistentNaming
+public sealed partial class TTSSystem : EntitySystem
+{
+ [Dependency] private readonly IConfigurationManager _cfg = default!;
+ [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+ [Dependency] private readonly TTSManager _ttsManager = default!;
+ [Dependency] private readonly SharedTransformSystem _xforms = default!;
+ [Dependency] private readonly IRobustRandom _rng = default!;
+
+ private readonly List _sampleText =
+ new()
+ {
+ "Съешь же ещё этих мягких французских булок, да выпей чаю.",
+ "Клоун, прекрати разбрасывать банановые кожурки офицерам под ноги!",
+ "Капитан, вы уверены что хотите назначить клоуна на должность главы персонала?",
+ "Эс Бэ! Тут человек в сером костюме, с тулбоксом и в маске! Помогите!!",
+ "Учёные, тут странная аномалия в баре! Она уже съела мима!",
+ "Я надеюсь что инженеры внимательно следят за сингулярностью...",
+ "Вы слышали эти странные крики в техах? Мне кажется туда ходить небезопасно.",
+ "Вы не видели Гамлета? Мне кажется он забегал к вам на кухню.",
+ "Здесь есть доктор? Человек умирает от отравленного пончика! Нужна помощь!",
+ "Вам нужно согласие и печать квартирмейстера, если вы хотите сделать заказ на партию дробовиков.",
+ "Возле эвакуационного шаттла разгерметизация! Инженеры, нам срочно нужна ваша помощь!",
+ "Бармен, налей мне самого крепкого вина, которое есть в твоих запасах!"
+ };
+
+ private const int MaxMessageChars = 100 * 2; // same as SingleBubbleCharLimit * 2
+ private bool _isEnabled = false;
+
+ public override void Initialize()
+ {
+ _cfg.OnValueChanged(OpenCVars.TTSEnabled, v => _isEnabled = v, true);
+
+ SubscribeLocalEvent(OnTransformSpeech);
+ SubscribeLocalEvent(OnEntitySpoke);
+ SubscribeLocalEvent(OnRoundRestartCleanup);
+
+ SubscribeNetworkEvent(OnRequestPreviewTTS);
+
+ RegisterRateLimits();
+ }
+
+ private void OnRoundRestartCleanup(RoundRestartCleanupEvent ev)
+ {
+ _ttsManager.ResetCache();
+ }
+
+ private async void OnRequestPreviewTTS(RequestPreviewTTSEvent ev, EntitySessionEventArgs args)
+ {
+ if (!_isEnabled ||
+ !_prototypeManager.TryIndex(ev.VoiceId, out var protoVoice))
+ return;
+
+ if (HandleRateLimit(args.SenderSession) != RateLimitStatus.Allowed)
+ return;
+
+ var previewText = _rng.Pick(_sampleText);
+ var soundData = await GenerateTTS(previewText, protoVoice.ID);
+ if (soundData is null)
+ return;
+
+ RaiseNetworkEvent(new PlayTTSEvent(soundData), Filter.SinglePlayer(args.SenderSession));
+ }
+
+ private async void OnEntitySpoke(EntityUid uid, TTSComponent component, EntitySpokeEvent args)
+ {
+ var voiceId = component.VoicePrototypeId;
+ if (!_isEnabled ||
+ args.Message.Length > MaxMessageChars ||
+ string.IsNullOrEmpty(voiceId))
+ return;
+
+ var voiceEv = new TransformSpeakerVoiceEvent(uid, voiceId);
+ RaiseLocalEvent(uid, voiceEv);
+ voiceId = voiceEv.VoiceId;
+
+ if (!_prototypeManager.TryIndex(voiceId, out var protoVoice))
+ return;
+
+ if (args.ObfuscatedMessage != null)
+ {
+ HandleWhisper(uid, args.Message, args.ObfuscatedMessage, protoVoice.ID);
+ return;
+ }
+
+ HandleSay(uid, args.Message, protoVoice.ID);
+ }
+
+ private async void HandleSay(EntityUid uid, string message, string speaker)
+ {
+ var soundData = await GenerateTTS(message, speaker);
+ if (soundData is null) return;
+ RaiseNetworkEvent(new PlayTTSEvent(soundData, GetNetEntity(uid)), Filter.Pvs(uid));
+ }
+
+ private async void HandleWhisper(EntityUid uid, string message, string obfMessage, string speaker)
+ {
+ var fullSoundData = await GenerateTTS(message, speaker, true);
+ if (fullSoundData is null) return;
+
+ var obfSoundData = await GenerateTTS(obfMessage, speaker, true);
+ if (obfSoundData is null) return;
+
+ var fullTtsEvent = new PlayTTSEvent(fullSoundData, GetNetEntity(uid), true);
+ var obfTtsEvent = new PlayTTSEvent(obfSoundData, GetNetEntity(uid), true);
+
+ // TODO: Check obstacles
+ var xformQuery = GetEntityQuery();
+ var sourcePos = _xforms.GetWorldPosition(xformQuery.GetComponent(uid), xformQuery);
+ var receptions = Filter.Pvs(uid).Recipients;
+ foreach (var session in receptions)
+ {
+ if (!session.AttachedEntity.HasValue) continue;
+ var xform = xformQuery.GetComponent(session.AttachedEntity.Value);
+ var distance = (sourcePos - _xforms.GetWorldPosition(xform, xformQuery)).Length();
+ if (distance > ChatSystem.VoiceRange)
+ continue;
+
+ RaiseNetworkEvent(distance > ChatSystem.WhisperClearRange ? obfTtsEvent : fullTtsEvent, session);
+ }
+ }
+
+ // ReSharper disable once InconsistentNaming
+ private async Task GenerateTTS(string text, string speaker, bool isWhisper = false)
+ {
+ var textSanitized = Sanitize(text);
+ if (textSanitized == "") return null;
+ if (char.IsLetter(textSanitized[^1]))
+ textSanitized += ".";
+
+ var ssmlTraits = SoundTraits.RateFast;
+ if (isWhisper)
+ ssmlTraits = SoundTraits.PitchVerylow;
+ var textSsml = ToSsmlText(textSanitized, ssmlTraits);
+
+ return await _ttsManager.ConvertTextToSpeech(speaker, textSsml);
+ }
+}
diff --git a/Content.Server/_OpenSpace/TTS/VoiceMaskSystem.TTS.cs b/Content.Server/_OpenSpace/TTS/VoiceMaskSystem.TTS.cs
new file mode 100644
index 00000000000..b2a074d3ff5
--- /dev/null
+++ b/Content.Server/_OpenSpace/TTS/VoiceMaskSystem.TTS.cs
@@ -0,0 +1,41 @@
+using Content.Shared._OpenSpace.TTS;
+using Content.Shared.Implants;
+using Content.Shared.Inventory;
+using Content.Shared.VoiceMask;
+
+namespace Content.Server.VoiceMask;
+
+public partial class VoiceMaskSystem
+{
+ private void InitializeTTS()
+ {
+ SubscribeLocalEvent>(OnSpeakerVoiceTransform);
+ SubscribeLocalEvent