Merge remote-tracking branch 'space-sunrise/master'

# Conflicts:
#	Resources/Prototypes/_Sunrise/Catalog/uplink_catalog.yml
#	Resources/migration.yml
This commit is contained in:
Vigers Ray 2026-02-01 05:35:23 +01:00
commit e521cac521
167 changed files with 4215 additions and 1193 deletions

View file

@ -43,14 +43,12 @@
MinSize="0 0"
SizeFlagsStretchRatio="2"
VerticalExpand="True">
<BoxContainer
Name="PageTextContainer"
MinSize="0 0"
Orientation="Vertical"
SizeFlagsStretchRatio="2"
VerticalExpand="True">
<!-- Sunrise-Start -->
<BoxContainer Orientation="Vertical" HorizontalExpand="True">
<RichTextLabel Margin="8,8,8,8" Name="PageText" VerticalAlignment="Top"/>
<GridContainer Name="ArticlePhotosContainer" Columns="2" HorizontalExpand="True" HSeparationOverride="10" VSeparationOverride="10" Margin="10,0,10,10"/>
</BoxContainer>
<RichTextLabel Margin="8,8,8,8" Name="PageText" VerticalAlignment="Top"/>
<!-- Sunrise-End -->
</ScrollContainer>
</PanelContainer>
<BoxContainer Orientation="Horizontal" HorizontalExpand="True" Margin="5,5,5,5">

View file

@ -1,18 +1,24 @@
using Content.Client._Sunrise.UserInterface.CustomControls;
using Content.Client.Message;
using Content.Client.RichText;
using Content.Client.UserInterface.RichText;
using Content.Shared.MassMedia.Systems;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.RichText;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Utility;
using Content.Client._Sunrise;
using Robust.Client.ResourceManagement;
using Robust.Client.Graphics;
using Robust.Client.UserInterface;
namespace Content.Client.CartridgeLoader.Cartridges;
[GenerateTypedNameReferences]
public sealed partial class NewsReaderUiFragment : BoxContainer
{
[Dependency] private readonly NetTexturesManager _netTexturesManager = default!;
[Dependency] private readonly IResourceCache _resourceCache = default!;
public event Action? OnNextButtonPressed;
public event Action? OnPrevButtonPressed;
@ -21,6 +27,7 @@ public sealed partial class NewsReaderUiFragment : BoxContainer
public NewsReaderUiFragment()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
Next.OnPressed += _ => OnNextButtonPressed?.Invoke();
Prev.OnPressed += _ => OnPrevButtonPressed?.Invoke();
@ -47,6 +54,81 @@ public sealed partial class NewsReaderUiFragment : BoxContainer
var author = Loc.GetString("news-read-ui-author-prefix") + " " + (article.Author ?? Loc.GetString("news-read-ui-no-author"));
Author.SetMessage(FormattedMessage.FromMarkupPermissive(author), UserFormattableTags.BaseAllowedTags);
// Sunrise-Start
ArticlePhotosContainer.Children.Clear();
if (article.PhotoPaths != null)
{
foreach (var path in article.PhotoPaths)
{
var photoButton = new ContainerButton
{
HorizontalExpand = true,
Margin = new Thickness(0, 4),
DefaultCursorShape = Control.CursorShape.Hand
};
var border = new PanelContainer
{
HorizontalExpand = true,
PanelOverride = new StyleBoxFlat
{
BackgroundColor = Color.Transparent,
BorderColor = Color.Transparent,
BorderThickness = new Thickness(2)
}
};
var textureRect = new TextureRect
{
HorizontalExpand = true,
Stretch = TextureRect.StretchMode.KeepAspectCentered,
MinHeight = 150
};
border.AddChild(textureRect);
photoButton.AddChild(border);
ArticlePhotosContainer.AddChild(photoButton);
photoButton.OnPressed += _ => PhotoPreviewWindow.Open(textureRect.Texture);
photoButton.OnMouseEntered += _ =>
{
if (border.PanelOverride is StyleBoxFlat style)
{
style.BorderColor = Color.White.WithAlpha(0.4f);
}
};
photoButton.OnMouseExited += _ =>
{
if (border.PanelOverride is StyleBoxFlat style)
{
style.BorderColor = Color.Transparent;
}
};
if (_netTexturesManager.EnsureResource(path))
{
var uploaded = _netTexturesManager.GetUploadedPath(path);
if (_resourceCache.TryGetResource<TextureResource>(uploaded, out var tex))
textureRect.Texture = tex.Texture;
}
else
{
void OnLoaded(string loadedPath)
{
if (loadedPath != path) return;
var uploaded = _netTexturesManager.GetUploadedPath(path);
if (_resourceCache.TryGetResource<TextureResource>(uploaded, out var tex))
textureRect.Texture = tex.Texture;
_netTexturesManager.ResourceLoaded -= OnLoaded;
}
_netTexturesManager.ResourceLoaded += OnLoaded;
}
}
}
// Sunrise-End
Prev.Disabled = targetNum <= 1;
Next.Disabled = targetNum >= totalNum;
}

View file

@ -154,6 +154,7 @@ namespace Content.Client.Lobby.UI
_configurationManager.OnValueChanged(SunriseCCVars.ServerName, OnServerNameChanged, true);
Chat.SetChatOpacity();
Chat.ToggleEmojiButton(true); // Sunrise-Add
ServerName.Text = Loc.GetString("ui-lobby-welcome", ("name", _serverName));
LoadIcons();

View file

@ -40,6 +40,24 @@
<RichTextLabel Name="PreviewLabel" VerticalAlignment="Top" Margin="9 3" MaxWidth="360"/>
</ScrollContainer>
</Control>
<!-- Sunrise-Start -->
<BoxContainer Orientation="Horizontal" Margin="11 5 11 0">
<Label Text="{Loc news-write-ui-photos-label}" Margin="6 0 10 0" VerticalAlignment="Center"/>
<Button Name="ButtonAddPhoto" Text="{Loc news-write-ui-add-photo-text}" SetHeight="24"/>
<Control HorizontalExpand="True"/>
<Label Name="PhotoCountLabel" Text="0/10" StyleClasses="LabelWeak" VerticalAlignment="Center" Margin="0 0 6 0"/>
</BoxContainer>
<Control Name="PhotosPanel" SetHeight="120" Margin="11 5 11 0">
<PanelContainer>
<PanelContainer.PanelOverride>
<graphics:StyleBoxFlat BackgroundColor="#1a1a1c" BorderThickness="1" BorderColor="#3B3E56"/>
</PanelContainer.PanelOverride>
</PanelContainer>
<ScrollContainer Name="PhotosScroll" HScrollEnabled="True" VScrollEnabled="False" VerticalExpand="True">
<BoxContainer Name="PhotosContainer" Orientation="Horizontal" SeparationOverride="5" Margin="5 5" VerticalExpand="True"/>
</ScrollContainer>
</Control>
<!-- Sunrise-End -->
<BoxContainer Orientation="Horizontal" Margin="12 5 12 8">
<Control>
<Button Name="ButtonCancel" SetHeight="32" SetWidth="85"
@ -47,11 +65,11 @@
</Control>
<Control HorizontalExpand="True"/>
<BoxContainer Orientation="Horizontal">
<Button Name="ButtonSaveDraft" SetHeight="32" SetWidth="85"
StyleClasses="OpenRight" Text="{Loc news-write-ui-save-text}"/>
<Button Name="ButtonPreview" SetHeight="32" SetWidth="85"
StyleClasses="OpenBoth" Text="{Loc news-write-ui-preview-text}"/>
<Button Name="ButtonPublish" SetHeight="32" SetWidth="85" Text="{Loc news-write-ui-publish-text}" Access="Public"/>
<Button Name="ButtonSaveDraft" SetHeight="32" SetWidth="120"
StyleClasses="OpenRight" Text="{Loc news-write-ui-save-text}"/> <!-- Sunrise-Edit -->
<Button Name="ButtonPreview" SetHeight="32" SetWidth="120"
StyleClasses="OpenBoth" Text="{Loc news-write-ui-preview-text}"/> <!-- Sunrise-Edit -->
<Button Name="ButtonPublish" SetHeight="32" SetWidth="120" Text="{Loc news-write-ui-publish-text}" Access="Public"/> <!-- Sunrise-Edit -->
</BoxContainer>
</BoxContainer>
</BoxContainer>

View file

@ -1,13 +1,14 @@
using Content.Client.Message;
using System.Numerics;
using Content.Client._Sunrise;
using Content.Client.Message;
using Content.Client.RichText;
using Content.Client.Stylesheets;
using Content.Client.UserInterface.RichText;
using Content.Shared.MassMedia.Systems;
using Robust.Client.AutoGenerated;
using Robust.Client.ResourceManagement;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.RichText;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Utility;
@ -16,14 +17,20 @@ namespace Content.Client.MassMedia.Ui;
[GenerateTypedNameReferences]
public sealed partial class ArticleEditorPanel : Control
{
[Dependency] private readonly NetTexturesManager _netTexturesManager = default!;
[Dependency] private readonly IResourceCache _resourceCache = default!;
public event Action? PublishButtonPressed;
public event Action<string, string>? ArticleDraftUpdated;
public event Action<string, string, List<string>?>? ArticleDraftUpdated; // Sunrise-Edit
public event Action? RequestPhotosPressed; // Sunrise-Add
private bool _preview;
public List<string> PhotoPaths = new(); // Sunrise-Add
public ArticleEditorPanel()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
ButtonPublish.StyleClasses.Add(StyleClass.ButtonOpenLeft);
ButtonPublish.StyleClasses.Add(StyleClass.Positive);
@ -49,10 +56,11 @@ public sealed partial class ArticleEditorPanel : Control
ButtonPreview.OnPressed += OnPreview;
ButtonCancel.OnPressed += OnCancel;
ButtonPublish.OnPressed += OnPublish;
ButtonSaveDraft.OnPressed += OnDraftSaved;
ButtonSaveDraft.OnPressed += _ => OnDraftSaved();
ButtonAddPhoto.OnPressed += _ => RequestPhotosPressed?.Invoke(); // Sunrise-Add
TitleField.OnTextChanged += args => OnTextChanged(args.Text.Length, args.Control, SharedNewsSystem.MaxTitleLength);
ContentField.OnTextChanged += args => OnTextChanged(Rope.CalcTotalLength(args.TextRope), args.Control, SharedNewsSystem.MaxContentLength);
TitleField.OnTextChanged += OnTitleChanged;
ContentField.OnTextChanged += OnContentChanged;
}
private void OnTextChanged(long length, Control control, long maxLength)
@ -75,7 +83,7 @@ public sealed partial class ArticleEditorPanel : Control
}
// save draft regardless; they can edit down the length later
ArticleDraftUpdated?.Invoke(TitleField.Text, Rope.Collapse(ContentField.TextRope));
ArticleDraftUpdated?.Invoke(TitleField.Text, Rope.Collapse(ContentField.TextRope), PhotoPaths); // Sunrise-Edit
}
private void OnPreview(BaseButton.ButtonEventArgs eventArgs)
@ -85,8 +93,16 @@ public sealed partial class ArticleEditorPanel : Control
TextEditPanel.Visible = !_preview;
PreviewPanel.Visible = _preview;
var articleBody = Rope.Collapse(ContentField.TextRope);
PreviewLabel.SetMessage(FormattedMessage.FromMarkupPermissive(articleBody), UserFormattableTags.BaseAllowedTags);
if (_preview)
{
var articleBody = Rope.Collapse(ContentField.TextRope);
PreviewLabel.SetMessage(FormattedMessage.FromMarkupPermissive(articleBody), UserFormattableTags.BaseAllowedTags);
ButtonPreview.Text = Loc.GetString("news-write-ui-write-text");
}
else
{
ButtonPreview.Text = Loc.GetString("news-write-ui-preview-text");
}
}
private void OnCancel(BaseButton.ButtonEventArgs eventArgs)
@ -98,16 +114,18 @@ public sealed partial class ArticleEditorPanel : Control
private void OnPublish(BaseButton.ButtonEventArgs eventArgs)
{
PublishButtonPressed?.Invoke();
Reset();
Visible = false;
}
private void OnDraftSaved(BaseButton.ButtonEventArgs eventArgs)
private void OnDraftSaved()
{
ArticleDraftUpdated?.Invoke(TitleField.Text, Rope.Collapse(ContentField.TextRope));
ArticleDraftUpdated?.Invoke(TitleField.Text, Rope.Collapse(ContentField.TextRope), PhotoPaths); // Sunrise-Edit
Visible = false;
}
private void OnTitleChanged(LineEdit.LineEditEventArgs args) => OnTextChanged(args.Text.Length, args.Control, SharedNewsSystem.MaxTitleLength);
private void OnContentChanged(TextEdit.TextEditEventArgs args) => OnTextChanged(Rope.CalcTotalLength(args.TextRope), args.Control, SharedNewsSystem.MaxContentLength);
private void Reset()
{
_preview = false;
@ -116,9 +134,115 @@ public sealed partial class ArticleEditorPanel : Control
PreviewLabel.SetMarkup("");
TitleField.Text = "";
ContentField.TextRope = Rope.Leaf.Empty;
ArticleDraftUpdated?.Invoke(string.Empty, string.Empty);
// Sunrise-Start
PhotoPaths.Clear();
UpdatePhotosUI();
// Sunrise-End
ArticleDraftUpdated?.Invoke(string.Empty, string.Empty, null); // Sunrise-Edit
}
// Sunrise-Start
public void UpdatePhotosUI()
{
PhotosContainer.Children.Clear();
foreach (var path in PhotoPaths)
{
var photoControl = new SelectedPhotoControl(path, _netTexturesManager, _resourceCache);
photoControl.OnRemove += () =>
{
PhotoPaths.Remove(path);
UpdatePhotosUI();
ArticleDraftUpdated?.Invoke(TitleField.Text, Rope.Collapse(ContentField.TextRope), PhotoPaths);
};
PhotosContainer.AddChild(photoControl);
}
UpdatePhotoCountLabel();
}
private void UpdatePhotoCountLabel()
{
PhotoCountLabel.Text = $"{PhotoPaths.Count}/10";
ButtonAddPhoto.Disabled = PhotoPaths.Count >= 10;
PhotoCountLabel.ModulateSelfOverride = PhotoPaths.Count >= 10 ? Color.Red : null;
}
private sealed class SelectedPhotoControl : Control
{
public event Action? OnRemove;
private readonly string _path;
private readonly NetTexturesManager _netTextures;
private readonly IResourceCache _cache;
private readonly TextureRect _textureRect;
public SelectedPhotoControl(string path, NetTexturesManager netTextures, IResourceCache cache)
{
_path = path;
_netTextures = netTextures;
_cache = cache;
SetSize = new Vector2(100, 100);
_textureRect = new TextureRect
{
HorizontalExpand = true,
VerticalExpand = true,
Stretch = TextureRect.StretchMode.KeepAspectCentered
};
AddChild(_textureRect);
var removeButton = new Button
{
StyleClasses = { StyleClass.ButtonSmall },
HorizontalAlignment = HAlignment.Right,
VerticalAlignment = VAlignment.Top,
SetSize = new Vector2(24, 24),
Margin = new Thickness(0, 2, 2, 0),
};
removeButton.AddChild(
new Label
{
Text = "✖",
HorizontalAlignment = HAlignment.Center,
VerticalAlignment = VAlignment.Center,
Margin = new Thickness(0, 0, 0, 2),
});
removeButton.OnPressed += _ => OnRemove?.Invoke();
AddChild(removeButton);
_netTextures.ResourceLoaded += OnResourceLoaded;
if (_netTextures.EnsureResource(path))
{
UpdateTexture();
}
}
private void OnResourceLoaded(string path)
{
if (path == _path)
UpdateTexture();
}
private void UpdateTexture()
{
var uploaded = _netTextures.GetUploadedPath(_path);
if (_cache.TryGetResource<TextureResource>(uploaded, out var tex))
_textureRect.Texture = tex.Texture;
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (!disposing)
return;
_netTextures.ResourceLoaded -= OnResourceLoaded;
}
}
// Sunrise-End
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
@ -128,5 +252,8 @@ public sealed partial class ArticleEditorPanel : Control
ButtonPreview.OnPressed -= OnPreview;
ButtonCancel.OnPressed -= OnCancel;
ButtonPublish.OnPressed -= OnPublish;
ButtonSaveDraft.OnPressed -= _ => OnDraftSaved();
TitleField.OnTextChanged -= OnTitleChanged;
ContentField.OnTextChanged -= OnContentChanged;
}
}

View file

@ -2,7 +2,6 @@ using JetBrains.Annotations;
using Content.Shared.MassMedia.Systems;
using Content.Shared.MassMedia.Components;
using Robust.Client.UserInterface;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Client.MassMedia.Ui;
@ -12,6 +11,7 @@ public sealed class NewsWriterBoundUserInterface : BoundUserInterface
{
[ViewVariables]
private NewsWriterMenu? _menu;
private PhotoSelectorWindow? _selector; // Sunrise-Edit
public NewsWriterBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
{
@ -29,6 +29,7 @@ public sealed class NewsWriterBoundUserInterface : BoundUserInterface
_menu.CreateButtonPressed += OnCreateButtonPressed;
_menu.ArticleEditorPanel.ArticleDraftUpdated += OnArticleDraftUpdated;
_menu.ArticleEditorPanel.RequestPhotosPressed += OnRequestPhotosPressed; // Sunrise-Edit
SendMessage(new NewsWriterArticlesRequestMessage());
}
@ -36,11 +37,57 @@ public sealed class NewsWriterBoundUserInterface : BoundUserInterface
protected override void UpdateState(BoundUserInterfaceState state)
{
base.UpdateState(state);
if (state is not NewsWriterBoundUserInterfaceState cast)
// Sunrise-Start
if (state is NewsWriterBoundUserInterfaceState cast)
{
_menu?.UpdateUI(cast.Articles, cast.PublishEnabled, cast.NextPublish, cast.DraftTitle, cast.DraftContent);
if (_menu != null)
{
_menu.ArticleEditorPanel.PhotoPaths = cast.DraftPhotoPaths != null ? new List<string>(cast.DraftPhotoPaths) : new List<string>();
_menu.ArticleEditorPanel.UpdatePhotosUI();
}
}
// Sunrise-End
}
// Sunrise-Start
protected override void ReceiveMessage(BoundUserInterfaceMessage message)
{
base.ReceiveMessage(message);
if (message is NewsWriterPhotosMessage photosMsg)
{
if (_selector != null && _selector.IsOpen)
{
_selector.Populate(photosMsg.Photos);
return;
}
_selector = new PhotoSelectorWindow();
_selector.PhotoSelected += path =>
{
if (_menu == null) return;
if (!_menu.ArticleEditorPanel.PhotoPaths.Contains(path))
{
_menu.ArticleEditorPanel.PhotoPaths.Add(path);
_menu.ArticleEditorPanel.UpdatePhotosUI();
OnArticleDraftUpdated(_menu.ArticleEditorPanel.TitleField.Text, Rope.Collapse(_menu.ArticleEditorPanel.ContentField.TextRope), _menu.ArticleEditorPanel.PhotoPaths);
}
};
_selector.OnClose += () => _selector = null;
_selector.Populate(photosMsg.Photos);
_selector.OpenCentered();
}
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (!disposing)
return;
_menu?.UpdateUI(cast.Articles, cast.PublishEnabled, cast.NextPublish, cast.DraftTitle, cast.DraftContent);
_selector?.Close();
}
// Sunrise-End
private void OnPublishButtonPressed()
{
@ -62,7 +109,7 @@ public sealed class NewsWriterBoundUserInterface : BoundUserInterface
: $"{stringContent[..(SharedNewsSystem.MaxContentLength - 3)]}...";
SendMessage(new NewsWriterPublishMessage(name, content));
SendMessage(new NewsWriterPublishMessage(name, content, _menu.ArticleEditorPanel.PhotoPaths)); // Sunrise-Edit
}
private void OnDeleteButtonPressed(int articleNum)
@ -78,8 +125,15 @@ public sealed class NewsWriterBoundUserInterface : BoundUserInterface
SendMessage(new NewsWriterRequestDraftMessage());
}
private void OnArticleDraftUpdated(string title, string content)
private void OnArticleDraftUpdated(string title, string content, List<string>? photoPaths) // Sunrise-Edit
{
SendMessage(new NewsWriterSaveDraftMessage(title, content));
SendMessage(new NewsWriterSaveDraftMessage(title, content, photoPaths)); // Sunrise-Edit
}
// Sunrise-Start
private void OnRequestPhotosPressed()
{
SendMessage(new NewsWriterRequestPhotosMessage());
}
// Sunrise-End
}

View file

@ -4,11 +4,11 @@
xmlns:ui="clr-namespace:Content.Client.MassMedia.Ui"
xmlns:graphics="clr-namespace:Robust.Client.Graphics;assembly=Robust.Client"
Title="{Loc 'news-write-ui-default-title'}"
MinSize="348 443"
SetSize="348 443">
MinSize="500 600"
SetSize="510 600"> <!-- Sunrise-Edit -->
<ui:ArticleEditorPanel Name="ArticleEditorPanel" HorizontalAlignment="Left" VerticalExpand="True"
MinWidth="410" MinHeight="370" Margin="0 0 0 30" Access="Public" Visible="False"/>
MinWidth="510" MinHeight="370" Margin="0 0 0 30" Access="Public" Visible="False"/> <!-- Sunrise-Edit -->
<BoxContainer Orientation="Vertical" VerticalExpand="True">
<Control VerticalExpand="True" HorizontalExpand="True" Margin="10 10 10 0">

View file

@ -0,0 +1,5 @@
<DefaultWindow xmlns="https://spacestation14.io">
<ScrollContainer VerticalExpand="True" HorizontalExpand="True">
<GridContainer Name="PhotosContainer" Columns="2" HSeparationOverride="5" VSeparationOverride="5" Margin="5"/>
</ScrollContainer>
</DefaultWindow>

View file

@ -0,0 +1,60 @@
using System.Linq;
using System.Numerics;
using Content.Client._Sunrise;
using Content.Client._Sunrise.CartridgeLoader.Cartridges;
using Content.Shared._Sunrise.CartridgeLoader.Cartridges;
using Robust.Client.AutoGenerated;
using Robust.Client.ResourceManagement;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Timing;
namespace Content.Client.MassMedia.Ui;
[GenerateTypedNameReferences]
public sealed partial class PhotoSelectorWindow : DefaultWindow
{
[Dependency] private readonly NetTexturesManager _netTexturesManager = default!;
[Dependency] private readonly IResourceCache _resourceCache = default!;
[Dependency] private readonly IGameTiming _gameTiming = default!;
public event Action<string>? PhotoSelected;
public PhotoSelectorWindow()
{
IoCManager.InjectDependencies(this);
RobustXamlLoader.Load(this);
Title = Loc.GetString("news-write-ui-select-photo-title");
MinSize = new Vector2(560, 620);
}
public void Populate(List<PhotoMetadata> photos)
{
PhotosContainer.Children.Clear();
if (photos.Count == 0)
{
PhotosContainer.AddChild(new Label
{
Text = Loc.GetString("news-write-ui-no-photos"),
HorizontalAlignment = HAlignment.Center,
Margin = new Thickness(0, 20)
});
return;
}
foreach (var metadata in photos.OrderByDescending(p => p.Timestamp))
{
var photoControl = new PhotoItemControl(metadata.ImagePath, metadata, _netTexturesManager, _resourceCache, _gameTiming);
photoControl.MinSize = new Vector2(120, 120);
photoControl.OnPressed += _ =>
{
PhotoSelected?.Invoke(metadata.ImagePath);
Close();
};
PhotosContainer.AddChild(photoControl);
}
}
}

View file

@ -19,6 +19,7 @@ using Content.Shared._Sunrise.CollectiveMind;
using Content.Shared.Administration;
using Content.Shared.CCVar;
using Content.Shared.Chat;
using Content.Shared.GameTicking;
using Content.Shared.Decals;
using Content.Shared.Damage.ForceSay;
using Content.Shared.Decals;
@ -140,6 +141,8 @@ public sealed partial class ChatUIController : UIController
private readonly Dictionary<EntityUid, SpeechBubbleQueueData> _queuedSpeechBubbles
= new();
private bool _roundEnded; // Sunrise-Add
private readonly HashSet<ChatBox> _chats = new();
public IReadOnlySet<ChatBox> Chats => _chats;
@ -190,6 +193,7 @@ public sealed partial class ChatUIController : UIController
_net.RegisterNetMessage<MsgChatMessage>(OnChatMessage);
_net.RegisterNetMessage<MsgDeleteChatMessagesBy>(OnDeleteChatMessagesBy);
SubscribeNetworkEvent<DamageForceSayEvent>(OnDamageForceSay);
SubscribeNetworkEvent<RoundEndMessageEvent>((_, _) => OnRoundEnd()); // Sunrise-Add
_config.OnValueChanged(CCVars.ChatEnableColorName, (value) => { _chatNameColorsEnabled = value; });
_chatNameColorsEnabled = _config.GetCVar(CCVars.ChatEnableColorName);
@ -250,6 +254,7 @@ public sealed partial class ChatUIController : UIController
}
_config.OnValueChanged(CCVars.ChatWindowOpacity, OnChatWindowOpacityChanged);
_config.OnValueChanged(CCVars.OocEnabled, _ => UpdateChannelPermissions()); // Sunrise-Add
InitializeHighlights();
}
@ -422,11 +427,24 @@ public sealed partial class ChatUIController : UIController
if (args.NewState is GameplayState)
{
PreferredChannel = ChatSelectChannel.Local;
_roundEnded = false; // Sunrise-Add
}
else
{
_roundEnded = false; // Sunrise-Add
}
UpdateChannelPermissions();
}
// Sunrise-Start
private void OnRoundEnd()
{
_roundEnded = true;
UpdateChannelPermissions();
}
// Sunrise-End
public void SetSpeechBubbleRoot(LayoutContainer root)
{
_speechBubbleRoot.Orphan();
@ -594,8 +612,29 @@ public sealed partial class ChatUIController : UIController
CanSendChannelsChanged?.Invoke(CanSendChannels);
FilterableChannelsChanged?.Invoke(FilterableChannels);
SelectableChannelsChanged?.Invoke(SelectableChannels);
// Sunrise-Start
var showEmoji = ShouldShowEmojiButton();
foreach (var chat in _chats)
{
chat.ToggleEmojiButton(showEmoji);
}
// Sunrise-End
}
// Sunrise-Start
private bool ShouldShowEmojiButton()
{
if (_state.CurrentState is not GameplayStateBase)
return true;
if (_roundEnded || _config.GetCVar(CCVars.OocEnabled)) // Sunrise-Edit
return true;
return _admin.HasFlag(AdminFlags.Adminchat) || (_ghost is { IsGhost: true });
}
// Sunrise-End
public void ClearUnfilteredUnreads(ChatChannel channels)
{
foreach (var channel in _unreadMessages.Keys.ToArray())
@ -746,6 +785,18 @@ public sealed partial class ChatUIController : UIController
if (text.Length == 0)
return (ChatSelectChannel.None, text, null, null); // Sunrise-Edit
// Sunrise-Start
if (text.StartsWith(SharedChatSystem.RadioChannelPrefix) && text.Length > 2 && text.IndexOf(SharedChatSystem.RadioChannelPrefix, 1) > 0)
{
var secondColon = text.IndexOf(SharedChatSystem.RadioChannelPrefix, 1);
var space = text.IndexOf(' ');
if (space == -1 || space > secondColon)
{
return (ChatSelectChannel.None, text, null, null);
}
}
// Sunrise-End
// We only cut off prefix only if it is not a radio or local channel, which both map to the same /say command
// because ????????
@ -953,6 +1004,7 @@ public sealed partial class ChatUIController : UIController
public void RegisterChat(ChatBox chat)
{
_chats.Add(chat);
chat.ToggleEmojiButton(ShouldShowEmojiButton()); // Sunrise-Edit
}
public void UnregisterChat(ChatBox chat)

View file

@ -14,6 +14,7 @@ public class ChatInputBox : PanelContainer
public readonly ChannelSelectorButton ChannelSelector;
public readonly HistoryLineEdit Input;
public readonly ChannelFilterButton FilterButton;
public Button? EmojiButton; // Sunrise-Add
protected readonly BoxContainer Container;
protected ChatChannel ActiveChannel { get; private set; } = ChatChannel.Local;
@ -52,6 +53,28 @@ public class ChatInputBox : PanelContainer
ChannelSelector.OnChannelSelect += UpdateActiveChannel;
}
// Sunrise-Start
public void ToggleEmojiButton(bool visible)
{
if (visible && EmojiButton == null)
{
EmojiButton = new Button
{
Name = "EmojiButton",
Text = "☻",
SetWidth = 30,
ToolTip = Loc.GetString("messenger-emoji-button-tooltip")
};
// Insert before FilterButton
Container.AddChild(EmojiButton);
FilterButton.SetPositionInParent(Container.ChildCount - 1);
}
if (EmojiButton != null)
EmojiButton.Visible = visible;
}
// Sunrise-End
private void UpdateActiveChannel(ChatSelectChannel selectedChannel)
{
ActiveChannel = (ChatChannel) selectedChannel;

View file

@ -14,7 +14,8 @@ using Robust.Shared.Input;
using Robust.Shared.Player;
using Robust.Shared.Utility;
using System.Linq;
using Robust.Client.UserInterface.RichText; // Sunrise-Edit
using Content.Client._Sunrise.UserInterface.CustomControls;
using Robust.Client.UserInterface.RichText;
using static Robust.Client.UserInterface.Controls.LineEdit;
namespace Content.Client.UserInterface.Systems.Chat.Widgets;
@ -27,6 +28,18 @@ public partial class ChatBox : UIWidget
// По умолчаюнию разрешены только RichTextEntry.DefaultTags.
// Теги ниже нужны для корректного отображения иконок в чате
private static readonly Type[] TagsAllowed =
[
typeof(BoldItalicTag),
typeof(BoldTag),
typeof(BulletTag),
typeof(ColorTag),
typeof(HeadingTag),
typeof(ItalicTag),
typeof(Client._Sunrise.UserInterface.RichText.RadioIconTag),
typeof(_Sunrise.Messenger.EmojiTag),
];
private static readonly Type[] TagsAllowedNoEmoji =
[
typeof(BoldItalicTag),
typeof(BoldTag),
@ -75,6 +88,37 @@ public partial class ChatBox : UIWidget
{
_controller.SetChatWindowOpacity(_configurationManager.GetCVar(CCVars.ChatWindowOpacity));
}
private EmojiPickerWindow? _emojiPicker;
private bool _emojiButtonSubscribed;
public void ToggleEmojiButton(bool visible)
{
ChatInput.ToggleEmojiButton(visible);
if (ChatInput.EmojiButton != null && !_emojiButtonSubscribed)
{
_emojiButtonSubscribed = true;
ChatInput.EmojiButton.OnPressed += _ =>
{
if (_emojiPicker != null && _emojiPicker.IsOpen)
{
_emojiPicker.Close();
return;
}
_emojiPicker = new EmojiPickerWindow();
_emojiPicker.OnEmojiSelected += emojiCode =>
{
ChatInput.Input.Text += emojiCode;
ChatInput.Input.CursorPosition = ChatInput.Input.Text.Length;
ChatInput.Input.GrabKeyboardFocus();
};
_emojiPicker.OnClose += () => _emojiPicker = null;
_emojiPicker.OpenCentered();
};
}
}
// Sunrise-End
private void OnTextEntered(LineEditEventArgs args)
@ -97,7 +141,7 @@ public partial class ChatBox : UIWidget
var color = msg.MessageColorOverride ?? msg.Channel.TextColor();
AddLine(msg.WrappedMessage, color);
AddLine(msg.WrappedMessage, color, msg.Channel); // Sunrise-Edit
}
private void OnHighlightsUpdated(string highlights)
@ -155,14 +199,24 @@ public partial class ChatBox : UIWidget
}
// Sunrise-End
public void AddLine(string message, Color color)
public void AddLine(string message, Color color, ChatChannel channel = ChatChannel.None)
{
// Sunrise-Start
var allowEmoji = channel == ChatChannel.None ||
(channel & (ChatChannel.OOC | ChatChannel.LOOC | ChatChannel.Dead | ChatChannel.AdminRelated | ChatChannel.Server)) != 0;
if (allowEmoji && _entManager.TrySystem<Client._Sunrise.Messenger.ClientEmojiSystem>(out var emojiSystem))
{
message = emojiSystem.ParseEmojis(message);
}
// Sunrise-End
var formatted = new FormattedMessage(3);
formatted.PushColor(color);
formatted.AddMarkupOrThrow(message);
formatted.Pop();
Contents.AddMessage(formatted);
Contents.SetMessage(^1, formatted, TagsAllowed); // Sunrise-Edit
Contents.SetMessage(^1, formatted, allowEmoji ? TagsAllowed : TagsAllowedNoEmoji); // Sunrise-Edit
}
public void Focus(ChatSelectChannel? channel = null)

View file

@ -314,6 +314,25 @@ namespace Content.Client.Viewport
return Vector2.Transform(vpLocal, matrix);
}
// Sunrise-Start
public Vector2 WorldToRenderTargetPixels(Vector2 map)
{
if (_eye == null)
return default;
EnsureViewportCreated();
return _viewport!.WorldToLocal(map);
}
public Vector2 RenderTargetPixelsToWorld(Vector2 pixels)
{
if (_eye == null || _viewport == null)
return default;
return _viewport.LocalToWorld(pixels).Position;
}
// Sunrise-End
public Matrix3x2 GetWorldToScreenMatrix()
{
EnsureViewportCreated();

View file

@ -568,7 +568,7 @@ public sealed partial class GunSystem : SharedGunSystem
var lifetime = 0.4f;
if (TryComp<TimedDespawnComponent>(gunUid, out var despawn))
if (TryComp<TimedDespawnComponent>(ent, out var despawn)) // Sunrise-Edit
{
lifetime = despawn.Lifetime;
}

View file

@ -4,6 +4,10 @@ using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Prototypes;
using Robust.Client.GameObjects;
using Content.Shared.StatusIcon;
using Robust.Client.UserInterface;
namespace Content.Client._Sunrise.CartridgeLoader.Cartridges;
@ -12,10 +16,16 @@ public sealed partial class AddUserDialog : DefaultWindow
{
public event Action<string>? OnUserSelected;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly IEntitySystemManager _entitySystemManager = default!;
private List<MessengerUser> _availableUsers = new();
private SpriteSystem GetSpriteSystem() => _entitySystemManager.GetEntitySystem<SpriteSystem>();
public AddUserDialog()
{
IoCManager.InjectDependencies(this);
RobustXamlLoader.Load(this);
Title = Loc.GetString("messenger-add-user-title");
@ -47,11 +57,29 @@ public sealed partial class AddUserDialog : DefaultWindow
{
var userButton = new Button
{
Text = user.Name,
HorizontalExpand = true,
Margin = new Thickness(2)
};
var buttonContainer = new BoxContainer
{
Orientation = BoxContainer.LayoutOrientation.Horizontal,
HorizontalExpand = true,
Margin = new Thickness(4, 0)
};
CreateJobIconRect(user.JobIconId, buttonContainer);
var nameLabel = new Label
{
Text = user.Name,
HorizontalExpand = true,
Margin = new Thickness(4, 0)
};
buttonContainer.AddChild(nameLabel);
userButton.AddChild(buttonContainer);
var userId = user.UserId;
userButton.OnPressed += _ =>
{
@ -62,4 +90,36 @@ public sealed partial class AddUserDialog : DefaultWindow
UsersList.AddChild(userButton);
}
}
private void CreateJobIconRect(ProtoId<JobIconPrototype>? jobIconId, Control container)
{
if (jobIconId != null && _prototypeManager.TryIndex(jobIconId.Value, out JobIconPrototype? jobIcon))
{
var iconRect = new TextureRect
{
Texture = GetSpriteSystem().Frame0(jobIcon.Icon),
SetWidth = 20,
SetHeight = 20,
Stretch = TextureRect.StretchMode.Scale,
Margin = new Thickness(0, 0, 4, 0)
};
container.AddChild(iconRect);
}
else
{
var unknownIconId = new ProtoId<JobIconPrototype>("JobIconUnknown");
if (_prototypeManager.TryIndex(unknownIconId, out JobIconPrototype? unknownIcon))
{
var iconRect = new TextureRect
{
Texture = GetSpriteSystem().Frame0(unknownIcon.Icon),
SetWidth = 20,
SetHeight = 20,
Stretch = TextureRect.StretchMode.Scale,
Margin = new Thickness(0, 0, 4, 0)
};
container.AddChild(iconRect);
}
}
}
}

View file

@ -1,5 +1,5 @@
using System.Numerics;
using Content.Client._Sunrise.Messenger;
using Content.Client._Sunrise.UserInterface.CustomControls;
using Content.Client.Resources;
using Content.Client.Stylesheets;
using Content.Shared._Sunrise.Messenger;
@ -175,25 +175,7 @@ public sealed partial class MessagePanel : PanelContainer
private void ShowFullImage()
{
if (ImagePreview.Texture == null)
return;
var window = new DefaultWindow
{
Title = Loc.GetString("messenger-image-preview-title"),
MinSize = new Vector2(600, 600)
};
var textureRect = new TextureRect
{
Texture = ImagePreview.Texture,
Stretch = TextureRect.StretchMode.KeepAspect,
HorizontalExpand = true,
VerticalExpand = true
};
window.Contents.AddChild(textureRect);
window.OpenCentered();
PhotoPreviewWindow.Open(ImagePreview.Texture);
}
private void OnResourceLoaded(string resourcePath)

View file

@ -1,8 +1,6 @@
using System.Linq;
using System.Numerics;
using Content.Client._Sunrise.Messenger;
using Content.Client.Resources;
using Content.Shared._Sunrise.SunriseCCVars;
using Content.Shared._Sunrise.CartridgeLoader.Cartridges;
using Content.Shared._Sunrise.Messenger;
using Robust.Client.AutoGenerated;
@ -17,9 +15,9 @@ using Robust.Client.UserInterface.XAML;
using Robust.Shared.Configuration;
using Robust.Shared.Input;
using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
using Content.Shared.StatusIcon;
using Robust.Shared.Timing;
using Content.Client._Sunrise.UserInterface.CustomControls;
namespace Content.Client._Sunrise.CartridgeLoader.Cartridges;
@ -69,27 +67,10 @@ public sealed partial class MessengerUiFragment : BoxContainer
private CreateGroupDialog? _createGroupDialog;
private AddUserDialog? _addUserDialog;
private DefaultWindow? _emojiPickerDialog;
private EmojiPickerWindow? _emojiPickerDialog;
private readonly List<string> _recentEmojis = new();
private readonly HashSet<string> _favoriteEmojis = new();
private BoxContainer? _emojiPickerContentContainer;
private ClientEmojiSystem EmojiSystem => _entitySystemManager.GetEntitySystem<ClientEmojiSystem>();
private SpriteSystem GetSpriteSystem() => _entitySystemManager.GetEntitySystem<SpriteSystem>();
private static readonly Type[] MessageTagsAllowed =
[
typeof(BoldItalicTag),
typeof(BoldTag),
typeof(BulletTag),
typeof(ColorTag),
typeof(HeadingTag),
typeof(ItalicTag),
typeof(EmojiTag),
];
public MessengerUiFragment()
{
IoCManager.InjectDependencies(this);
@ -113,8 +94,6 @@ public sealed partial class MessengerUiFragment : BoxContainer
PersonalChatsTab.Pressed = true;
MessagesContainer.OnScrolled += OnMessagesScrolled;
LoadSavedEmojis();
}
private void OnMessagesScrolled()
@ -552,6 +531,7 @@ public sealed partial class MessengerUiFragment : BoxContainer
var pinnedChats = state.PinnedChats;
var sortedUsers = usersList.OrderByDescending(u => pinnedChats.Contains(GetPersonalChatId(u.UserId)))
.ThenByDescending(u => state.UnreadCounts.TryGetValue(GetPersonalChatId(u.UserId), out var count) && count > 0)
.ThenBy(u => u.Name)
.ToList();
@ -582,6 +562,7 @@ public sealed partial class MessengerUiFragment : BoxContainer
var pinnedChats = state.PinnedChats;
var sortedGroups = state.Groups.OrderByDescending(g => pinnedChats.Contains(g.GroupId))
.ThenByDescending(g => state.UnreadCounts.TryGetValue(g.GroupId, out var count) && count > 0)
.ThenBy(g => g.Name)
.ToList();
@ -1218,7 +1199,7 @@ public sealed partial class MessengerUiFragment : BoxContainer
HorizontalExpand = true
};
foreach (var (id, metadata) in photos)
foreach (var (id, metadata) in photos.OrderByDescending(p => p.Value.Timestamp))
{
var photoControl = new PhotoItemControl(id, metadata,
_netTexturesManager,
@ -1342,449 +1323,24 @@ public sealed partial class MessengerUiFragment : BoxContainer
{
if (_emojiPickerDialog != null && _emojiPickerDialog.IsOpen)
{
UpdateEmojiPickerContent();
_emojiPickerDialog.UpdateEmojiPickerContent();
return;
}
var emojiSystem = EmojiSystem;
var spriteSystem = GetSpriteSystem();
_emojiPickerDialog?.Close();
var dialog = new DefaultWindow
{
Title = Loc.GetString("messenger-emoji-picker-title"),
MinSize = new Vector2(445, 400),
Resizable = false,
};
var dialog = new EmojiPickerWindow();
_emojiPickerDialog = dialog;
var container = new BoxContainer
{
Orientation = LayoutOrientation.Vertical,
HorizontalExpand = true,
VerticalExpand = true,
Margin = new Thickness(8)
};
var scrollContainer = new ScrollContainer
{
HorizontalExpand = true,
VerticalExpand = true,
HScrollEnabled = false,
ReserveScrollbarSpace = true
};
var contentContainer = new BoxContainer
{
Orientation = LayoutOrientation.Vertical,
HorizontalExpand = true,
Margin = new Thickness(0, 0, 0, 5)
};
_emojiPickerContentContainer = contentContainer;
BuildEmojiPickerContent(contentContainer, emojiSystem, spriteSystem);
scrollContainer.AddChild(contentContainer);
container.AddChild(scrollContainer);
dialog.Contents.AddChild(container);
dialog.OnClose += () =>
{
_emojiPickerDialog = null;
_emojiPickerContentContainer = null;
};
dialog.OpenCentered();
}
private void UpdateEmojiPickerContent()
{
if (_emojiPickerContentContainer == null)
return;
var emojiSystem = EmojiSystem;
var spriteSystem = GetSpriteSystem();
_emojiPickerContentContainer.RemoveAllChildren();
BuildEmojiPickerContent(_emojiPickerContentContainer, emojiSystem, spriteSystem);
}
private void BuildEmojiPickerContent(BoxContainer contentContainer, ClientEmojiSystem emojiSystem, SpriteSystem spriteSystem)
{
var allEmojis = emojiSystem.GetAllEmojis().ToList();
var emojiDict = allEmojis.ToDictionary(e => e.Code, e => e);
BuildRecentEmojisSection(contentContainer, emojiDict, spriteSystem);
BuildFavoriteEmojisSection(contentContainer, allEmojis, spriteSystem);
BuildAllEmojisSection(contentContainer, allEmojis, spriteSystem);
}
/// <summary>
/// Создает стиль панели для секций эмодзи
/// </summary>
private StyleBoxTexture CreateEmojiPanelStyle()
{
var panelTex = _resourceCache.GetTexture("/Textures/Interface/Nano/rounded_button_bordered.svg.96dpi.png");
var panelStyle = new StyleBoxTexture
{
Texture = panelTex
};
panelStyle.SetPatchMargin(StyleBox.Margin.All, 5);
panelStyle.SetContentMarginOverride(StyleBox.Margin.All, 8);
panelStyle.Modulate = Color.FromHex("#2F2F35");
return panelStyle;
}
/// <summary>
/// Создает секцию недавних эмодзи
/// </summary>
private void BuildRecentEmojisSection(BoxContainer contentContainer, Dictionary<string, EmojiPrototype> emojiDict, SpriteSystem spriteSystem)
{
var recentPanel = new PanelContainer
{
HorizontalExpand = true,
Margin = new Thickness(0, 0, 0, 10)
};
recentPanel.PanelOverride = CreateEmojiPanelStyle();
var recentSection = new BoxContainer
{
Orientation = LayoutOrientation.Vertical,
HorizontalExpand = true
};
var recentLabel = new Label
{
Text = Loc.GetString("messenger-emoji-recent-title"),
StyleClasses = { "Bold" },
Margin = new Thickness(0, 0, 0, 4)
};
recentSection.AddChild(recentLabel);
if (_recentEmojis.Count > 0)
{
var recentContainer = new GridContainer
{
Columns = 5,
HorizontalExpand = true
};
var recentToShow = _recentEmojis.TakeLast(5).Reverse().ToList();
foreach (var emojiCode in recentToShow)
{
if (emojiDict.TryGetValue(emojiCode, out var emoji))
{
var button = CreateEmojiButton(emoji, spriteSystem, false);
recentContainer.AddChild(button);
}
}
recentSection.AddChild(recentContainer);
}
else
{
var hintLabel = new Label
{
Text = Loc.GetString("messenger-emoji-recent-empty-hint"),
StyleClasses = { "LabelSubText" }
};
recentSection.AddChild(hintLabel);
}
recentPanel.AddChild(recentSection);
contentContainer.AddChild(recentPanel);
}
/// <summary>
/// Создает секцию избранных эмодзи
/// </summary>
private void BuildFavoriteEmojisSection(BoxContainer contentContainer, List<EmojiPrototype> allEmojis, SpriteSystem spriteSystem)
{
var favoritePanel = new PanelContainer
{
HorizontalExpand = true,
Margin = new Thickness(0, 0, 0, 10)
};
favoritePanel.PanelOverride = CreateEmojiPanelStyle();
var favoriteSection = new BoxContainer
{
Orientation = LayoutOrientation.Vertical,
HorizontalExpand = true
};
var favoriteLabel = new Label
{
Text = Loc.GetString("messenger-emoji-favorite-title"),
StyleClasses = { "Bold" },
Margin = new Thickness(0, 0, 0, 4)
};
favoriteSection.AddChild(favoriteLabel);
if (_favoriteEmojis.Count > 0)
{
var favoriteContainer = new GridContainer
{
Columns = 5,
HorizontalExpand = true
};
var favoriteEmojisList = allEmojis.Where(e => _favoriteEmojis.Contains(e.Code)).ToList();
foreach (var emoji in favoriteEmojisList)
{
var button = CreateEmojiButton(emoji, spriteSystem, true);
favoriteContainer.AddChild(button);
}
favoriteSection.AddChild(favoriteContainer);
}
else
{
var hintFavoriteLabel = new Label
{
Text = Loc.GetString("messenger-emoji-favorite-hint"),
StyleClasses = { "LabelSubText" },
Margin = new Thickness(0, 4, 0, 0)
};
favoriteSection.AddChild(hintFavoriteLabel);
}
favoritePanel.AddChild(favoriteSection);
contentContainer.AddChild(favoritePanel);
}
/// <summary>
/// Создает секцию всех эмодзи
/// </summary>
private void BuildAllEmojisSection(BoxContainer contentContainer, List<EmojiPrototype> allEmojis, SpriteSystem spriteSystem)
{
var allPanel = new PanelContainer
{
HorizontalExpand = true,
Margin = new Thickness(0, 0, 0, 0)
};
allPanel.PanelOverride = CreateEmojiPanelStyle();
var allSection = new BoxContainer
{
Orientation = LayoutOrientation.Vertical,
HorizontalExpand = true
};
var allLabel = new Label
{
Text = Loc.GetString("messenger-emoji-all-title"),
StyleClasses = { "Bold" },
Margin = new Thickness(0, 0, 0, 4)
};
allSection.AddChild(allLabel);
var allEmojisContainer = new GridContainer
{
Columns = 5,
HorizontalExpand = true
};
var nonFavoriteEmojis = allEmojis.Where(e => !_favoriteEmojis.Contains(e.Code)).ToList();
foreach (var emoji in nonFavoriteEmojis)
{
var button = CreateEmojiButton(emoji, spriteSystem, false);
allEmojisContainer.AddChild(button);
}
allSection.AddChild(allEmojisContainer);
allPanel.AddChild(allSection);
contentContainer.AddChild(allPanel);
}
private Button CreateEmojiButton(EmojiPrototype emoji, SpriteSystem spriteSystem, bool isFavorite)
{
var emojiButton = new Button
{
MinSize = new Vector2(75, 75),
MaxSize = new Vector2(75, 75),
ToolTip = emoji.Code
};
try
{
var spriteSpec = new SpriteSpecifier.Rsi(new ResPath(emoji.SpritePath), emoji.SpriteState);
var state = spriteSystem.RsiStateLike(spriteSpec);
emojiButton.Label.Visible = false;
if (state.IsAnimated)
{
var animatedRect = new AnimatedTextureRect
{
SetWidth = 65,
SetHeight = 65,
HorizontalAlignment = HAlignment.Center,
VerticalAlignment = VAlignment.Center
};
animatedRect.SetFromSpriteSpecifier(spriteSpec);
animatedRect.DisplayRect.HorizontalExpand = true;
animatedRect.DisplayRect.VerticalExpand = true;
animatedRect.DisplayRect.Stretch = TextureRect.StretchMode.KeepAspectCentered;
emojiButton.AddChild(animatedRect);
}
else
{
var texture = spriteSystem.Frame0(spriteSpec);
var textureRect = new TextureRect
{
Texture = texture,
SetWidth = 45,
SetHeight = 45,
HorizontalAlignment = HAlignment.Center,
VerticalAlignment = VAlignment.Center,
Stretch = TextureRect.StretchMode.KeepAspectCentered
};
emojiButton.AddChild(textureRect);
}
}
catch
{
emojiButton.Text = emoji.Code;
}
var emojiCode = emoji.Code;
emojiButton.OnPressed += _ =>
dialog.OnEmojiSelected += (emojiCode) =>
{
var currentText = MessageInput.Text;
MessageInput.Text = currentText + emojiCode;
MessageInput.CursorPosition = MessageInput.Text.Length;
AddToRecentEmojis(emojiCode);
if (_emojiPickerDialog != null && _emojiPickerDialog.IsOpen)
{
UpdateEmojiPickerContent();
}
};
emojiButton.OnKeyBindDown += args =>
{
if (args.Function == EngineKeyFunctions.UIRightClick)
{
args.Handle();
ToggleFavoriteEmoji(emojiCode);
if (_emojiPickerDialog != null && _emojiPickerDialog.IsOpen)
{
UpdateEmojiPickerContent();
}
}
};
return emojiButton;
}
private void AddToRecentEmojis(string emojiCode)
{
_recentEmojis.Remove(emojiCode);
_recentEmojis.Add(emojiCode);
if (_recentEmojis.Count > 5)
{
_recentEmojis.RemoveAt(0);
}
SaveRecentEmojis();
}
private void ToggleFavoriteEmoji(string emojiCode)
{
if (!_favoriteEmojis.Add(emojiCode))
{
_favoriteEmojis.Remove(emojiCode);
}
SaveFavoriteEmojis();
}
private void LoadSavedEmojis()
{
HashSet<string>? allEmojis = null;
try
{
allEmojis = EmojiSystem.GetAllEmojis().Select(e => e.Code).ToHashSet();
}
catch
{
// ignored
}
try
{
var recentEmojisStr = _configurationManager.GetCVar(SunriseCCVars.MessengerRecentEmojis);
if (!string.IsNullOrWhiteSpace(recentEmojisStr))
{
var emojiCodes = recentEmojisStr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
_recentEmojis.Clear();
foreach (var code in emojiCodes)
{
if (string.IsNullOrWhiteSpace(code))
continue;
if (allEmojis != null && !allEmojis.Contains(code))
continue;
_recentEmojis.Add(code);
if (_recentEmojis.Count >= 5)
break;
}
}
}
catch
{
_recentEmojis.Clear();
}
try
{
var favoriteEmojisStr = _configurationManager.GetCVar(SunriseCCVars.MessengerFavoriteEmojis);
if (!string.IsNullOrWhiteSpace(favoriteEmojisStr))
{
var emojiCodes = favoriteEmojisStr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
_favoriteEmojis.Clear();
foreach (var code in emojiCodes)
{
if (string.IsNullOrWhiteSpace(code))
continue;
if (allEmojis != null && !allEmojis.Contains(code))
continue;
_favoriteEmojis.Add(code);
}
}
}
catch
{
_favoriteEmojis.Clear();
}
}
private void SaveRecentEmojis()
{
try
{
var emojiCodesStr = string.Join(",", _recentEmojis);
_configurationManager.SetCVar(SunriseCCVars.MessengerRecentEmojis, emojiCodesStr);
_configurationManager.SaveToFile();
}
catch
{
// ignored
}
}
private void SaveFavoriteEmojis()
{
try
{
var emojiCodesStr = string.Join(",", _favoriteEmojis);
_configurationManager.SetCVar(SunriseCCVars.MessengerFavoriteEmojis, emojiCodesStr);
_configurationManager.SaveToFile();
}
catch
{
// ignored
}
dialog.OnClose += () => _emojiPickerDialog = null;
dialog.OpenCentered();
}
#region UI Helper Methods

View file

@ -49,7 +49,7 @@ public sealed class PhotoCaptureOverlay : Overlay
}
var sourcePos = _transformSystem.GetWorldPosition(source.Value);
var targetPos = photoSystem.GetCameraPosition(source.Value, photoSystem.CaptureDistance);
var targetPos = photoSystem.GetClampedCapturePosition(source.Value, photoSystem.CaptureDistance);
var targetScreen = _eyeManager.WorldToScreen(targetPos);
var playerScreen = _eyeManager.WorldToScreen(sourcePos);
@ -58,10 +58,13 @@ public sealed class PhotoCaptureOverlay : Overlay
if (pixelsPerMeter < 1) return;
var halfSize = (3.0f * pixelsPerMeter) / 2.0f;
var rect = new UIBox2(targetScreen.X - halfSize, targetScreen.Y - halfSize, targetScreen.X + halfSize, targetScreen.Y + halfSize);
var vpRect = viewportControl.GlobalPixelRect;
var size = 3.0f * pixelsPerMeter;
size = Math.Min(size, Math.Min(vpRect.Width, vpRect.Height));
var halfSize = size / 2.0f;
var rect = UIBox2.FromDimensions(targetScreen.X - halfSize, targetScreen.Y - halfSize, size, size);
var color = Color.Black.WithAlpha(0.5f);
screenHandle.DrawRect(new UIBox2(vpRect.Left, vpRect.Top, vpRect.Right, rect.Top), color);

View file

@ -1,39 +1,41 @@
using System.IO;
using Content.Shared.CartridgeLoader;
using System.Linq;
using System.Numerics;
using Content.Shared._Sunrise.CartridgeLoader.Cartridges;
using Content.Client.Viewport;
using Content.Shared.Mobs.Components;
using Robust.Client.Graphics;
using Robust.Client.State;
using Robust.Shared.Network;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using Robust.Client.Player;
using Robust.Client.UserInterface;
using Robust.Client.Utility;
using Robust.Shared.Physics;
using Robust.Shared.Physics.Systems;
using Robust.Shared.Map.Components;
using Robust.Shared.Network;
using Robust.Shared.Timing;
namespace Content.Client._Sunrise.CartridgeLoader.Cartridges;
public sealed class PhotoCartridgeClientSystem : EntitySystem
{
[Dependency] private readonly IClientNetManager _netManager = default!;
[Dependency] private readonly ILogManager _logManager = default!;
[Dependency] private readonly IStateManager _stateManager = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly SharedTransformSystem _transformSystem = default!;
[Dependency] private readonly IEntityManager _entityManager = default!;
[Dependency] private readonly SharedPhysicsSystem _physics = default!;
[Dependency] private readonly IEyeManager _eyeManager = default!;
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly INetManager _netManager = default!;
[Dependency] private readonly IEyeManager _eyeManager = default!;
[Dependency] private readonly NetTexturesManager _netTexturesManager = default!;
private TimeSpan _nextCaptureTime = TimeSpan.Zero;
public bool CameraReady => _timing.CurTime >= _nextCaptureTime;
public float CaptureDistance { get; set; } = 2.0f;
public float MinCaptureDistance { get; set; } = 1.0f;
private ISawmill _sawmill = default!;
@ -43,7 +45,6 @@ public sealed class PhotoCartridgeClientSystem : EntitySystem
public override void Initialize()
{
_sawmill = _logManager.GetSawmill("photo.cartridge.client");
_netManager.RegisterNetMessage<PdaPhotoCaptureMessage>(accept: NetMessageAccept.Server);
}
public override void Shutdown()
@ -92,6 +93,9 @@ public sealed class PhotoCartridgeClientSystem : EntitySystem
public void CaptureAndSendPhoto(EntityUid source)
{
if (TryComp<CartridgeComponent>(source, out var cart) && cart.LoaderUid.HasValue)
source = cart.LoaderUid.Value;
if (_timing.CurTime < _nextCaptureTime)
return;
@ -100,6 +104,32 @@ public sealed class PhotoCartridgeClientSystem : EntitySystem
Timer.Spawn(500, () => CaptureInternal(source));
}
public Vector2 GetClampedCapturePosition(EntityUid source, float distance)
{
var targetPos = GetCameraPosition(source, distance);
if (_stateManager.CurrentState is not IMainViewportState viewportState)
return targetPos;
var targetScreen = _eyeManager.WorldToScreen(targetPos);
var sourcePos = _transformSystem.GetWorldPosition(source);
var sourceScreen = _eyeManager.WorldToScreen(sourcePos);
var offsetScreen = _eyeManager.WorldToScreen(sourcePos + new Vector2(1, 0));
var pixelsPerMeter = (offsetScreen - sourceScreen).Length();
var size = 3.0f * pixelsPerMeter;
var control = (Control)viewportState.Viewport;
var vpRect = control.GlobalPixelRect;
size = Math.Min(size, Math.Min(vpRect.Width, vpRect.Height));
var halfSize = size / 2f;
var clampedX = Math.Clamp(targetScreen.X, vpRect.Left + halfSize, vpRect.Right - halfSize);
var clampedY = Math.Clamp(targetScreen.Y, vpRect.Top + halfSize, vpRect.Bottom - halfSize);
return _eyeManager.ScreenToMap(new Vector2(clampedX, clampedY)).Position;
}
private void CaptureInternal(EntityUid source)
{
if (!_netManager.IsConnected)
@ -108,51 +138,47 @@ public sealed class PhotoCartridgeClientSystem : EntitySystem
return;
}
if (_playerManager.LocalPlayer?.ControlledEntity is not { } player)
if (_playerManager.LocalEntity is not { })
return;
if (_stateManager.CurrentState is not IMainViewportState viewportState || viewportState.Viewport is not Control control)
if (_stateManager.CurrentState is not IMainViewportState viewportState)
return;
var viewport = viewportState.Viewport.Viewport;
var sourcePos = _transformSystem.GetWorldPosition(source);
var targetPos = GetClampedCapturePosition(source, CaptureDistance);
var targetPos = GetCameraPosition(source, CaptureDistance);
var targetScreen = _eyeManager.WorldToScreen(targetPos);
var sourceScreen = _eyeManager.WorldToScreen(sourcePos);
var offsetScreen = _eyeManager.WorldToScreen(sourcePos + new Vector2(1, 0));
var logicalPixelsPerMeter = (offsetScreen - sourceScreen).Length();
var center = viewport.WorldToRenderTargetPixels(targetPos);
var p1 = viewport.WorldToRenderTargetPixels(sourcePos);
var p2 = viewport.WorldToRenderTargetPixels(sourcePos + new Vector2(1, 0));
var pixelSize = 3.0f * (p2 - p1).Length();
viewport.Screenshot(img => {
var scaleX = (float)img.Width / control.Size.X;
var scaleY = (float)img.Height / control.Size.Y;
var size = (int)pixelSize;
size = Math.Min(size, Math.Min(img.Width, img.Height));
var texturePixelsPerMeter = logicalPixelsPerMeter * scaleX;
var size = (int)(3.0f * texturePixelsPerMeter);
var left = (int)(center.X - size / 2f);
var top = (int)(center.Y - size / 2f);
var localTargetLogical = targetScreen - control.GlobalPosition;
var centerX = localTargetLogical.X * scaleX;
var centerY = localTargetLogical.Y * scaleY;
left = Math.Clamp(left, 0, img.Width - size);
top = Math.Clamp(top, 0, img.Height - size);
var x = (int)(centerX - size / 2f);
var y = (int)(centerY - size / 2f);
if (size <= 0)
{
img.Dispose();
return;
}
x = Math.Clamp(x, 0, img.Width);
y = Math.Clamp(y, 0, img.Height);
var w = Math.Clamp(size, 1, img.Width - x);
var h = Math.Clamp(size, 1, img.Height - y);
var finalRect = new SixLabors.ImageSharp.Rectangle(x, y, w, h);
TakeScreenshot(img, finalRect);
var finalRect = new Rectangle(left, top, size, size);
TakeScreenshot(source, img, finalRect);
});
}
private void TakeScreenshot<T>(Image<T> screenshot, SixLabors.ImageSharp.Rectangle cropRect) where T : unmanaged, IPixel<T>
private void TakeScreenshot<T>(EntityUid source, Image<T> screenshot, Rectangle cropRect) where T : unmanaged, IPixel<T>
{
try
{
ProcessCapturedImage(screenshot, cropRect);
ProcessCapturedImage(source, screenshot, cropRect);
}
catch (Exception ex)
{
@ -164,40 +190,48 @@ public sealed class PhotoCartridgeClientSystem : EntitySystem
}
}
private void ProcessCapturedImage<T>(Image<T> image, SixLabors.ImageSharp.Rectangle cropRect) where T : unmanaged, IPixel<T>
private void ProcessCapturedImage<T>(EntityUid source, Image<T> image, Rectangle cropRect) where T : unmanaged, IPixel<T>
{
var processed = image.Clone(ctx =>
{
ctx.Crop(cropRect);
ctx.Resize(TargetPhotoWidth, TargetPhotoHeight);
});
if (cropRect.Width <= 0 || cropRect.Height <= 0)
return;
var width = processed.Width;
var height = processed.Height;
var rescaled = new Image<T>(TargetPhotoWidth, TargetPhotoHeight);
var rescaledSpan = rescaled.GetPixelSpan();
var sourceSpan = image.GetPixelSpan();
float scaleX = (float)cropRect.Width / TargetPhotoWidth;
float scaleY = (float)cropRect.Height / TargetPhotoHeight;
for (int y = 0; y < TargetPhotoHeight; y++)
{
for (int x = 0; x < TargetPhotoWidth; x++)
{
int srcX = cropRect.X + (int)(x * scaleX);
int srcY = cropRect.Y + (int)(y * scaleY);
if (srcX >= 0 && srcX < image.Width && srcY >= 0 && srcY < image.Height)
{
rescaledSpan[y * TargetPhotoWidth + x] = sourceSpan[srcY * image.Width + srcX];
}
}
}
var width = rescaled.Width;
var height = rescaled.Height;
byte[] imageData;
using (var memoryStream = new MemoryStream())
{
processed.SaveAsPng(memoryStream);
rescaled.SaveAsPng(memoryStream);
imageData = memoryStream.ToArray();
}
processed.Dispose();
SendPhotoToServer(imageData, width, height);
rescaled.Dispose();
SendPhotoToServer(source, imageData, width, height);
}
private void SendPhotoToServer(byte[] imageData, int width, int height)
private void SendPhotoToServer(EntityUid source, byte[] imageData, int width, int height)
{
if (!_netManager.IsConnected)
return;
var message = new PdaPhotoCaptureMessage
{
ImageData = imageData,
Width = width,
Height = height
};
_netManager.ClientSendMessage(message);
_netTexturesManager.SendPhotoToServer(_entityManager.GetNetEntity(source), imageData, width, height);
}
}

View file

@ -1,5 +1,3 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using Content.Client.Viewport;
@ -67,10 +65,10 @@ public sealed partial class PhotoUiFragment : BoxContainer
SwitchTab(true);
var system = _entityManager.System<PhotoCartridgeClientSystem>();
ZoomSlider.Value = (system.CaptureDistance - 2.0f) / 8.0f;
ZoomSlider.Value = (system.CaptureDistance - system.MinCaptureDistance) / 8.0f;
ZoomSlider.OnValueChanged += args =>
{
system.CaptureDistance = 2.0f + args.Value * 8.0f;
system.CaptureDistance = system.MinCaptureDistance + args.Value * 8.0f;
};
SetupCameraPreview();
@ -140,7 +138,7 @@ public sealed partial class PhotoUiFragment : BoxContainer
if (CameraPreview != null)
{
CameraPreview.Eye = _previewEye;
CameraPreview.ViewportSize = new Robust.Shared.Maths.Vector2i(256, 256);
CameraPreview.ViewportSize = new Vector2i(256, 256);
CameraPreview.RenderScaleMode = ScalingViewportRenderScaleMode.Fixed;
CameraPreview.FixedRenderScale = 1;
@ -175,7 +173,7 @@ public sealed partial class PhotoUiFragment : BoxContainer
var xform = _entityManager.GetComponent<TransformComponent>(source);
var sourcePos = xform.MapPosition;
var targetPos = photoSystem.GetCameraPosition(source, photoSystem.CaptureDistance);
var targetPos = photoSystem.GetClampedCapturePosition(source, photoSystem.CaptureDistance);
_previewEye.Position = sourcePos.Offset(targetPos - sourcePos.Position);
_previewEye.Rotation = _eyeManager.CurrentEye.Rotation;

View file

@ -3,15 +3,13 @@ using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Content.Shared._Sunrise.CartridgeLoader.Cartridges;
using Content.Shared._Sunrise.NetTextures;
using Robust.Client;
using Robust.Client.ResourceManagement;
using Robust.Client.Upload;
using Robust.Shared.Asynchronous;
using Robust.Shared.ContentPack;
using Robust.Shared.Network;
using Robust.Shared.Network.Transfer;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Client._Sunrise;
@ -39,14 +37,14 @@ public sealed class NetTexturesManager
private const string UploadedPrefix = "/Uploaded";
private readonly HashSet<string> _requestedResources = new();
private readonly Dictionary<string, ResPath> _pendingResources = new(); // resourcePath -> ResPath
private readonly Dictionary<string, ResPath> _pendingResources = new();
private readonly MemoryContentRoot _netTexturesContentRoot = new();
/// <summary>
/// Event fired when a network texture becomes available.
/// </summary>
public event Action<string>? ResourceLoaded; // resourcePath
public event Action<string>? ResourceLoaded;
public void Initialize()
{
@ -55,10 +53,8 @@ public sealed class NetTexturesManager
_transferManager.RegisterTransferMessage(TransferKeyNetTextures, ReceiveNetTexturesTransfer);
// NetworkResourceUploadMessage is already registered by SharedNetworkResourceManager
// We'll check for loaded resources in Update() method very frequently
_netManager.RegisterNetMessage<PdaPhotoCaptureMessage>(accept: NetMessageAccept.Server);
// Clear resources when disconnecting to ensure fresh state on reconnection
_baseClient.RunLevelChanged += OnRunLevelChanged;
}
@ -78,20 +74,16 @@ public sealed class NetTexturesManager
try
{
// Read transfer stream using the same format as SharedNetworkResourceManager
// But without IAsyncEnumerable to avoid sandbox violations
await ReadTransferStream(stream, (relative, data) =>
{
fileCount++;
totalSize += data.Length;
_sawmill.Verbose($"Storing NetTexture: {relative} ({ByteHelpers.FormatBytes(data.Length)})");
// Store file on main thread (required for MemoryContentRoot)
_taskManager.RunOnMainThread(() =>
{
_netTexturesContentRoot.AddOrUpdateFile(relative, data);
// Check if any pending resources are now available
CheckPendingResourcesAfterLoad(relative);
});
});
@ -149,7 +141,6 @@ public sealed class NetTexturesManager
var relativePath = resPath.ToRelativePath();
bool exists = false;
// For RSI directories, check if all files are present
var pathStr = relativePath.ToString();
if (pathStr.EndsWith(".rsi") || pathStr.EndsWith(".rsi/"))
{
@ -157,7 +148,6 @@ public sealed class NetTexturesManager
}
else
{
// Single file - check through resource manager (our content root is added there)
var uploadedPath = (new ResPath(UploadedPrefix) / relativePath).ToRootedPath();
exists = _resourceManager.ContentFileExists(uploadedPath);
}
@ -188,34 +178,24 @@ public sealed class NetTexturesManager
public void Update(float frameTime)
{
// If there are no pending resources, skip checking
if (_pendingResources.Count == 0)
return;
// Check for loaded resources every frame when there are pending resources
// This ensures we catch resources as soon as they're loaded
var completedResources = new List<string>();
foreach (var (resourcePath, resPath) in _pendingResources)
{
// Check if resource is available
// MemoryContentRoot stores paths relative to /Uploaded prefix
var relativePath = resPath.ToRelativePath();
bool exists = false;
ResPath checkPath;
// For RSI directories, check if all files are present
// This ensures all PNG files are present, not just meta.json
// Check if path ends with .rsi (more reliable than Extension property)
var pathStr = relativePath.ToString();
if (pathStr.EndsWith(".rsi") || pathStr.EndsWith(".rsi/"))
{
// Check if all RSI files are present by reading meta.json and verifying all PNG files exist
exists = CheckRsiFilesComplete(relativePath);
}
else
{
// Single file - check through resource manager
var uploadedPath = (new ResPath(UploadedPrefix) / relativePath).ToRootedPath();
exists = _resourceManager.ContentFileExists(uploadedPath);
}
@ -241,7 +221,6 @@ public sealed class NetTexturesManager
/// <returns>True if the resource is available, false if it's being requested</returns>
public bool EnsureResource(string resourcePath)
{
// Normalize the path
ResPath resPath;
if (resourcePath.StartsWith("/"))
{
@ -253,14 +232,11 @@ public sealed class NetTexturesManager
resPath = rootPath / resourcePath;
}
// Check if the resource is actually available
var relativePath = resPath.ToRelativePath();
var uploadedPath = (new ResPath(UploadedPrefix) / relativePath).ToRootedPath();
bool isAvailable = false;
// For RSI directories, check for meta.json
// Check if path ends with .rsi (more reliable than Extension property)
var pathStr = relativePath.ToString();
if (pathStr.EndsWith(".rsi") || pathStr.EndsWith(".rsi/"))
{
@ -270,40 +246,31 @@ public sealed class NetTexturesManager
}
else
{
// Single file
isAvailable = _resourceManager.ContentFileExists(uploadedPath);
}
if (isAvailable)
{
// Resource is available
if (!_requestedResources.Contains(resourcePath))
_requestedResources.Add(resourcePath);
// Remove from pending if it was there
_pendingResources.Remove(resourcePath);
return true;
}
// Resource is not available yet
// If it's already in pending, we're already tracking it
if (_pendingResources.ContainsKey(resourcePath))
{
return false;
}
// If it was requested before but not in pending, add it to pending to track it
if (_requestedResources.Contains(resourcePath))
{
_pendingResources[resourcePath] = resPath;
return false;
}
// Request the resource for the first time
RequestResource(resourcePath);
_pendingResources[resourcePath] = resPath;
// Immediately check if the resource is already available (might be cached or loaded very fast)
// This helps catch resources that load synchronously
CheckResourceImmediately(resourcePath, resPath);
return false;
@ -314,7 +281,6 @@ public sealed class NetTexturesManager
if (_requestedResources.Contains(resourcePath))
return;
// Check if client is connected to server before trying to send message
if (!_netManager.IsConnected)
{
_sawmill.Debug($"Cannot request resource {resourcePath}: client not connected to server");
@ -340,17 +306,13 @@ public sealed class NetTexturesManager
var relativePath = resPath.ToRelativePath();
bool exists = false;
// For RSI directories, check if all files are present
// Check if path ends with .rsi (more reliable than Extension property)
var pathStr = relativePath.ToString();
if (pathStr.EndsWith(".rsi") || pathStr.EndsWith(".rsi/"))
{
// Check if all RSI files are present by reading meta.json and verifying all PNG files exist
exists = CheckRsiFilesComplete(relativePath);
}
else
{
// Single file - check through resource manager
var uploadedPath = (new ResPath(UploadedPrefix) / relativePath).ToRootedPath();
exists = _resourceManager.ContentFileExists(uploadedPath);
}
@ -383,7 +345,7 @@ public sealed class NetTexturesManager
var relativePath = resPath.ToRelativePath();
var path = new ResPath(UploadedPrefix) / relativePath;
return path.ToRootedPath(); // Ensure it's always rooted
return path.ToRootedPath();
}
/// <summary>
@ -395,17 +357,14 @@ public sealed class NetTexturesManager
{
try
{
// Read meta.json from uploaded path (check through VFS)
var uploadedPath = (new ResPath(UploadedPrefix) / relativePath).ToRootedPath();
var metaUploadedPath = (uploadedPath / "meta.json").ToRootedPath();
// First check if meta.json exists in VFS
if (!_resourceManager.ContentFileExists(metaUploadedPath))
{
return false;
}
// Try to read meta.json to verify it's accessible
if (!_resourceManager.TryContentFileRead(metaUploadedPath, out var metaStream))
{
return false;
@ -413,22 +372,17 @@ public sealed class NetTexturesManager
using (metaStream)
{
// Read JSON text
using var reader = new StreamReader(metaStream);
var jsonText = reader.ReadToEnd();
// Simple regex to extract state names from JSON
// Matches "name": "statename" patterns
var namePattern = new Regex(@"""name""\s*:\s*""([^""]+)""", RegexOptions.Compiled);
var matches = namePattern.Matches(jsonText);
if (matches.Count == 0)
{
// No states found, might be invalid JSON or empty states array
return false;
}
// Check if all PNG files for each state exist in VFS
foreach (Match match in matches)
{
if (match.Groups.Count < 2)
@ -440,7 +394,6 @@ public sealed class NetTexturesManager
continue;
}
// Check if PNG file exists in VFS (not just MemoryContentRoot)
var pngUploadedPath = (uploadedPath / $"{stateName}.png").ToRootedPath();
if (!_resourceManager.ContentFileExists(pngUploadedPath))
{
@ -458,5 +411,32 @@ public sealed class NetTexturesManager
return false;
}
}
}
/// <summary>
/// Sends a captured photo to the server.
/// This method is used by PhotoCartridgeClientSystem to avoid registering its own network message.
/// </summary>
/// <param name="loaderUid">Uid of the cartridge loader (PDA) that took the photo</param>
/// <param name="imageData">PNG image data</param>
/// <param name="width">Image width in pixels</param>
/// <param name="height">Image height in pixels</param>
public void SendPhotoToServer(NetEntity loaderUid, byte[] imageData, int width, int height)
{
if (!_netManager.IsConnected)
{
_sawmill.Warning("Cannot send photo: client not connected to server");
return;
}
var message = new PdaPhotoCaptureMessage
{
LoaderUid = loaderUid,
ImageData = imageData,
Width = width,
Height = height
};
_netManager.ClientSendMessage(message);
_sawmill.Debug($"Sent photo to server: {width}x{height}, {imageData.Length} bytes, loader: {loaderUid}");
}
}

View file

@ -0,0 +1,398 @@
using Content.Client._Sunrise.Messenger;
using Content.Shared._Sunrise.Messenger;
using Content.Shared._Sunrise.SunriseCCVars;
using Robust.Client.GameObjects;
using Robust.Client.Graphics;
using Robust.Client.ResourceManagement;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
using Robust.Shared.Configuration;
using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
using System.Linq;
using System.Numerics;
using Content.Client.Resources;
using Robust.Shared.Input;
namespace Content.Client._Sunrise.UserInterface.CustomControls;
public sealed class EmojiPickerWindow : DefaultWindow
{
[Dependency] private readonly IEntitySystemManager _entitySystemManager = default!;
[Dependency] private readonly IResourceCache _resourceCache = default!;
[Dependency] private readonly IConfigurationManager _configurationManager = default!;
private readonly BoxContainer? _emojiPickerContentContainer;
private readonly List<string> _recentEmojis = new();
private readonly HashSet<string> _favoriteEmojis = new();
public event Action<string>? OnEmojiSelected;
private ClientEmojiSystem EmojiSystem => _entitySystemManager.GetEntitySystem<ClientEmojiSystem>();
private SpriteSystem SpriteSystem => _entitySystemManager.GetEntitySystem<SpriteSystem>();
public EmojiPickerWindow()
{
IoCManager.InjectDependencies(this);
Title = Loc.GetString("messenger-emoji-picker-title");
MinSize = new Vector2(445, 400);
Resizable = false;
var container = new BoxContainer
{
Orientation = BoxContainer.LayoutOrientation.Vertical,
HorizontalExpand = true,
VerticalExpand = true,
Margin = new Thickness(8)
};
var scrollContainer = new ScrollContainer
{
HorizontalExpand = true,
VerticalExpand = true,
HScrollEnabled = false,
ReserveScrollbarSpace = true
};
_emojiPickerContentContainer = new BoxContainer
{
Orientation = BoxContainer.LayoutOrientation.Vertical,
HorizontalExpand = true,
Margin = new Thickness(0, 0, 0, 5)
};
scrollContainer.AddChild(_emojiPickerContentContainer);
container.AddChild(scrollContainer);
Contents.AddChild(container);
LoadSavedEmojis();
UpdateEmojiPickerContent();
}
public void UpdateEmojiPickerContent()
{
if (_emojiPickerContentContainer == null)
return;
_emojiPickerContentContainer.RemoveAllChildren();
var allEmojis = EmojiSystem.GetAllEmojis().ToList();
var emojiDict = allEmojis.ToDictionary(e => e.Code, e => e);
BuildRecentEmojisSection(_emojiPickerContentContainer, emojiDict);
BuildFavoriteEmojisSection(_emojiPickerContentContainer, allEmojis);
BuildAllEmojisSection(_emojiPickerContentContainer, allEmojis);
}
private StyleBoxTexture CreateEmojiPanelStyle()
{
var panelTex = _resourceCache.GetTexture("/Textures/Interface/Nano/rounded_button_bordered.svg.96dpi.png");
var panelStyle = new StyleBoxTexture
{
Texture = panelTex
};
panelStyle.SetPatchMargin(StyleBox.Margin.All, 5);
panelStyle.SetContentMarginOverride(StyleBox.Margin.All, 8);
panelStyle.Modulate = Color.FromHex("#2F2F35");
return panelStyle;
}
private void BuildRecentEmojisSection(BoxContainer contentContainer, Dictionary<string, EmojiPrototype> emojiDict)
{
var recentPanel = new PanelContainer
{
HorizontalExpand = true,
Margin = new Thickness(0, 0, 0, 10),
PanelOverride = CreateEmojiPanelStyle()
};
var recentSection = new BoxContainer
{
Orientation = BoxContainer.LayoutOrientation.Vertical,
HorizontalExpand = true
};
recentSection.AddChild(new Label
{
Text = Loc.GetString("messenger-emoji-recent-title"),
StyleClasses = { "Bold" },
Margin = new Thickness(0, 0, 0, 4)
});
if (_recentEmojis.Count > 0)
{
var recentContainer = new GridContainer
{
Columns = 5,
HorizontalExpand = true
};
var recentToShow = _recentEmojis.TakeLast(5).Reverse().ToList();
foreach (var emojiCode in recentToShow)
{
if (emojiDict.TryGetValue(emojiCode, out var emoji))
{
recentContainer.AddChild(CreateEmojiButton(emoji, false));
}
}
recentSection.AddChild(recentContainer);
}
else
{
recentSection.AddChild(new Label
{
Text = Loc.GetString("messenger-emoji-recent-empty-hint"),
StyleClasses = { "LabelSubText" }
});
}
recentPanel.AddChild(recentSection);
contentContainer.AddChild(recentPanel);
}
private void BuildFavoriteEmojisSection(BoxContainer contentContainer, List<EmojiPrototype> allEmojis)
{
var favoritePanel = new PanelContainer
{
HorizontalExpand = true,
Margin = new Thickness(0, 0, 0, 10),
PanelOverride = CreateEmojiPanelStyle()
};
var favoriteSection = new BoxContainer
{
Orientation = BoxContainer.LayoutOrientation.Vertical,
HorizontalExpand = true
};
favoriteSection.AddChild(new Label
{
Text = Loc.GetString("messenger-emoji-favorite-title"),
StyleClasses = { "Bold" },
Margin = new Thickness(0, 0, 0, 4)
});
if (_favoriteEmojis.Count > 0)
{
var favoriteContainer = new GridContainer
{
Columns = 5,
HorizontalExpand = true
};
var favoriteEmojisList = allEmojis.Where(e => _favoriteEmojis.Contains(e.Code)).ToList();
foreach (var emoji in favoriteEmojisList)
{
favoriteContainer.AddChild(CreateEmojiButton(emoji, true));
}
favoriteSection.AddChild(favoriteContainer);
}
else
{
favoriteSection.AddChild(new Label
{
Text = Loc.GetString("messenger-emoji-favorite-hint"),
StyleClasses = { "LabelSubText" },
Margin = new Thickness(0, 4, 0, 0)
});
}
favoritePanel.AddChild(favoriteSection);
contentContainer.AddChild(favoritePanel);
}
private void BuildAllEmojisSection(BoxContainer contentContainer, List<EmojiPrototype> allEmojis)
{
var allPanel = new PanelContainer
{
HorizontalExpand = true,
PanelOverride = CreateEmojiPanelStyle()
};
var allSection = new BoxContainer
{
Orientation = BoxContainer.LayoutOrientation.Vertical,
HorizontalExpand = true
};
allSection.AddChild(new Label
{
Text = Loc.GetString("messenger-emoji-all-title"),
StyleClasses = { "Bold" },
Margin = new Thickness(0, 0, 0, 4)
});
var allEmojisContainer = new GridContainer
{
Columns = 5,
HorizontalExpand = true
};
var nonFavoriteEmojis = allEmojis.Where(e => !_favoriteEmojis.Contains(e.Code)).ToList();
foreach (var emoji in nonFavoriteEmojis)
{
allEmojisContainer.AddChild(CreateEmojiButton(emoji, false));
}
allSection.AddChild(allEmojisContainer);
allPanel.AddChild(allSection);
contentContainer.AddChild(allPanel);
}
private Button CreateEmojiButton(EmojiPrototype emoji, bool isFavorite)
{
var emojiButton = new Button
{
MinSize = new Vector2(75, 75),
MaxSize = new Vector2(75, 75),
ToolTip = emoji.Code
};
try
{
var spriteSpec = new SpriteSpecifier.Rsi(new ResPath(emoji.SpritePath), emoji.SpriteState);
var state = SpriteSystem.RsiStateLike(spriteSpec);
emojiButton.Label.Visible = false;
if (state.IsAnimated)
{
var animatedRect = new AnimatedTextureRect
{
SetWidth = 65,
SetHeight = 65,
HorizontalAlignment = Control.HAlignment.Center,
VerticalAlignment = Control.VAlignment.Center
};
animatedRect.SetFromSpriteSpecifier(spriteSpec);
animatedRect.DisplayRect.HorizontalExpand = true;
animatedRect.DisplayRect.VerticalExpand = true;
animatedRect.DisplayRect.Stretch = TextureRect.StretchMode.KeepAspectCentered;
emojiButton.AddChild(animatedRect);
}
else
{
var texture = SpriteSystem.Frame0(spriteSpec);
var textureRect = new TextureRect
{
Texture = texture,
SetWidth = 45,
SetHeight = 45,
HorizontalAlignment = Control.HAlignment.Center,
VerticalAlignment = Control.VAlignment.Center,
Stretch = TextureRect.StretchMode.KeepAspectCentered
};
emojiButton.AddChild(textureRect);
}
}
catch
{
emojiButton.Text = emoji.Code;
}
var emojiCode = emoji.Code;
emojiButton.OnPressed += _ =>
{
OnEmojiSelected?.Invoke(emojiCode);
AddToRecentEmojis(emojiCode);
UpdateEmojiPickerContent();
};
emojiButton.OnKeyBindDown += args =>
{
if (args.Function == EngineKeyFunctions.UIRightClick)
{
args.Handle();
ToggleFavoriteEmoji(emojiCode);
UpdateEmojiPickerContent();
}
};
return emojiButton;
}
private void AddToRecentEmojis(string emojiCode)
{
_recentEmojis.Remove(emojiCode);
_recentEmojis.Add(emojiCode);
if (_recentEmojis.Count > 5)
{
_recentEmojis.RemoveAt(0);
}
SaveRecentEmojis();
}
private void ToggleFavoriteEmoji(string emojiCode)
{
if (!_favoriteEmojis.Add(emojiCode))
{
_favoriteEmojis.Remove(emojiCode);
}
SaveFavoriteEmojis();
}
private void LoadSavedEmojis()
{
HashSet<string>? allEmojis = null;
try
{
allEmojis = EmojiSystem.GetAllEmojis().Select(e => e.Code).ToHashSet();
}
catch { }
try
{
var recentEmojisStr = _configurationManager.GetCVar(SunriseCCVars.MessengerRecentEmojis);
if (!string.IsNullOrWhiteSpace(recentEmojisStr))
{
var emojiCodes = recentEmojisStr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
_recentEmojis.Clear();
foreach (var code in emojiCodes)
{
if (string.IsNullOrWhiteSpace(code) || (allEmojis != null && !allEmojis.Contains(code)))
continue;
_recentEmojis.Add(code);
if (_recentEmojis.Count >= 5) break;
}
}
}
catch { _recentEmojis.Clear(); }
try
{
var favoriteEmojisStr = _configurationManager.GetCVar(SunriseCCVars.MessengerFavoriteEmojis);
if (!string.IsNullOrWhiteSpace(favoriteEmojisStr))
{
var emojiCodes = favoriteEmojisStr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
_favoriteEmojis.Clear();
foreach (var code in emojiCodes)
{
if (string.IsNullOrWhiteSpace(code) || (allEmojis != null && !allEmojis.Contains(code)))
continue;
_favoriteEmojis.Add(code);
}
}
}
catch { _favoriteEmojis.Clear(); }
}
private void SaveRecentEmojis()
{
try
{
var emojiCodesStr = string.Join(",", _recentEmojis);
_configurationManager.SetCVar(SunriseCCVars.MessengerRecentEmojis, emojiCodesStr);
_configurationManager.SaveToFile();
}
catch { }
}
private void SaveFavoriteEmojis()
{
try
{
var emojiCodesStr = string.Join(",", _favoriteEmojis);
_configurationManager.SetCVar(SunriseCCVars.MessengerFavoriteEmojis, emojiCodesStr);
_configurationManager.SaveToFile();
}
catch { }
}
}

View file

@ -0,0 +1,34 @@
using System.Numerics;
using Robust.Client.Graphics;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
namespace Content.Client._Sunrise.UserInterface.CustomControls;
public sealed class PhotoPreviewWindow : DefaultWindow
{
public PhotoPreviewWindow(Texture texture)
{
Title = Loc.GetString("messenger-image-preview-title");
MinSize = new Vector2(600, 600);
var textureRect = new TextureRect
{
Texture = texture,
Stretch = TextureRect.StretchMode.KeepAspect,
HorizontalExpand = true,
VerticalExpand = true
};
Contents.AddChild(textureRect);
}
public static void Open(Texture? texture)
{
if (texture == null)
return;
var window = new PhotoPreviewWindow(texture);
window.OpenCentered();
}
}

View file

@ -176,7 +176,13 @@ public sealed partial class AnomalySystem
Audio.PlayPvs(component.GeneratingFinishedSound, uid);
var message = Loc.GetString("anomaly-generator-announcement");
_radio.SendRadioMessage(uid, message, _prototype.Index<RadioChannelPrototype>(component.ScienceChannel), uid);
// _radio.SendRadioMessage(uid, message, _prototype.Index<RadioChannelPrototype>(component.ScienceChannel), uid);
if (_messenger.GetServerEntity(_station.GetOwningStation(uid)) is var (server, _) &&
_messenger.GetGroupIdByRadioChannel(component.ScienceChannel) is { } groupId)
{
_messenger.SendSystemMessageToGroup(server, groupId, message);
}
}
private void UpdateGenerator()

View file

@ -6,6 +6,7 @@ using Content.Server.Materials;
using Content.Server.Radiation.Systems;
using Content.Server.Radio.EntitySystems;
using Content.Server.Station.Systems;
using Content.Server._Sunrise.Messenger;
using Content.Shared.Anomaly;
using Content.Shared.Anomaly.Components;
using Content.Shared.Anomaly.Prototypes;
@ -39,6 +40,7 @@ public sealed partial class AnomalySystem : SharedAnomalySystem
[Dependency] private readonly RadiationSystem _radiation = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly UserInterfaceSystem _ui = default!;
[Dependency] private readonly MessengerServerSystem _messenger = default!;
public const float MinParticleVariation = 0.8f;
public const float MaxParticleVariation = 1.2f;

View file

@ -242,9 +242,17 @@ namespace Content.Server.Cargo.Systems
("orderAmount", order.OrderQuantity),
("approver", order.Approver ?? string.Empty),
("cost", cost));
_radio.SendRadioMessage(uid, message, account.RadioChannel, uid, escapeMarkup: false);
// Sunrise-Start
/*_radio.SendRadioMessage(uid, message, account.RadioChannel, uid, escapeMarkup: false);
if (CargoOrderConsoleComponent.BaseAnnouncementChannel != account.RadioChannel)
_radio.SendRadioMessage(uid, message, CargoOrderConsoleComponent.BaseAnnouncementChannel, uid, escapeMarkup: false);
_radio.SendRadioMessage(uid, message, CargoOrderConsoleComponent.BaseAnnouncementChannel, uid, escapeMarkup: false);*/
if (_messenger.GetServerEntity(_station.GetOwningStation(uid)) is var (server, _) &&
_messenger.GetGroupIdByRadioChannel(CargoOrderConsoleComponent.BaseAnnouncementChannel) is { } groupId)
{
_messenger.SendSystemMessageToGroup(server, groupId, message);
}
// Sunrise-End
}
ConsolePopup(args.Actor, Loc.GetString("cargo-console-trade-station", ("destination", MetaData(ev.FulfillmentEntity.Value).EntityName)));

View file

@ -4,6 +4,7 @@ using Content.Server.Popups;
using Content.Server.Stack;
using Content.Server.Station.Systems;
using Content.Shared.Access.Systems;
using Content.Server._Sunrise.Messenger;
using Content.Shared.Administration.Logs;
using Content.Server.Radio.EntitySystems;
using Content.Shared.Cargo;
@ -38,6 +39,7 @@ public sealed partial class CargoSystem : SharedCargoSystem
[Dependency] private readonly UserInterfaceSystem _uiSystem = default!;
[Dependency] private readonly MetaDataSystem _metaSystem = default!;
[Dependency] private readonly RadioSystem _radio = default!;
[Dependency] private readonly MessengerServerSystem _messenger = default!;
private EntityQuery<TransformComponent> _xformQuery;
private EntityQuery<CargoSellBlacklistComponent> _blacklistQuery;

View file

@ -1,6 +1,7 @@
using Content.Server.Popups;
using Content.Server.Radio.EntitySystems;
using Content.Server.Station.Systems;
using Content.Server._Sunrise.Messenger;
using Content.Server.StationRecords;
using Content.Server.StationRecords.Systems;
using Content.Shared.Access.Systems;
@ -30,6 +31,7 @@ public sealed class CriminalRecordsConsoleSystem : SharedCriminalRecordsConsoleS
[Dependency] private readonly StationRecordsSystem _records = default!;
[Dependency] private readonly StationSystem _station = default!;
[Dependency] private readonly UserInterfaceSystem _ui = default!;
[Dependency] private readonly MessengerServerSystem _messenger = default!;
public override void Initialize()
{
@ -170,8 +172,17 @@ public sealed class CriminalRecordsConsoleSystem : SharedCriminalRecordsConsoleS
// this is impossible
_ => "not-wanted"
};
_radio.SendRadioMessage(ent, Loc.GetString($"criminal-records-console-{statusString}", args),
ent.Comp.SecurityChannel, ent);
// Sunrise-Start
var locMsg = Loc.GetString($"criminal-records-console-{statusString}", args);
// _radio.SendRadioMessage(ent, locMsg,
// ent.Comp.SecurityChannel, ent);
if (_messenger.GetServerEntity(_station.GetOwningStation(ent)) is var (server, _) &&
_messenger.GetGroupIdByRadioChannel(ent.Comp.SecurityChannel) is { } groupId)
{
_messenger.SendSystemMessageToGroup(server, groupId, locMsg);
}
// Sunrise-End
UpdateUserInterface(ent);
}

View file

@ -34,4 +34,10 @@ public sealed partial class NewsWriterComponent : Component
/// </summary>
[DataField, ViewVariables]
public string DraftContent = "";
/// <summary>
/// This stores the working photo paths of the current article
/// </summary>
[DataField, ViewVariables]
public List<string>? DraftPhotoPaths;
}

View file

@ -28,6 +28,7 @@ using Robust.Shared.Utility;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading.Tasks;
using Content.Shared._Sunrise.CartridgeLoader.Cartridges;
namespace Content.Server.MassMedia.Systems;
@ -46,6 +47,7 @@ public sealed class NewsSystem : SharedNewsSystem
[Dependency] private readonly DiscordWebhook _discord = default!;
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] private readonly IBaseServer _baseServer = default!;
[Dependency] private readonly SharedTransformSystem _transform = default!;
private WebhookIdentifier? _webhookId = null;
private Color _webhookEmbedColor;
@ -84,6 +86,7 @@ public sealed class NewsSystem : SharedNewsSystem
subs.Event<NewsWriterPublishMessage>(OnWriteUiPublishMessage);
subs.Event<NewsWriterSaveDraftMessage>(OnNewsWriterDraftUpdatedMessage);
subs.Event<NewsWriterRequestDraftMessage>(OnRequestArticleDraftMessage);
subs.Event<NewsWriterRequestPhotosMessage>(OnRequestPhotosMessage);
});
// News reader
@ -165,7 +168,11 @@ public sealed class NewsSystem : SharedNewsSystem
return;
if (!CanUse(msg.Actor, ent.Owner))
{
_popup.PopupEntity(Loc.GetString("news-write-no-access-popup"), ent, msg.Actor, PopupType.SmallCaution);
_audio.PlayPvs(ent.Comp.NoAccessSound, ent);
return;
}
ent.Comp.PublishEnabled = false;
ent.Comp.NextPublish = _timing.CurTime + TimeSpan.FromSeconds(ent.Comp.PublishCooldown);
@ -176,8 +183,9 @@ public sealed class NewsSystem : SharedNewsSystem
var title = msg.Title.Trim();
var content = msg.Content.Trim();
var photoPaths = msg.PhotoPaths;
if (TryAddNews(ent, title, content, out var article, authorName, msg.Actor))
if (TryAddNews(ent, title, content, out var article, authorName, msg.Actor, photoPaths))
{
_audio.PlayPvs(ent.Comp.ConfirmSound, ent);
@ -197,7 +205,7 @@ public sealed class NewsSystem : SharedNewsSystem
/// <param name="content">Content of the news article.</param>
/// <param name="author">Author of the news article.</param>
/// <param name="actor">Entity which caused the news article to publish. Used for admin logs.</param>
public bool TryAddNews(EntityUid uid, string title, string content, [NotNullWhen(true)] out NewsArticle? article, string? author = null, EntityUid? actor = null)
public bool TryAddNews(EntityUid uid, string title, string content, [NotNullWhen(true)] out NewsArticle? article, string? author = null, EntityUid? actor = null, List<string>? photoPaths = null)
{
if (!TryGetArticles(uid, out var articles))
{
@ -210,7 +218,8 @@ public sealed class NewsSystem : SharedNewsSystem
Title = title.Length <= MaxTitleLength ? title : $"{title[..MaxTitleLength]}...",
Content = content.Length <= MaxContentLength ? content : $"{content[..MaxContentLength]}...",
Author = author,
ShareTime = _ticker.RoundDuration()
ShareTime = _ticker.RoundDuration(),
PhotoPaths = photoPaths?.Take(10).ToList()
};
articles.Add(article.Value);
@ -327,7 +336,7 @@ public sealed class NewsSystem : SharedNewsSystem
if (!TryGetArticles(ent, out var articles))
return;
var state = new NewsWriterBoundUserInterfaceState(articles.ToArray(), ent.Comp.PublishEnabled, ent.Comp.NextPublish, ent.Comp.DraftTitle, ent.Comp.DraftContent);
var state = new NewsWriterBoundUserInterfaceState(articles.ToArray(), ent.Comp.PublishEnabled, ent.Comp.NextPublish, ent.Comp.DraftTitle, ent.Comp.DraftContent, ent.Comp.DraftPhotoPaths);
_ui.SetUiState(ent.Owner, NewsWriterUiKey.Key, state);
}
@ -390,6 +399,7 @@ public sealed class NewsSystem : SharedNewsSystem
{
ent.Comp.DraftTitle = args.DraftTitle;
ent.Comp.DraftContent = args.DraftContent;
ent.Comp.DraftPhotoPaths = args.DraftPhotoPaths;
}
private void OnRequestArticleDraftMessage(Entity<NewsWriterComponent> ent, ref NewsWriterRequestDraftMessage msg)
@ -397,6 +407,25 @@ public sealed class NewsSystem : SharedNewsSystem
UpdateWriterUi(ent);
}
private void OnRequestPhotosMessage(Entity<NewsWriterComponent> ent, ref NewsWriterRequestPhotosMessage msg)
{
var photos = new List<PhotoMetadata>();
// Find all PDAs or cartridges with photos on the actor
var query = EntityQueryEnumerator<PhotoCartridgeComponent>();
while (query.MoveNext(out var uid, out var photoComp))
{
// Check if this entity is child of the actor (in inventory, hands, etc.)
if (_transform.ContainsEntity(msg.Actor, uid))
{
photos.AddRange(photoComp.PhotoGallery.Values);
}
}
var photosMsg = new NewsWriterPhotosMessage(photos);
_ui.ServerSendUiMessage(ent.Owner, NewsWriterUiKey.Key, photosMsg, msg.Actor);
}
#region Discord Hook
private void OnRoundEndMessageEvent(RoundEndMessageEvent ev)

View file

@ -94,16 +94,36 @@ public sealed partial class ResearchSystem
("amount", technologyPrototype.Cost),
("approver", getIdentityEvent.Title ?? string.Empty)
);
_radio.SendRadioMessage(uid, message, component.AnnouncementChannel, uid, escapeMarkup: false);
// _radio.SendRadioMessage(uid, message, component.AnnouncementChannel, uid, escapeMarkup: false);
if (technologyPrototype.RadioChannels.Any())
// Sunrise-Start
if (_messenger.GetServerEntity(_stationSystem.GetOwningStation(uid)) is var (server, _))
{
var mainGroupId = _messenger.GetGroupIdByRadioChannel(component.AnnouncementChannel);
if (mainGroupId != null)
_messenger.SendSystemMessageToGroup(server, mainGroupId, message);
if (technologyPrototype.RadioChannels.Any())
{
foreach (var radioChannelId in technologyPrototype.RadioChannels)
{
var groupId = _messenger.GetGroupIdByRadioChannel(radioChannelId);
if (groupId != null)
_messenger.SendSystemMessageToGroup(server, groupId, message);
}
}
}
/*if (technologyPrototype.RadioChannels.Any())
foreach (var radioChannelId in technologyPrototype.RadioChannels)
{
if (PrototypeManager.TryIndex(radioChannelId, out var radioChannel))
{
_radio.SendRadioMessage(uid, message, radioChannel, uid, escapeMarkup: false);
}
}
}*/
// Sunrise-End
}

View file

@ -2,6 +2,8 @@ using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Content.Server.Administration.Logs;
using Content.Server.Radio.EntitySystems;
using Content.Server.Station.Systems;
using Content.Server._Sunrise.Messenger;
using Content.Shared.Access.Systems;
using Content.Shared.Popups;
using Content.Shared.Research.Components;
@ -22,6 +24,8 @@ namespace Content.Server.Research.Systems
[Dependency] private readonly UserInterfaceSystem _uiSystem = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
[Dependency] private readonly RadioSystem _radio = default!;
[Dependency] private readonly MessengerServerSystem _messenger = default!;
[Dependency] private readonly StationSystem _stationSystem = default!;
public override void Initialize()
{

View file

@ -1,4 +1,5 @@
using Content.Server.Radio.EntitySystems;
using Content.Server._Sunrise.Messenger;
using Content.Shared.Radio;
using Content.Shared.Salvage;
using Robust.Server.GameObjects;
@ -45,6 +46,7 @@ namespace Content.Server.Salvage
[Dependency] private readonly ShuttleConsoleSystem _shuttleConsoles = default!;
[Dependency] private readonly StationSystem _station = default!;
[Dependency] private readonly UserInterfaceSystem _ui = default!;
[Dependency] private readonly MessengerServerSystem _messenger = default!;
private EntityQuery<MapGridComponent> _gridQuery;
private EntityQuery<TransformComponent> _xformQuery;
@ -64,8 +66,14 @@ namespace Content.Server.Salvage
private void Report(EntityUid source, string channelName, string messageKey, params (string, object)[] args)
{
var message = args.Length == 0 ? Loc.GetString(messageKey) : Loc.GetString(messageKey, args);
var channel = _prototypeManager.Index<RadioChannelPrototype>(channelName);
_radioSystem.SendRadioMessage(source, message, channel, source);
// var channel = _prototypeManager.Index<RadioChannelPrototype>(channelName);
// _radioSystem.SendRadioMessage(source, message, channel, source);
if (_messenger.GetServerEntity(_station.GetOwningStation(source)) is var (server, _) &&
_messenger.GetGroupIdByRadioChannel(channelName) is { } groupId)
{
_messenger.SendSystemMessageToGroup(server, groupId, message);
}
}
public override void Update(float frameTime)

View file

@ -179,6 +179,11 @@ public sealed partial class ShuttleConsoleSystem : SharedShuttleConsoleSystem
return false;
}
// Sunrise-Start
if (TryComp<DroneConsoleComponent>(uid, out var droneConsole) && droneConsole.Entity == null)
droneConsole.Entity = GetShuttleConsole(uid, droneConsole);
// Sunrise-End
var pilotComponent = EnsureComp<PilotComponent>(user);
var console = pilotComponent.Console;

View file

@ -1,6 +1,8 @@
using Content.Server.Inventory;
using Content.Server.Radio.EntitySystems;
using Content.Server.Station.Systems;
using Content.Server.StationRecords.Systems;
using Content.Server._Sunrise.Messenger;
using Content.Shared.Inventory;
using Content.Shared.Radio;
using Content.Shared.Roles;
@ -22,6 +24,8 @@ public sealed class BorgSwitchableTypeSystem : SharedBorgSwitchableTypeSystem
[Dependency] private readonly ServerInventorySystem _inventorySystem = default!;
[Dependency] private readonly RadioSystem _radioSystem = default!;
[Dependency] private readonly StationRecordsSystem _record = default!;
[Dependency] private readonly MessengerServerSystem _messenger = default!;
[Dependency] private readonly StationSystem _station = default!;
protected override void SelectBorgModule(Entity<BorgSwitchableTypeComponent> ent, ProtoId<BorgTypePrototype> borgType)
{
@ -129,8 +133,14 @@ public sealed class BorgSwitchableTypeSystem : SharedBorgSwitchableTypeSystem
private void Report(EntityUid source, string channelName, string message)
{
var channel = Prototypes.Index<RadioChannelPrototype>(channelName);
_radioSystem.SendRadioMessage(source, message, channel, source);
// var channel = Prototypes.Index<RadioChannelPrototype>(channelName);
// _radioSystem.SendRadioMessage(source, message, channel, source);
if (_messenger.GetServerEntity(_station.GetOwningStation(source)) is var (server, _) &&
_messenger.GetGroupIdByRadioChannel(channelName) is { } groupId)
{
_messenger.SendSystemMessageToGroup(server, groupId, message);
}
}
// Sunrise-End
}

View file

@ -1,5 +1,6 @@
using System.Linq;
using Content.Server.Station.Systems;
using Content.Server._Sunrise.Messenger;
using Content.Server.StationRecords.Components;
using Content.Shared.StationRecords;
using Robust.Server.GameObjects;
@ -11,6 +12,7 @@ public sealed partial class GeneralStationRecordConsoleSystem : EntitySystem
[Dependency] private readonly UserInterfaceSystem _ui = default!;
[Dependency] private readonly StationSystem _station = default!;
[Dependency] private readonly StationRecordsSystem _stationRecords = default!;
[Dependency] private readonly MessengerServerSystem _messenger = default!;
public override void Initialize()
{

View file

@ -1,6 +1,9 @@
using Content.Server.Radio.EntitySystems;
using Content.Server.Pinpointer;
using Content.Server.Station.Systems;
using Content.Server._Sunrise.Messenger;
using Content.Shared.Mobs.Components;
using Content.Shared.Radio;
using Content.Shared.Trigger;
using Content.Shared.Trigger.Components.Effects;
using Robust.Shared.Prototypes;
@ -13,6 +16,8 @@ public sealed class RattleOnTriggerSystem : EntitySystem
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly RadioSystem _radio = default!;
[Dependency] private readonly NavMapSystem _navMap = default!;
[Dependency] private readonly MessengerServerSystem _messenger = default!;
[Dependency] private readonly StationSystem _station = default!;
public override void Initialize()
{
@ -43,7 +48,18 @@ public sealed class RattleOnTriggerSystem : EntitySystem
var posText = FormattedMessage.RemoveMarkupOrThrow(_navMap.GetNearestBeaconString(target.Value));
var message = Loc.GetString(messageId, ("user", target.Value), ("position", posText));
// Sends a message to the radio channel specified by the implant
_radio.SendRadioMessage(ent.Owner, message, _prototypeManager.Index(ent.Comp.RadioChannel), ent.Owner);
// Sunrise-Start
var sentToMessenger = false;
if (_messenger.GetServerEntity(_station.GetOwningStation(ent.Owner)) is var (server, _) &&
_messenger.GetGroupIdByRadioChannel(ent.Comp.RadioChannel) is { } groupId)
{
_messenger.SendSystemMessageToGroup(server, groupId, message);
sentToMessenger = true;
}
if (!sentToMessenger)
_radio.SendRadioMessage(ent.Owner, message, _prototypeManager.Index(ent.Comp.RadioChannel), ent.Owner);
// Sunrise-End
}
}

View file

@ -3,6 +3,8 @@ using Content.Server.Atmos.EntitySystems;
using Content.Server.Chat.Managers;
using Content.Server.Lightning;
using Content.Server.Radio.EntitySystems;
using Content.Server.Station.Systems;
using Content.Server._Sunrise.Messenger;
using Content.Shared.Abilities.Goliath;
using Content.Shared.Atmos;
using Content.Shared.Damage;
@ -35,6 +37,8 @@ public sealed class SupermatterSystem : AccUpdateEntitySystem
[Dependency] private readonly IChatManager _chat = default!;
[Dependency] private readonly IPrototypeManager _prototypes = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly MessengerServerSystem _messenger = default!;
[Dependency] private readonly StationSystem _station = default!;
private readonly Dictionary<EntityUid, Entity<SupermatterComponent>> _supermatters = [];
private DamageGroupPrototype? _brute;
@ -125,10 +129,24 @@ public sealed class SupermatterSystem : AccUpdateEntitySystem
supermatter.Comp.LastSendedDurability = supermatter.Comp.Durability;
if (currentDurability > lastDurability)
_radioSystem.SendRadioMessage(supermatter.Owner, $"The crystal is regenerating. Durability: {currentDurability}%", _engi, supermatter.Owner);
{
// _radioSystem.SendRadioMessage(supermatter.Owner, $"The crystal is regenerating. Durability: {currentDurability}%", _engi, supermatter.Owner);
if (_messenger.GetServerEntity(_station.GetOwningStation(supermatter.Owner)) is var (server, _) &&
_messenger.GetGroupIdByRadioChannel(_engi.ID) is { } groupId)
{
_messenger.SendSystemMessageToGroup(server, groupId, $"The crystal is regenerating. Durability: {currentDurability}%");
}
}
else switch (currentDurability)
{
case > 75: _radioSystem.SendRadioMessage(supermatter.Owner, $"Attention! The crystal is destabilizing. Durability: {currentDurability}%", _engi, supermatter.Owner); break;
case > 75:
// _radioSystem.SendRadioMessage(supermatter.Owner, $"Attention! The crystal is destabilizing. Durability: {currentDurability}%", _engi, supermatter.Owner);
if (_messenger.GetServerEntity(_station.GetOwningStation(supermatter.Owner)) is var (server, _) &&
_messenger.GetGroupIdByRadioChannel(_engi.ID) is { } groupId)
{
_messenger.SendSystemMessageToGroup(server, groupId, $"Attention! The crystal is destabilizing. Durability: {currentDurability}%");
}
break;
case > 50: _chat.DispatchServerAnnouncement($"Attention! The crystal is destabilizing. Durability: {currentDurability}%", Color.Yellow); break;
case > 25: _chat.DispatchServerAnnouncement($"Critical state of the crystal! Durability: {currentDurability}%", Color.OrangeRed); break;
default: _chat.DispatchServerAnnouncement($"Crystal destruction is inevitable. Current durability: {currentDurability}%", Color.Red); break;

View file

@ -3,6 +3,7 @@ using Content.Server.CriminalRecords.Systems;
using Content.Server.Radio.EntitySystems;
using Content.Server.Station.Systems;
using Content.Server.StationRecords.Systems;
using Content.Server._Sunrise.Messenger;
using Content.Shared._Sunrise.AddWantedStatus;
using Content.Shared.Actions;
using Content.Shared.Clothing.Components;
@ -20,6 +21,7 @@ public sealed partial class AddWantedStatusSystem : EntitySystem
[Dependency] private readonly StationRecordsSystem _records = default!;
[Dependency] private readonly StationSystem _station = default!;
[Dependency] private readonly RadioSystem _radio = default!;
[Dependency] private readonly MessengerServerSystem _messenger = default!;
public override void Initialize()
{
@ -96,6 +98,14 @@ public sealed partial class AddWantedStatusSystem : EntitySystem
reason = Loc.GetString("wanted-list-unknown-reason-label");
var message = Loc.GetString("criminal-records-console-wanted", [("name", wantedName), ("officer", officer), ("reason", reason), ("job", wantedJobTitle)]);
_radio.SendRadioMessage(sender, message, "Security", sender);
// _radio.SendRadioMessage(sender, message, "Security", sender);
// Sunrise-Start
if (_messenger.GetServerEntity(_station.GetOwningStation(sender)) is var (server, _) &&
_messenger.GetGroupIdByRadioChannel("Security") is { } groupId)
{
_messenger.SendSystemMessageToGroup(server, groupId, message);
}
// Sunrise-End
}
}

View file

@ -118,17 +118,33 @@ public sealed partial class MessengerCartridgeSystem
userData.TryGetValue("job_title", out object? jobTitleObj);
userData.TryGetValue("department_id", out object? departmentIdObj);
userData.TryGetValue("department_ids", out object? departmentIdsObj);
userData.TryGetValue("job_icon_id", out object? jobIconIdObj);
var jobTitle = jobTitleObj?.ToString();
var departmentId = departmentIdObj?.ToString();
var departmentIds = new List<string>();
if (departmentIdsObj is List<object> deptList)
{
departmentIds.AddRange(deptList.Select(d => d.ToString() ?? string.Empty).Where(s => !string.IsNullOrWhiteSpace(s)));
}
else if (departmentIdsObj is List<string> deptStringList)
{
departmentIds.AddRange(deptStringList.Where(s => !string.IsNullOrWhiteSpace(s)));
}
if (departmentIds.Count == 0 && departmentId != null && !string.IsNullOrWhiteSpace(departmentId))
{
departmentIds.Add(departmentId);
}
ProtoId<JobIconPrototype>? jobIconId = null;
if (jobIconIdObj != null && !string.IsNullOrEmpty(jobIconIdObj.ToString()))
if (jobIconIdObj != null && !string.Empty.Equals(jobIconIdObj.ToString()))
{
jobIconId = new ProtoId<JobIconPrototype>(jobIconIdObj.ToString()!);
}
users.Add(new MessengerUser(userId, userName, jobTitle, departmentId, jobIconId));
users.Add(new MessengerUser(userId, userName, jobTitle, departmentIds, jobIconId));
}
component.Users = users;
@ -202,19 +218,20 @@ public sealed partial class MessengerCartridgeSystem
groups.Add(new MessengerGroup(groupId, groupName, new HashSet<string>(membersList ?? new List<string>()), groupType, autoGroupPrototypeId, ownerId));
}
component.Groups = groups;
var existingGroupIds = component.Groups.Select(g => g.GroupId).ToHashSet();
foreach (var group in groups)
{
if (group.Type == MessengerGroupType.Automatic && group.AutoGroupPrototypeId != null)
{
if (!component.MutedGroupChats.Contains(group.GroupId))
if (!existingGroupIds.Contains(group.GroupId) && !component.MutedGroupChats.Contains(group.GroupId))
{
component.MutedGroupChats.Add(group.GroupId);
}
}
}
component.Groups = groups;
if (serverUnreadCounts != null)
{
foreach (var (chatId, count) in serverUnreadCounts)
@ -538,9 +555,23 @@ public sealed partial class MessengerCartridgeSystem
component.ServerUnreadCounts[chatId] = currentCount + 1;
}
if (!isMuted && !isSender && TryComp<RingerComponent>(loaderUid, out var ringer))
if (!isMuted && !isSender)
{
_ringer.RingerPlayRingtone(loaderUid);
if (TryComp<RingerComponent>(loaderUid, out var ringer))
_ringer.RingerPlayRingtone(loaderUid);
string notificationMessage;
if (isGroupChat)
{
var groupName = component.Groups.FirstOrDefault(g => g.GroupId == chatId)?.Name ?? chatId ?? string.Empty;
notificationMessage = Loc.GetString("messenger-group-notification-message", ("name", groupName));
}
else
{
notificationMessage = Loc.GetString("messenger-notification-message", ("name", senderName ?? string.Empty));
}
_cartridgeLoader.SendNotification(loaderUid, Loc.GetString("messenger-program-name"), notificationMessage);
}
UpdateUiState(uid, loaderUid, component);
@ -581,11 +612,7 @@ public sealed partial class MessengerCartridgeSystem
if (isNewInvite)
{
component.ActiveInvites.Add(invite);
if (TryComp<RingerComponent>(loaderUid, out var ringer))
{
_ringer.RingerPlayRingtone(loaderUid);
}
_cartridgeLoader.SendNotification(loaderUid, Loc.GetString("messenger-program-name"), Loc.GetString("messenger-invite-notification-message", ("name", groupName ?? string.Empty)));
}
UpdateUiState(uid, loaderUid, component);
@ -644,9 +671,10 @@ public sealed partial class MessengerCartridgeSystem
if (addedUserId == component.UserId && !string.IsNullOrEmpty(groupId))
{
if (TryComp<RingerComponent>(loaderUid, out var ringer))
var group = component.Groups.FirstOrDefault(g => g.GroupId == groupId);
if (group != null)
{
_ringer.RingerPlayRingtone(loaderUid);
_cartridgeLoader.SendNotification(loaderUid, Loc.GetString("messenger-program-name"), Loc.GetString("messenger-invite-notification-message", ("name", group.Name)));
}
}

View file

@ -1,20 +1,13 @@
using System.Linq;
using Content.Server.CartridgeLoader;
using Content.Server.DeviceNetwork.Systems;
using Content.Server._Sunrise;
using Content.Server._Sunrise.Messenger;
using Content.Server.Station.Systems;
using Content.Shared.CartridgeLoader;
using Content.Shared.DeviceNetwork;
using Content.Shared.DeviceNetwork.Components;
using Content.Shared.Inventory;
using Content.Shared.PDA;
using Content.Shared._Sunrise.CartridgeLoader.Cartridges;
using Content.Shared._Sunrise.Messenger;
using Robust.Server.Player;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Random;
using Robust.Shared.Timing;
using Robust.Shared.Audio.Systems;
@ -24,16 +17,12 @@ public sealed class PhotoCartridgeSystem : EntitySystem
{
[Dependency] private readonly CartridgeLoaderSystem _cartridgeLoader = default!;
[Dependency] private readonly NetTexturesManager _netTexturesManager = default!;
[Dependency] private readonly DeviceNetworkSystem _deviceNetwork = default!;
[Dependency] private readonly IGameTiming _gameTiming = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly ILogManager _logManager = default!;
[Dependency] private readonly MessengerServerSystem _messengerServer = default!;
[Dependency] private readonly StationSystem _stationSystem = default!;
[Dependency] private readonly SingletonDeviceNetServerSystem _singletonServer = default!;
[Dependency] private readonly IServerNetManager _netManager = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly InventorySystem _inventory = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly SharedTransformSystem _transform = default!;
@ -56,7 +45,7 @@ public sealed class PhotoCartridgeSystem : EntitySystem
SubscribeLocalEvent<PhotoCartridgeComponent, CartridgeMessageEvent>(OnUiMessage);
SubscribeLocalEvent<PhotoCartridgeComponent, CartridgeUiReadyEvent>(OnUiReady);
_netManager.RegisterNetMessage<PdaPhotoCaptureMessage>(OnPhotoCaptureMessage, accept: NetMessageAccept.Server);
_netTexturesManager.OnPhotoCaptureMessage = OnPhotoCaptureMessage;
}
private void OnUiMessage(EntityUid uid, PhotoCartridgeComponent component, CartridgeMessageEvent args)
@ -155,22 +144,30 @@ public sealed class PhotoCartridgeSystem : EntitySystem
return;
}
var pdaUid = FindPlayerPda(session);
if (pdaUid == null)
var entity = GetEntity(msg.LoaderUid);
PhotoCartridgeComponent? photoComponent;
EntityUid? cartridgeUid;
EntityUid pdaUid = EntityUid.Invalid;
if (_cartridgeLoader.TryGetProgram(entity, out cartridgeUid, out photoComponent))
{
_sawmill.Warning($"Photo capture rejected: no PDA found for player {session.Name}");
return;
pdaUid = entity;
}
else if (TryComp(entity, out photoComponent) && TryComp<CartridgeComponent>(entity, out var cartridge) && cartridge.LoaderUid.HasValue)
{
pdaUid = cartridge.LoaderUid.Value;
cartridgeUid = entity;
}
if (!_cartridgeLoader.TryGetProgram<PhotoCartridgeComponent>(pdaUid.Value, out var cartridgeUid, out var photoComponent))
if (photoComponent == null || cartridgeUid == null)
{
_sawmill.Warning($"Photo capture rejected: photo cartridge not found in PDA {ToPrettyString(pdaUid.Value)}");
_sawmill.Warning($"Photo capture rejected: photo cartridge not found for entity {ToPrettyString(entity)}");
return;
}
if (photoComponent.PhotoGallery.Count >= MaxPhotosPerUser)
{
UpdateUiState(cartridgeUid.Value, pdaUid.Value, photoComponent, errorMessage: Loc.GetString("photo-cartridge-limit-reached"));
UpdateUiState(cartridgeUid!.Value, pdaUid, photoComponent, errorMessage: Loc.GetString("photo-cartridge-limit-reached"));
return;
}
@ -187,41 +184,7 @@ public sealed class PhotoCartridgeSystem : EntitySystem
_sawmill.Info($"Photo captured from {session.Name}: {photoId}, path: {imagePath}, size: {msg.Width}x{msg.Height}, {msg.ImageData.Length} bytes");
UpdateUiState(cartridgeUid.Value, pdaUid.Value, photoComponent);
}
/// <summary>
/// Находит КПК игрока по его сессии
/// </summary>
private EntityUid? FindPlayerPda(ICommonSession session)
{
if (session.AttachedEntity == null)
return null;
var playerEntity = session.AttachedEntity.Value;
if (_inventory.TryGetSlotEntity(playerEntity, "idcard", out var idCardEntity) &&
TryComp<PdaComponent>(idCardEntity, out _))
{
return idCardEntity;
}
if (_inventory.TryGetSlotEntity(playerEntity, "belt", out var beltEntity) &&
TryComp<PdaComponent>(beltEntity, out _))
{
return beltEntity;
}
var pdaQuery = EntityQueryEnumerator<PdaComponent>();
while (pdaQuery.MoveNext(out var uid, out var pda))
{
if (pda.PdaOwner == playerEntity)
{
return uid;
}
}
return null;
UpdateUiState(cartridgeUid!.Value, pdaUid, photoComponent);
}
private void HandleSendPhotoToMessenger(EntityUid uid, PhotoCartridgeComponent component, EntityUid loaderUid, string? photoId, string? recipientId, string? groupId)

View file

@ -246,6 +246,81 @@ public sealed partial class MessengerServerSystem
SendGroupMessage(uid, component, sender, groupId, content, timestamp, imagePath);
}
/// <summary>
/// Отправляет системное сообщение в группу (используется для оповещений департаментов)
/// </summary>
public void SendSystemMessageToGroup(EntityUid uid, string groupId, string content)
{
if (!TryComp<MessengerServerComponent>(uid, out var component))
return;
if (!component.Groups.TryGetValue(groupId, out var group))
return;
var timestamp = GetStationTime();
var messageId = GetNextMessageId(uid, component);
var senderName = Loc.GetString("messenger-system-name");
var message = new MessengerMessage("system", senderName, content, timestamp, groupId, null, false, messageId);
if (!component.MessageHistory.TryGetValue(groupId, out var history))
{
history = new List<MessengerMessage>();
component.MessageHistory[groupId] = history;
}
history.Add(message);
TrimMessageHistory(history, component.MaxMessageHistory);
if (!TryComp<DeviceNetworkComponent>(uid, out var serverDevice))
return;
uint? pdaFrequency = null;
if (_prototypeManager.TryIndex(component.PdaFrequencyId, out var pdaFreq))
{
pdaFrequency = pdaFreq.Frequency;
}
var payload = new NetworkPayload
{
[DeviceNetworkConstants.Command] = MessengerCommands.CmdMessageReceived,
["sender_id"] = message.SenderId,
["sender_name"] = message.SenderName,
["content"] = message.Content,
["timestamp"] = message.Timestamp.TotalSeconds,
["group_id"] = message.GroupId ?? string.Empty,
["recipient_id"] = message.RecipientId ?? string.Empty,
["is_read"] = message.IsRead,
["message_id"] = message.MessageId,
["sender_job_icon_id"] = string.Empty,
["image_path"] = string.Empty
};
foreach (var memberId in group.Members)
{
var isMemberChatOpen = component.OpenChats.TryGetValue(memberId, out var memberOpenChatId) && memberOpenChatId == groupId;
if (!isMemberChatOpen)
{
if (!component.UnreadCounts.TryGetValue(memberId, out var memberUnreads))
{
memberUnreads = new Dictionary<string, int>();
component.UnreadCounts[memberId] = memberUnreads;
}
memberUnreads.TryGetValue(groupId, out var currentCount);
memberUnreads[groupId] = currentCount + 1;
}
if (pdaFrequency.HasValue)
{
_deviceNetwork.QueuePacket(uid, memberId, payload, frequency: pdaFrequency, network: serverDevice.DeviceNetId);
}
else
{
_deviceNetwork.QueuePacket(uid, memberId, payload);
}
}
}
/// <summary>
/// Обрабатывает удаление сообщения пользователем
/// </summary>
@ -326,4 +401,79 @@ public sealed partial class MessengerServerSystem
}
}
}
/// <summary>
/// Sends a fake message to a specific user (used for spam).
/// </summary>
public void SendFakePersonalMessage(EntityUid uid, string recipientId, string senderId, string senderName, string content)
{
if (!TryComp<MessengerServerComponent>(uid, out var component))
return;
if (!component.Users.ContainsKey(recipientId))
return;
var timestamp = GetStationTime();
var messageId = GetNextMessageId(uid, component);
var message = new MessengerMessage(senderId, senderName, content, timestamp, null, recipientId, isRead: false, messageId);
var chatId = GetPersonalChatId(senderId, recipientId);
if (!component.MessageHistory.TryGetValue(chatId, out var history))
{
history = new List<MessengerMessage>();
component.MessageHistory[chatId] = history;
}
history.Add(message);
TrimMessageHistory(history, component.MaxMessageHistory);
var isChatOpen = component.OpenChats.TryGetValue(recipientId, out var openChatId) && openChatId == chatId;
if (isChatOpen)
{
message.IsRead = true;
}
else
{
if (!component.UnreadCounts.TryGetValue(recipientId, out var recipientUnreads))
{
recipientUnreads = new Dictionary<string, int>();
component.UnreadCounts[recipientId] = recipientUnreads;
}
recipientUnreads.TryGetValue(chatId, out var currentCount);
recipientUnreads[chatId] = currentCount + 1;
}
if (!TryComp<DeviceNetworkComponent>(uid, out var serverDevice))
return;
uint? pdaFrequency = null;
if (_prototypeManager.TryIndex(component.PdaFrequencyId, out var pdaFreq))
{
pdaFrequency = pdaFreq.Frequency;
}
var payload = new NetworkPayload
{
[DeviceNetworkConstants.Command] = MessengerCommands.CmdMessageReceived,
["sender_id"] = message.SenderId,
["sender_name"] = message.SenderName,
["content"] = message.Content,
["timestamp"] = message.Timestamp.TotalSeconds,
["group_id"] = message.GroupId ?? string.Empty,
["recipient_id"] = message.RecipientId ?? string.Empty,
["is_read"] = message.IsRead,
["message_id"] = message.MessageId,
["sender_job_icon_id"] = string.Empty,
["image_path"] = string.Empty
};
if (pdaFrequency.HasValue)
{
_deviceNetwork.QueuePacket(uid, recipientId, payload, frequency: pdaFrequency, network: serverDevice.DeviceNetId);
}
else
{
_deviceNetwork.QueuePacket(uid, recipientId, payload);
}
}
}

View file

@ -0,0 +1,181 @@
using System.Linq;
using Content.Shared._Sunrise.Messenger;
using Content.Shared._Sunrise.SunriseCCVars;
using Content.Shared.Dataset;
using Robust.Shared.Configuration;
using Robust.Shared.Random;
using Robust.Shared.Timing;
namespace Content.Server._Sunrise.Messenger;
public sealed partial class MessengerServerSystem
{
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] private readonly IGameTiming _timing = default!;
private bool _spamEnabled;
private float _spamMinTime;
private float _spamMaxTime;
private float _spamPlayerPercentage;
private TimeSpan _nextTick = TimeSpan.Zero;
private readonly TimeSpan _refreshCooldown = TimeSpan.FromSeconds(1);
private void InitializeSpam()
{
Subs.CVar(_cfg, SunriseCCVars.MessengerSpamEnabled, OnSpamEnabledChanged, true);
Subs.CVar(_cfg, SunriseCCVars.MessengerSpamMinTime, OnSpamMinTimeChanged, true);
Subs.CVar(_cfg, SunriseCCVars.MessengerSpamMaxTime, OnSpamMaxTimeChanged, true);
Subs.CVar(_cfg, SunriseCCVars.MessengerSpamPlayerPercentage, OnSpamPlayerPercentageChanged, true);
}
private void OnSpamEnabledChanged(bool value)
{
_spamEnabled = value;
}
private void OnSpamMinTimeChanged(float value)
{
UpdateSpamTiming(value, _spamMaxTime);
}
private void OnSpamMaxTimeChanged(float value)
{
UpdateSpamTiming(_spamMinTime, value);
}
private void OnSpamPlayerPercentageChanged(float value)
{
_spamPlayerPercentage = value;
}
private void UpdateSpamTiming(float min, float max)
{
_spamMinTime = min;
_spamMaxTime = max;
if (_spamMinTime > _spamMaxTime)
{
(_spamMinTime, _spamMaxTime) = (_spamMaxTime, _spamMinTime);
}
}
private void ResetSpamTimer(StationMessengerSpamComponent component)
{
component.Timer = 0;
component.NextSpamTime = _random.NextFloat(_spamMinTime, _spamMaxTime);
}
private void UpdateSpam(float frameTime)
{
if (_nextTick > _timing.CurTime)
return;
_nextTick = _timing.CurTime + _refreshCooldown;
if (!_spamEnabled)
return;
var query = EntityQueryEnumerator<StationMessengerSpamComponent>();
while (query.MoveNext(out var uid, out var spam))
{
if (spam.NextSpamTime <= 0)
{
ResetSpamTimer(spam);
continue;
}
spam.Timer += (float)_refreshCooldown.TotalSeconds;
if (spam.Timer < spam.NextSpamTime)
continue;
ResetSpamTimer(spam);
var serverResult = GetServerEntity(uid);
if (serverResult != null)
{
Sawmill.Info($"Triggering spam wave for station {uid} (Server: {serverResult.Value.Item1})");
SendSpamWave(serverResult.Value.Item1, serverResult.Value.Item2);
}
else
{
Sawmill.Warning($"Could not find messenger server for station {uid}, skipping spam wave.");
}
}
}
private void SendSpamWave(EntityUid uid, MessengerServerComponent component)
{
var prototypes = _prototypeManager.EnumeratePrototypes<MessengerSpamPrototype>().ToList();
if (prototypes.Count == 0)
return;
var players = new List<(EntityUid Uid, MessengerServerComponent Component, MessengerUser User)>();
foreach (var user in component.Users.Values)
{
// Skip spam bots
if (user.UserId.StartsWith("spam_"))
continue;
players.Add((uid, component, user));
}
if (players.Count == 0)
{
return;
}
var targetCount = (int)(players.Count * _spamPlayerPercentage);
if (targetCount < 1)
targetCount = 1;
_random.Shuffle(players);
var count = Math.Min(targetCount, players.Count);
Sawmill.Info($"Sending spam to {count} users (Total: {players.Count}, Target %: {_spamPlayerPercentage})");
for (int i = 0; i < count; i++)
{
var p = players[i];
SendSpamToUser(p.Uid, p.Component, p.User, prototypes);
}
}
private void SendSpamToUser(EntityUid uid, MessengerServerComponent component, MessengerUser user, List<MessengerSpamPrototype> prototypes)
{
var proto = _random.Pick(prototypes);
var senderName = GetRandomString(proto.SenderDataset);
if (proto.NameDataset != null && proto.SurnameDataset != null)
{
senderName += " " + GetRandomString(proto.NameDataset);
senderName += " " + GetRandomString(proto.SurnameDataset);
}
var messageContent = GetRandomString(proto.MessageDataset);
if (string.IsNullOrWhiteSpace(messageContent))
return;
var senderId = $"spam_{Math.Abs(senderName.GetHashCode())}";
if (!component.Users.ContainsKey(senderId))
{
var spamUser = new MessengerUser(senderId, senderName);
component.Users.Add(senderId, spamUser);
}
SendFakePersonalMessage(uid, user.UserId, senderId, senderName, messageContent);
}
private string GetRandomString(string datasetId)
{
if (!_prototypeManager.TryIndex(datasetId, out LocalizedDatasetPrototype? dataset))
return "Error";
var key = _random.Pick(dataset.Values);
return _loc.GetString(key);
}
}

View file

@ -143,23 +143,20 @@ public sealed partial class MessengerServerSystem
var userName = pda.OwnerName ?? Loc.GetString("messenger-user-unknown");
string? jobTitle = null;
string? departmentId = null;
ProtoId<JobIconPrototype>? jobIconId = null;
var departments = new List<string>();
ProtoId<JobIconPrototype> jobIconId = "JobIconUnknown";
if (pda.ContainedId != null && TryComp<IdCardComponent>(pda.ContainedId.Value, out var idCard))
{
jobTitle = idCard.LocalizedJobTitle;
if (idCard.JobDepartments.Count > 0)
{
departmentId = idCard.JobDepartments[0];
}
departments.AddRange(idCard.JobDepartments.Select(d => (string) d));
jobIconId = idCard.JobIcon;
}
var user = new MessengerUser(userId, userName, jobTitle, departmentId, jobIconId);
var user = new MessengerUser(userId, userName, jobTitle, departments, jobIconId);
component.Users[userId] = user;
AddUserToAutoGroups(uid, component, userId, userName, departmentId);
AddUserToAutoGroups(uid, component, userId, userName, departments);
if (!TryComp<DeviceNetworkComponent>(uid, out var serverDevice))
{
@ -184,8 +181,9 @@ public sealed partial class MessengerServerSystem
["user_id"] = userId,
["user_name"] = userName,
["job_title"] = jobTitle ?? string.Empty,
["department_id"] = departmentId ?? string.Empty,
["job_icon_id"] = jobIconId?.Id ?? string.Empty
["department_id"] = user.DepartmentId ?? string.Empty,
["department_ids"] = user.DepartmentIds,
["job_icon_id"] = jobIconId.Id
};
if (_deviceNetwork.IsAddressPresent(serverDevice.DeviceNetId, userId))
@ -202,6 +200,7 @@ public sealed partial class MessengerServerSystem
["user_name"] = u.Name,
["job_title"] = u.JobTitle ?? string.Empty,
["department_id"] = u.DepartmentId ?? string.Empty,
["department_ids"] = u.DepartmentIds,
["job_icon_id"] = u.JobIconId?.Id ?? string.Empty
}).ToList()
};
@ -312,17 +311,14 @@ public sealed partial class MessengerServerSystem
string? userName = null;
string? jobTitle = null;
string? departmentId = null;
ProtoId<JobIconPrototype>? jobIconId = null;
var departments = new List<string>();
ProtoId<JobIconPrototype> jobIconId = "JobIconUnknown";
if (pda.ContainedId != null && TryComp<IdCardComponent>(pda.ContainedId.Value, out var idCard))
{
userName = idCard.FullName;
jobTitle = idCard.LocalizedJobTitle;
if (idCard.JobDepartments.Count > 0)
{
departmentId = idCard.JobDepartments[0];
}
departments.AddRange(idCard.JobDepartments.Select(d => (string) d));
jobIconId = idCard.JobIcon;
}
@ -331,10 +327,10 @@ public sealed partial class MessengerServerSystem
userName = pda.OwnerName ?? Loc.GetString("messenger-user-unknown");
}
var user = new MessengerUser(userId, userName, jobTitle, departmentId, jobIconId);
var user = new MessengerUser(userId, userName, jobTitle, departments, jobIconId);
component.Users[userId] = user;
AddUserToAutoGroups(uid, component, userId, userName, departmentId);
AddUserToAutoGroups(uid, component, userId, userName, departments);
if (!TryComp<DeviceNetworkComponent>(uid, out var serverDevice))
{
@ -359,8 +355,9 @@ public sealed partial class MessengerServerSystem
["user_id"] = userId,
["user_name"] = userName,
["job_title"] = jobTitle ?? string.Empty,
["department_id"] = departmentId ?? string.Empty,
["job_icon_id"] = jobIconId?.Id ?? string.Empty
["department_id"] = user.DepartmentId ?? string.Empty,
["department_ids"] = user.DepartmentIds,
["job_icon_id"] = jobIconId.Id
};
if (_deviceNetwork.IsAddressPresent(serverDevice.DeviceNetId, args.SenderAddress))
@ -377,6 +374,7 @@ public sealed partial class MessengerServerSystem
["user_name"] = u.Name,
["job_title"] = u.JobTitle ?? string.Empty,
["department_id"] = u.DepartmentId ?? string.Empty,
["department_ids"] = u.DepartmentIds,
["job_icon_id"] = u.JobIconId?.Id ?? string.Empty
}).ToList()
};
@ -431,7 +429,7 @@ public sealed partial class MessengerServerSystem
/// <summary>
/// Добавляет пользователя в автоматические группы на основе прототипов
/// </summary>
private void AddUserToAutoGroups(EntityUid uid, MessengerServerComponent component, string userId, string userName, string? departmentId)
private void AddUserToAutoGroups(EntityUid uid, MessengerServerComponent component, string userId, string userName, IEnumerable<string> departments)
{
foreach (var autoGroupProto in _prototypeManager.EnumeratePrototypes<MessengerAutoGroupPrototype>())
{
@ -441,9 +439,9 @@ public sealed partial class MessengerServerSystem
{
shouldAdd = true;
}
else if (departmentId != null && autoGroupProto.Departments.Count > 0)
else if (autoGroupProto.Departments.Count > 0)
{
shouldAdd = autoGroupProto.Departments.Contains(departmentId);
shouldAdd = autoGroupProto.Departments.Any(d => departments.Contains((string) d));
}
if (!shouldAdd)
@ -630,25 +628,22 @@ public sealed partial class MessengerServerSystem
return;
string? jobTitle = null;
string? departmentId = null;
ProtoId<JobIconPrototype>? jobIconId = null;
var departments = new List<string>();
ProtoId<JobIconPrototype> jobIconId = "JobIconUnknown";
if (pdaComp.ContainedId != null && TryComp<IdCardComponent>(pdaComp.ContainedId.Value, out var idCard))
{
jobTitle = idCard.LocalizedJobTitle;
if (idCard.JobDepartments.Count > 0)
{
departmentId = idCard.JobDepartments[0];
}
departments.AddRange(idCard.JobDepartments.Select(d => (string) d));
jobIconId = idCard.JobIcon;
}
var needsUpdate = user.JobTitle != jobTitle || user.DepartmentId != departmentId || user.JobIconId != jobIconId;
var needsUpdate = user.JobTitle != jobTitle || !user.DepartmentIds.SequenceEqual(departments) || user.JobIconId != jobIconId;
if (needsUpdate)
{
user.JobTitle = jobTitle;
user.DepartmentId = departmentId;
user.DepartmentIds = departments;
user.JobIconId = jobIconId;
if (!TryComp<DeviceNetworkComponent>(uid, out var serverDevice))
@ -669,6 +664,7 @@ public sealed partial class MessengerServerSystem
["user_name"] = u.Name,
["job_title"] = u.JobTitle ?? string.Empty,
["department_id"] = u.DepartmentId ?? string.Empty,
["department_ids"] = u.DepartmentIds,
["job_icon_id"] = u.JobIconId?.Id ?? string.Empty
}).ToList()
};

View file

@ -1,4 +1,5 @@
using System.Linq;
using Content.Shared.Radio;
using Content.Server.DeviceNetwork.Systems;
using Content.Shared.DeviceNetwork.Events;
using Content.Server.Station.Systems;
@ -9,6 +10,8 @@ using Content.Shared._Sunrise.Messenger;
using Content.Shared.Inventory;
using Robust.Shared.Prototypes;
using Content.Server.CartridgeLoader;
using Content.Server.DeviceNetwork.Components;
using Robust.Shared.Random;
namespace Content.Server._Sunrise.Messenger;
@ -25,9 +28,23 @@ public sealed partial class MessengerServerSystem : EntitySystem
[Dependency] private readonly ILocalizationManager _loc = default!;
[Dependency] private readonly InventorySystem _inventory = default!;
[Dependency] private readonly CartridgeLoaderSystem _cartridgeLoader = default!;
[Dependency] private readonly IRobustRandom _random = default!;
private ISawmill Sawmill { get; set; } = default!;
/// <summary>
/// Находит GroupId для указанного радиоканала
/// </summary>
public string? GetGroupIdByRadioChannel(string radioChannelId)
{
foreach (var proto in _prototypeManager.EnumeratePrototypes<MessengerAutoGroupPrototype>())
{
if (proto.RadioChannel == radioChannelId)
return proto.GroupId;
}
return null;
}
public override void Initialize()
{
base.Initialize();
@ -39,6 +56,14 @@ public sealed partial class MessengerServerSystem : EntitySystem
SubscribeLocalEvent<MessengerServerComponent, DeviceNetServerDisconnectedEvent>(OnServerDisconnected);
SubscribeLocalEvent<MessengerServerComponent, RoundRestartCleanupEvent>(OnRoundRestart);
SubscribeLocalEvent<PlayerSpawnCompleteEvent>(OnPlayerSpawnComplete);
InitializeSpam();
}
public override void Update(float frameTime)
{
base.Update(frameTime);
UpdateSpam(frameTime);
}
private void OnRoundRestart(EntityUid uid, MessengerServerComponent component, RoundRestartCleanupEvent args)
@ -72,7 +97,7 @@ public sealed partial class MessengerServerSystem : EntitySystem
foreach (var user in component.Users.Values)
{
AddUserToAutoGroups(uid, component, user.UserId, user.Name, user.DepartmentId);
AddUserToAutoGroups(uid, component, user.UserId, user.Name, user.DepartmentIds);
}
}
@ -172,4 +197,27 @@ public sealed partial class MessengerServerSystem : EntitySystem
{
return ++component.MessageIdCounter;
}
/// <summary>
/// Находит сущность сервера мессенджера для станции
/// </summary>
public (EntityUid, MessengerServerComponent)? GetServerEntity(EntityUid? station)
{
if (station == null)
return null;
var query = EntityQueryEnumerator<MessengerServerComponent, SingletonDeviceNetServerComponent>();
while (query.MoveNext(out var uid, out var comp, out var singleton))
{
if (!_singletonServer.IsActiveServer(uid, singleton))
continue;
if (_stationSystem.GetOwningStation(uid) != station)
continue;
return (uid, comp);
}
return null;
}
}

View file

@ -0,0 +1,20 @@
namespace Content.Server._Sunrise.Messenger;
/// <summary>
/// Component for handling messenger spam timing on a station/entity.
/// </summary>
[RegisterComponent]
public sealed partial class StationMessengerSpamComponent : Component
{
/// <summary>
/// Current timer in seconds.
/// </summary>
[DataField, ViewVariables(VVAccess.ReadWrite)]
public float Timer;
/// <summary>
/// Target time in seconds for the next spam wave.
/// </summary>
[DataField, ViewVariables(VVAccess.ReadWrite)]
public float NextSpamTime;
}

View file

@ -3,6 +3,7 @@ using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Content.Shared._Sunrise.CartridgeLoader.Cartridges;
using Content.Shared._Sunrise.NetTextures;
using Robust.Server.Player;
using Robust.Shared.ContentPack;
@ -11,8 +12,6 @@ using Robust.Shared.Network.Transfer;
using Robust.Shared.Player;
using Robust.Shared.Upload;
using Robust.Shared.Utility;
using Robust.Shared.GameObjects;
using Content.Shared.GameTicking;
using ByteHelpers = Robust.Shared.Utility.ByteHelpers;
namespace Content.Server._Sunrise;
@ -45,11 +44,20 @@ namespace Content.Server._Sunrise;
/// </summary>
private readonly Dictionary<ResPath, byte[]> _dynamicResources = new();
/// <summary>
/// Callback for handling photo captures. PhotoCartridgeSystem registers itself here.
/// </summary>
public Action<PdaPhotoCaptureMessage>? OnPhotoCaptureMessage { get; set; }
public void Initialize()
{
_sawmill = _logManager.GetSawmill("network.textures");
_netManager.RegisterNetMessage<RequestNetworkResourceMessage>(OnRequestNetworkResource);
_netManager.RegisterNetMessage<PdaPhotoCaptureMessage>(
msg => OnPhotoCaptureMessage?.Invoke(msg),
accept: NetMessageAccept.Server);
_transferManager.RegisterTransferMessage(TransferKeyNetTextures);
}

View file

@ -168,10 +168,18 @@ public sealed partial class GeneralStationRecordConsoleSystem
if (ent.Comp.Silent)
return;
// Sunrise-Start
var server = _messenger.GetServerEntity(_station.GetOwningStation(ent));
foreach (var channel in ent.Comp.AnnouncementChannels)
{
_radio.SendRadioMessage(ent, message, channel, ent);
//_radio.SendRadioMessage(ent, message, channel, ent);
if (_messenger.GetGroupIdByRadioChannel(channel) is { } groupId && server != null)
{
_messenger.SendSystemMessageToGroup(server.Value.Item1, groupId, message);
}
}
// Sunrise-End
_audio.PlayPvs(ent.Comp.SuccessfulSound, ent);
}

View file

@ -57,14 +57,14 @@ public sealed partial class CCVars
/// Also looks weird on slow spacing for unrelated reasons. If you do want to enable this, you should probably turn on instaspacing.
/// </summary>
public static readonly CVarDef<bool> MonstermosRipTiles =
CVarDef.Create("atmos.monstermos_rip_tiles", false, CVar.SERVERONLY);
CVarDef.Create("atmos.monstermos_rip_tiles", true, CVar.SERVERONLY); // Sunrise-Edit
/// <summary>
/// Whether explosive depressurization will cause the grid to gain an impulse.
/// Needs <see cref="MonstermosEqualization"/> and <see cref="MonstermosDepressurization"/> to be enabled to work.
/// </summary>
public static readonly CVarDef<bool> AtmosGridImpulse =
CVarDef.Create("atmos.grid_impulse", false, CVar.SERVERONLY);
CVarDef.Create("atmos.grid_impulse", true, CVar.SERVERONLY); // Sunrise-Edit
/// <summary>
/// What fraction of air from a spaced tile escapes every tick.

View file

@ -1,3 +1,4 @@
using Content.Shared._Sunrise.CartridgeLoader.Cartridges;
using Content.Shared.MassMedia.Systems;
using Robust.Shared.Serialization;
@ -17,14 +18,16 @@ public sealed class NewsWriterBoundUserInterfaceState : BoundUserInterfaceState
public readonly TimeSpan NextPublish;
public readonly string DraftTitle;
public readonly string DraftContent;
public readonly List<string>? DraftPhotoPaths;
public NewsWriterBoundUserInterfaceState(NewsArticle[] articles, bool publishEnabled, TimeSpan nextPublish, string draftTitle, string draftContent)
public NewsWriterBoundUserInterfaceState(NewsArticle[] articles, bool publishEnabled, TimeSpan nextPublish, string draftTitle, string draftContent, List<string>? draftPhotoPaths = null)
{
Articles = articles;
PublishEnabled = publishEnabled;
NextPublish = nextPublish;
DraftTitle = draftTitle;
DraftContent = draftContent;
DraftPhotoPaths = draftPhotoPaths;
}
}
@ -33,12 +36,14 @@ public sealed class NewsWriterPublishMessage : BoundUserInterfaceMessage
{
public readonly string Title;
public readonly string Content;
public readonly List<string>? PhotoPaths;
public NewsWriterPublishMessage(string title, string content)
public NewsWriterPublishMessage(string title, string content, List<string>? photoPaths = null)
{
Title = title;
Content = content;
PhotoPaths = photoPaths;
}
}
@ -63,11 +68,13 @@ public sealed class NewsWriterSaveDraftMessage : BoundUserInterfaceMessage
{
public readonly string DraftTitle;
public readonly string DraftContent;
public readonly List<string>? DraftPhotoPaths;
public NewsWriterSaveDraftMessage(string draftTitle, string draftContent)
public NewsWriterSaveDraftMessage(string draftTitle, string draftContent, List<string>? draftPhotoPaths = null)
{
DraftTitle = draftTitle;
DraftContent = draftContent;
DraftPhotoPaths = draftPhotoPaths;
}
}
@ -75,3 +82,19 @@ public sealed class NewsWriterSaveDraftMessage : BoundUserInterfaceMessage
public sealed class NewsWriterRequestDraftMessage : BoundUserInterfaceMessage
{
}
[Serializable, NetSerializable]
public sealed class NewsWriterRequestPhotosMessage : BoundUserInterfaceMessage
{
}
[Serializable, NetSerializable]
public sealed class NewsWriterPhotosMessage : BoundUserInterfaceMessage
{
public readonly List<PhotoMetadata> Photos;
public NewsWriterPhotosMessage(List<PhotoMetadata> photos)
{
Photos = photos;
}
}

View file

@ -25,6 +25,9 @@ public struct NewsArticle
[ViewVariables]
public TimeSpan ShareTime;
[ViewVariables(VVAccess.ReadWrite)]
public List<string>? PhotoPaths;
}
[ByRefEvent]

View file

@ -438,7 +438,7 @@ public abstract partial class SharedVendingMachineSystem : EntitySystem
uint restock = amount;
if (type == InventoryType.Regular)
if (type == InventoryType.Regular || type == InventoryType.Contraband)
{
var chanceOfMissingStock = 1 - restockQuality;
var result = Randomizer.NextFloat(0, 1);

View file

@ -26,8 +26,14 @@ public sealed class PdaPhotoCaptureMessage : NetMessage
/// </summary>
public int Height { get; set; }
/// <summary>
/// Uid of the cartridge loader (PDA) that took the photo
/// </summary>
public NetEntity LoaderUid { get; set; }
public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer)
{
LoaderUid = buffer.ReadNetEntity();
Width = buffer.ReadInt32();
Height = buffer.ReadInt32();
var dataLength = buffer.ReadInt32();
@ -36,6 +42,7 @@ public sealed class PdaPhotoCaptureMessage : NetMessage
public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer serializer)
{
buffer.Write(LoaderUid);
buffer.Write(Width);
buffer.Write(Height);
buffer.Write(ImageData.Length);

View file

@ -1,3 +1,4 @@
using Content.Shared.Radio;
using Content.Shared.Roles;
using Robust.Shared.Prototypes;
@ -24,6 +25,12 @@ public sealed partial class MessengerAutoGroupPrototype : IPrototype
[DataField(required: true)]
public string GroupId { get; private set; } = default!;
/// <summary>
/// Связанный радиоканал (для системных оповещений)
/// </summary>
[DataField]
public ProtoId<RadioChannelPrototype>? RadioChannel { get; private set; } = null;
/// <summary>
/// Если true, добавляет всех пользователей автоматически
/// </summary>

View file

@ -0,0 +1,22 @@
using Robust.Shared.Prototypes;
namespace Content.Shared._Sunrise.Messenger;
[Prototype]
public sealed partial class MessengerSpamPrototype : IPrototype
{
[IdDataField]
public string ID { get; private set; } = default!;
[DataField(required: true)]
public string SenderDataset { get; private set; } = string.Empty;
[DataField(required: true)]
public string MessageDataset { get; private set; } = string.Empty;
[DataField]
public string? NameDataset { get; private set; }
[DataField]
public string? SurnameDataset { get; private set; }
}

View file

@ -26,21 +26,26 @@ public sealed class MessengerUser
public string? JobTitle { get; set; }
/// <summary>
/// ID отдела пользователя (опционально)
/// ID главного отдела пользователя (опционально)
/// </summary>
public string? DepartmentId { get; set; }
public string? DepartmentId => DepartmentIds.Count > 0 ? DepartmentIds[0] : null;
/// <summary>
/// Список всех отделов пользователя
/// </summary>
public List<string> DepartmentIds { get; set; } = new();
/// <summary>
/// ID иконки роли пользователя (опционально)
/// </summary>
public ProtoId<JobIconPrototype>? JobIconId { get; set; }
public MessengerUser(string userId, string name, string? jobTitle = null, string? departmentId = null, ProtoId<JobIconPrototype>? jobIconId = null)
public MessengerUser(string userId, string name, string? jobTitle = null, List<string>? departmentIds = null, ProtoId<JobIconPrototype>? jobIconId = null)
{
UserId = userId;
Name = name;
JobTitle = jobTitle;
DepartmentId = departmentId;
DepartmentIds = departmentIds ?? new List<string>();
JobIconId = jobIconId;
}
}

View file

@ -590,4 +590,33 @@ public sealed partial class SunriseCCVars : CVars
/// </summary>
public static readonly CVarDef<string> MessengerFavoriteEmojis =
CVarDef.Create("messenger.favorite_emojis", "", CVar.ARCHIVE | CVar.CLIENTONLY);
/*
* Messenger Spam
*/
/// <summary>
/// Enables the mechanic where players receive spam messages on their PDA.
/// </summary>
public static readonly CVarDef<bool> MessengerSpamEnabled =
CVarDef.Create("messenger.spam_enabled", true, CVar.SERVERONLY);
/// <summary>
/// Minimum time between spam waves in seconds.
/// </summary>
public static readonly CVarDef<float> MessengerSpamMinTime =
CVarDef.Create("messenger.spam_min_time", 300f, CVar.SERVERONLY);
/// <summary>
/// Maximum time between spam waves in seconds.
/// </summary>
public static readonly CVarDef<float> MessengerSpamMaxTime =
CVarDef.Create("messenger.spam_max_time", 600f, CVar.SERVERONLY);
/// <summary>
/// Percentage of players (0.0 to 1.0) who will receive spam during a wave.
/// </summary>
public static readonly CVarDef<float> MessengerSpamPlayerPercentage =
CVarDef.Create("messenger.spam_player_percentage", 0.4f, CVar.SERVERONLY);
}

View file

@ -25400,3 +25400,220 @@
id: 1645
time: '2026-01-30T05:26:06.0000000+00:00'
url: https://github.com/space-sunrise/sunrise-station/pull/3819
- author: Orvex07
changes:
- message: "\u0418\u0441\u043F\u0440\u0430\u0432\u043B\u0435\u043D \u0443\u0434\u0430\
\u043B\u0435\u043D\u043D\u044B\u0439 \u043A\u043E\u043D\u0442\u0440\u043E\u043B\
\u043B\u0435\u0440 \u0443\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0438\u044F\
\ \u0448\u0430\u0442\u0442\u043B\u043E\u043C \u0434\u0438\u0432\u0435\u0440\u0441\
\u0438\u043E\u043D\u043D\u043E\u0433\u043E \u043E\u0442\u0440\u044F\u0434\u0430"
type: Fix
- message: "\u0418\u0441\u043F\u0440\u0430\u0432\u043B\u0435\u043D \u0443\u0434\u0430\
\u043B\u0435\u043D\u043D\u044B\u0439 \u043A\u043E\u043D\u0442\u0440\u043E\u043B\
\u043B\u0435\u0440 \u0443\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0438\u044F\
\ \u0448\u0430\u0442\u0442\u043B\u043E\u043C \u044F\u0434\u0435\u0440\u043D\u044B\
\u0445 \u043E\u043F\u0435\u0440\u0430\u0442\u0438\u0432\u043D\u0438\u043A\u043E\
\u0432"
type: Fix
id: 1646
time: '2026-01-30T05:55:56.0000000+00:00'
url: https://github.com/space-sunrise/sunrise-station/pull/3812
- author: Kinar_7
changes:
- message: "\u0441\u0435\u043A\u0440\u0435\u043D\u044B\u0439 \u0438\u043D\u0432\u0435\
\u043D\u0442\u0430\u0440\u044C \u0432\u0435\u043D\u0434\u043E\u043C\u0430\u0442\
\u0430 \u0434\u0435\u0442\u0435\u043A\u0442\u0438\u0432\u0430 \u0442\u0435\u043F\
\u0435\u0440\u044C \u0438\u043C\u0435\u0435\u0442 \u043E\u0442\u0441\u044B\u043B\
\u043A\u0443 \u043D\u0430 \u0434\u0438\u0441\u043A\u043E \u044D\u043B\u0438\u0437\
\u0438\u0443\u043C."
type: Tweak
- message: "\u0435\u0449\u0451 \u0447\u0430\u0441\u0442\u044C \u0432\u0435\u043D\
\u0434\u043E\u043C\u0430\u0442\u043E\u0432 \u0442\u0435\u043F\u0435\u0440\u044C\
\ \u043C\u0435\u043D\u044F\u044E\u0442 \u0438\u043D\u0432\u0435\u043D\u0442\u0430\
\u0440\u044C \u0432 \u0437\u0430\u0432\u0438\u0441\u0438\u043C\u043E\u0441\u0442\
\u0438 \u043E\u0442 \u043E\u043D\u043B\u0430\u0439\u043D\u0430"
type: Tweak
- message: "\u0441\u0435\u043A\u0440\u0435\u0442\u043D\u044B\u0435 \u0438\u043D\u0432\
\u0435\u043D\u0442\u0430\u0440\u0438 \u0448\u0435\u0432\u0432\u0435\u043D\u0434\
\u0430 \u0438 \u043C\u0438\u0441\u0442\u0435\u0440\u0430 \u0447\u0430\u043D\u0433\
\u0430 \u0431\u044B\u043B\u0438 \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\
\u044B."
type: Tweak
- message: "\u0411\u043E\u0435\u0432\u043E\u0439 \u043D\u0430\u0431\u043E\u0440\
\ \u0433\u043E\u0440\u043D\u0438\u0447\u043D\u043E\u0439 \u0441\u0438\u043D\u0434\
\u0438\u043A\u0430\u0442\u0430 \u0432 \u0441\u0438\u043D\u0434\u0438\u0448\u043A\
\u0430\u0444\u0435, \u0431\u044B\u043B \u043F\u0435\u0440\u0435\u043D\u0435\u0441\
\u0435\u043D \u0441 \u0441\u0435\u043A\u0440\u0435\u0442\u043D\u043E\u0433\u043E\
\ \u0438\u043D\u0432\u0435\u043D\u0442\u0430\u0440\u044F \u0432 \u0435\u043C\
\u0430\u0433\u043D\u0443\u0442\u044B\u0439."
type: Tweak
id: 1647
time: '2026-01-30T14:06:45.0000000+00:00'
url: https://github.com/space-sunrise/sunrise-station/pull/3777
- author: Orvex
changes:
- message: "\u0412\u043E\u0437\u0432\u0440\u0430\u0449\u0435\u043D\u0430 \u0446\u0435\
\u043B\u044C \u043D\u0430 \u043A\u0440\u0430\u0436\u0443 \u0440\u0443\u0447\u043D\
\u043E\u0433\u043E \u043C\u043E\u043D\u0438\u0442\u043E\u0440\u0438\u043D\u0433\
\u0430"
type: Add
- message: "\u0421\u0442\u0430\u043D\u0434\u0430\u0440\u0442\u043D\u044B\u0439 \u0440\
\u0443\u0447\u043D\u043E\u0439 \u043C\u043E\u043D\u0438\u0442\u043E\u0440\u0438\
\u043D\u0433 \u0437\u0430\u043C\u0435\u043D\u0435\u043D \u043D\u0430 \u0430\u043B\
\u044C\u0442\u0435\u0440\u043D\u0430\u0442\u0438\u0432\u043D\u0443\u044E \u0432\
\u0435\u0440\u0441\u0438\u044E"
type: Tweak
id: 1648
time: '2026-01-31T08:40:10.0000000+00:00'
url: https://github.com/space-sunrise/sunrise-station/pull/3799
- author: KaiserMaus
changes:
- message: "\u0414\u043E\u0431\u0430\u0432\u043B\u0435\u043D\u043E \u0440\u0443\u043A\
\u043E\u0432\u043E\u0434\u0441\u0442\u0432\u043E \u043F\u043E \u0432\u0437\u0440\
\u044B\u0432\u043D\u043E\u043C\u0443 \u0448\u043D\u0443\u0440\u0443 \u0438 \u0434\
\u0435\u0442\u043E\u043D\u0430\u0442\u043E\u0440\u0430\u0445."
type: Add
- message: "\u0428\u043B\u044E\u0437\u044B \u0438 \u043E\u0431\u044B\u0447\u043D\
\u044B\u0435 \u0441\u0442\u0435\u043D\u044B \u043F\u043E\u0434\u0432\u0435\u0440\
\u0436\u0435\u043D\u044B DeltaPressure."
type: Tweak
- message: "\u0423\u0432\u0435\u043B\u0438\u0447\u0435\u043D\u043E \u043A\u043E\u043B\
\u0438\u0447\u0435\u0441\u0442\u0432\u043E \u043F\u0440\u0435\u0434\u0430\u0442\
\u0435\u043B\u0435\u0439 \u043F\u0440\u0438 \u0432\u044B\u0441\u043E\u043A\u043E\
\u043C \u043E\u043D\u043B\u0430\u0439\u043D\u0435."
type: Tweak
id: 1649
time: '2026-01-31T22:27:45.0000000+00:00'
url: https://github.com/space-sunrise/sunrise-station/pull/3832
- author: VigersRay
changes:
- message: "\u0414\u043E\u0431\u0430\u0432\u043B\u0435\u043D\u0430 \u043F\u043E\u0434\
\u0434\u0435\u0440\u0436\u043A\u0430 \u0441\u043C\u0430\u0439\u043B\u0438\u043A\
\u043E\u0432 \u0434\u043B\u044F Non IC \u0447\u0430\u0442\u043E\u0432 (OOC,\
\ \u043C\u0451\u0440\u0442\u0432\u044B\u0435, \u0430\u0434\u043C\u0438\u043D\
\u0441\u043A\u0438\u0439)."
type: Add
- message: "\u0414\u043E\u0431\u0430\u0432\u043B\u0435\u043D \u043D\u043E\u0432\u044B\
\u0439 \u0441\u043C\u0430\u0439\u043B\u0438\u043A \u0447\u0435\u0440\u0435\u043F\
\u0430."
type: Add
- message: "\u0414\u043E\u0431\u0430\u0432\u043B\u0435\u043D\u0430 \u0432\u043E\u0437\
\u043C\u043E\u0436\u043D\u043E\u0441\u0442\u044C \u0432\u0441\u0442\u0440\u0430\
\u0438\u0432\u0430\u0442\u044C \u0432 \u043D\u043E\u0432\u043E\u0441\u0442\u0438\
\ \u0438\u0437\u043E\u0431\u0440\u0430\u0436\u0435\u043D\u0438\u044F \u0441\
\ \u041A\u041F\u041A."
type: Add
- message: "\u0421\u0438\u0441\u0442\u0435\u043C\u043D\u044B\u0435 \u0443\u0432\u0435\
\u0434\u043E\u043C\u043B\u0435\u043D\u0438\u0435 \u043E\u0431 \u0438\u0437\u0443\
\u0447\u0435\u043D\u0438\u044F\u0445, \u043E\u0431\u044C\u044F\u0432\u043B\u0435\
\u043D\u0438\u0438 \u0440\u043E\u0437\u044B\u0441\u043A\u0430, \u043E\u0434\u043E\
\u0431\u0440\u0435\u043D\u0438\u0438 \u043F\u043E\u043A\u0443\u043F\u043E\u043A\
\ \u043A\u0430\u0440\u0433\u043E, \u0441\u043E\u0437\u0434\u0430\u043D\u0438\
\u0435 \u0430\u043D\u043E\u043C\u0430\u043B\u0438\u0439, \u043F\u0440\u0438\u0442\
\u044F\u0433\u0438\u0432\u0430\u043D\u0438\u0435 \u043E\u0431\u043B\u043E\u043C\
\u043A\u043E\u0432, \u0442\u0440\u0430\u043D\u0441\u0444\u043E\u0440\u043C\u0430\
\u0446\u0438\u044F \u0431\u043E\u0440\u0433\u0430, \u043F\u0440\u0435\u0434\u0441\
\u043C\u0435\u0440\u0442\u043D\u044B\u0439 \u0445\u0440\u0438\u043F, \u0441\u043E\
\u0441\u0442\u043E\u044F\u043D\u0438\u0435 \u0421\u041C\u0430 \u0438 \u0438\u0437\
\u043C\u0435\u043D\u0435\u043D\u0438\u044F \u0441\u0442\u0430\u043D\u0446\u0438\
\u043E\u043D\u043D\u044B\u0445 \u0437\u0430\u043F\u0438\u0441\u0435\u0439 \u043F\
\u0435\u0440\u0435\u043D\u0435\u0441\u0435\u043D\u044B \u0438\u0437 \u0440\u0430\
\u0434\u0438\u043E \u0432 \u0433\u0440\u0443\u043F\u044B \u043E\u0442\u0434\u0435\
\u043B\u043E\u0432 \u0432 \u043C\u0435\u0441\u0441\u0435\u043D\u0436\u0435\u0440\
\u0435."
type: Tweak
- message: "C\u043D\u0438\u043C\u043A\u0438 \u0438\u0437 \u0444\u043E\u0442\u043E\
\u0430\u043F\u0430\u0440\u0430\u0442\u0430 \u0441\u0442\u0430\u043B\u044B \u0431\
\u043E\u043B\u0435\u0435 \u0447\u0435\u0442\u043A\u0438\u043C\u0438. \u041F\u0440\
\u043E\u0449\u0435 \u0433\u043E\u0432\u043E\u0440\u044F \u0431\u0435\u0437 \u043C\
\u044B\u043B\u0430."
type: Tweak
- message: "\u0414\u043E\u0431\u0430\u0432\u043B\u0435\u043D\u0430 \u0432\u043E\u0437\
\u043C\u043E\u0436\u043D\u043E\u0441\u0442\u044C \u0443\u043C\u0435\u043D\u044C\
\u0448\u0438\u0442\u044C \u0437\u0443\u043C \u0447\u0442\u043E\u0431\u044B \u0441\
\u0434\u0435\u043B\u0430\u0442\u044C \u0441\u0435\u043B\u0444\u0438."
type: Tweak
- message: "\u0414\u043E\u0431\u0430\u0432\u043B\u0435\u043D\u043E \u043E\u0442\u043E\
\u0431\u0440\u0430\u0436\u0435\u043D\u0438\u0435 \u0438\u043A\u043E\u043D\u043E\
\u043A \u0434\u043E\u043B\u0436\u043D\u043E\u0441\u0442\u0438 \u043F\u0440\u0438\
\ \u043F\u0440\u0438\u0433\u043B\u0430\u0448\u0435\u043D\u0438\u0438 \u043F\u043E\
\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u0435\u0439 \u0432 \u043F\
\u0440\u0438\u0432\u0430\u0442\u043D\u0443\u044E \u0433\u0440\u0443\u043F\u043F\
\u0443."
type: Tweak
- message: "\u0422\u0435\u043F\u0435\u0440\u044C \u0447\u0430\u0442\u044B \u043A\
\u043E\u0442\u043E\u0440\u044B\u0435 \u0438\u043C\u0435\u044E\u0442 \u043D\u0435\
\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u044B\u0435 \u0441\u043E\u043E\
\u0431\u0449\u0435\u043D\u0438\u044F \u043F\u043E\u0434\u043D\u0438\u043C\u0430\
\u044E\u0442\u0441\u044F \u0432\u0432\u0435\u0440\u0445 \u0441\u043F\u0438\u0441\
\u043A\u0430."
type: Tweak
- message: "\u0414\u043E\u0431\u0430\u0432\u043B\u0435\u043D\u043E \u0442\u0435\u043A\
\u0441\u0442\u043E\u0432\u043E\u0435 \u0443\u0432\u0435\u0434\u043E\u043C\u043B\
\u0435\u043D\u0438\u0435 \u043E\u0431 \u043D\u043E\u0432\u044B\u0445 \u0441\u043E\
\u043E\u0431\u0449\u0435\u043D\u0438\u044F\u0445 \u0438\u043B\u0438 \u043F\u0440\
\u0438\u0433\u043B\u0430\u0448\u0435\u043D\u0438\u044F\u0445."
type: Tweak
- message: "\u0418\u0441\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0430 \u043E\u0448\
\u0438\u0431\u043A\u0430 \u043F\u0440\u0438 \u043A\u043E\u0442\u043E\u0440\u043E\
\u0439 \u0444\u043E\u0442\u043E\u0433\u0440\u0430\u0444\u0438\u0438 \u0441\u043E\
\u0445\u0440\u0430\u043D\u044F\u043B\u0438\u0441\u044C \u043D\u0435 \u0432 \u0442\
\u043E\u0442 \u043A\u043F\u043A \u0441 \u043A\u043E\u0442\u043E\u0440\u043E\u0433\
\u043E \u0444\u043E\u0442\u043A\u0430 \u0431\u044B\u043B\u0430 \u0441\u0434\u0435\
\u043B\u0430\u043D\u0430."
type: Fix
- message: "\u0418\u0441\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u043E \u0441\u043C\
\u0435\u0449\u0435\u043D\u0438\u0435 \u043E\u0431\u043B\u0430\u0441\u0442\u0438\
\ \u0441\u044C\u0435\u043C\u043A\u0438 \u043F\u0440\u0438 \u043D\u0435\u0441\
\u0442\u0430\u043D\u0434\u0430\u0440\u0442\u043D\u044B\u0445 \u0440\u0430\u0437\
\u0440\u0435\u0448\u0435\u043D\u0438\u044F\u0445 \u044D\u043A\u0440\u0430\u043D\
\u0430."
type: Fix
- message: "\u0418\u0441\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0430 \u043E\u0448\
\u0438\u0431\u043A\u0430 \u043F\u0440\u0438 \u043A\u043E\u0442\u043E\u0440\u043E\
\u0439 \u0434\u043E\u043B\u0436\u043D\u043E\u0441\u0442\u0438 \u0441\u043E\u0441\
\u0442\u043E\u044F\u0449\u0438\u0435 \u0432 \u043D\u0435\u0441\u043A\u043E\u043B\
\u044C\u043A\u0438\u0445 \u0434\u0435\u043F\u0430\u0440\u0442\u0430\u043C\u0435\
\u043D\u0442\u0430\u0445 \u043F\u043E\u043F\u0430\u0434\u0430\u043B\u0438 \u0442\
\u043E\u043B\u044C\u043A\u043E \u0432 \u043E\u0434\u0438\u043D \u0433\u0440\u0443\
\u043F\u043E\u0432\u043E\u0439 \u0447\u0430\u0442."
type: Fix
- message: "\u0418\u0441\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0430 \u043E\u0448\
\u0438\u0431\u043A\u0430 \u043F\u0440\u0438 \u043F\u0435\u0440\u0435\u043F\u043E\
\u0434\u043A\u043B\u044E\u0447\u0435\u043D\u0438\u0438 \u043A \u0441\u0435\u0440\
\u0432\u0435\u0440\u0443."
type: Fix
- message: "\u0423\u0434\u0430\u043B\u0435\u043D\u044B \u0430\u0434\u043C\u0438\u043D\
\u044B \u0438\u0437 \u043C\u0435\u0441\u0435\u043D\u0436\u0435\u0440\u0430."
type: Remove
id: 1650
time: '2026-02-01T00:55:47.0000000+00:00'
url: https://github.com/space-sunrise/sunrise-station/pull/3833
- author: VigersRay
changes:
- message: "\u041C\u0435\u0441\u0441\u0435\u043D\u0434\u0436\u0435\u0440 \u0441\u0442\
\u0430\u043B \u043D\u0430\u0441\u0442\u043E\u043B\u044C\u043A\u043E \u0442\u0435\
\u0445\u043D\u043E\u043B\u043E\u0433\u0438\u0447\u043D\u044B\u043C, \u0447\u0442\
\u043E \u0432 \u043D\u0435\u0433\u043E \u043F\u0440\u043E\u0431\u0440\u0430\u043B\
\u0438\u0441\u044C \u043A\u043E\u0441\u043C\u0438\u0447\u0435\u0441\u043A\u0438\
\u0435 \u043C\u043E\u0448\u0435\u043D\u043D\u0438\u043A\u0438. \u0421\u043E\u043E\
\u0431\u0449\u0435\u043D\u0438\u044F \u043E\u0442 \u043E\u0434\u0438\u043D\u043E\
\u043A\u0438\u0445 \u0432\u0434\u043E\u0432, \u0441\u043E\u0431\u0430\u043A\
\ \u043F\u043E 500, \u043F\u043E\u0445\u043E\u0442\u043B\u0438\u0432\u044B\u0445\
\ \u0441\u043A\u0440\u0435\u043B\u043B\u043E\u0447\u043E\u043A \u0438 \u0431\
\u0435\u0437\u0443\u043C\u043D\u044B\u0445 \u043A\u0443\u043B\u044C\u0442\u0438\
\u0441\u0442\u043E\u0432 \u0442\u0435\u043F\u0435\u0440\u044C \u0434\u043E\u0441\
\u0442\u0443\u043F\u043D\u044B \u0430\u0431\u0441\u043E\u043B\u044E\u0442\u043D\
\u043E \u0431\u0435\u0441\u043F\u043B\u0430\u0442\u043D\u043E \u043A\u0430\u0436\
\u0434\u043E\u043C\u0443 \u0447\u043B\u0435\u043D\u0443 \u044D\u043A\u0438\u043F\
\u0430\u0436\u0430."
type: Add
- message: "\u041D\u0435\u0439\u0440\u043E\u0441\u0435\u0442\u044C \u0442\u0435\u043F\
\u0435\u0440\u044C \u043F\u0438\u0448\u0435\u0442 \u0432\u0430\u043C \u0447\u0435\
\u0439\u043D\u0436\u043B\u043E\u0433\u0438, \u0432\u043E\u0442 \u0442\u0430\u043A\
\ \u0432\u043E\u0442, \u0434\u0443\u043C\u041E\u0439\u0442\u0435, \u0438\u0445\
\u0438\u0445\u044F\u0445\u044F."
type: Tweak
id: 1651
time: '2026-02-01T04:01:38.0000000+00:00'
url: https://github.com/space-sunrise/sunrise-station/pull/3835

View file

@ -72,3 +72,5 @@ ent-ClothingOuterLeatherCoat = leather jacket
.desc = Perfect for performances!
ent-ClothingOuterLeatherCoatOpened = leather jacket
.desc = Perfect for performances!
ent-ClothingOuterAerostaticBomberJacketArmored = armored aerostatic bomber jacket
.desc = A jacket once worn by the revolutionary air brigades during the Antecentennial Revolution. There are quite a few pockets on the inside, mostly for storing notebooks and compasses.

View file

@ -11,5 +11,5 @@ ent-DeathRattleImplanterFreelance = { ent-BaseImplantOnlyImplanterSyndi }
.suffix = freelance death rattle
.desc = { ent-BaseImplantOnlyImplanterSyndi.desc }
ent-ScramImplanterProto = { ent-ScramImplanter }
.suffix = prototype
.suffix = prototype scram
.desc = { ent-ScramImplanter.desc }

View file

@ -58,7 +58,7 @@ ent-WeaponIAR52Borg = cyborg's IAR-52
.desc = High-speed SMG, equipped with a universal connector for other types of pistol magazines.
ent-HypoBorgSaboteurSyndicate = the cyborg saboteur's hypospray
.desc = { ent-BaseItem.desc }
ent-HandheldCrewMonitorBorg = handheld robot crew monitor
ent-HandheldEmergencyCrewMonitorBorg = handheld robot crew monitor
.desc = A hand-held crew monitor that runs off of your own power cells, which displays the status of the crew suit sensors.
ent-WeaponPlasmaCutterBorg = borg plasma cutter
.desc = A mining tool that fires low-damage plasma bolts at a short range. This one is modified for cyborg use.

View file

@ -2,6 +2,8 @@ ent-MagazineBR64 = BR64 magazine
.desc = Standart EarthGov type of heavy magazines.
ent-BaseMagazineBauer127 = magazine (15mm anti-materiel)
.desc = A large magazine for a heavy weapon, holds 15×115mm anti-materiel rounds.
ent-MagazineBauer127Large = large magazine (15mm space anti-materiel)
.desc = { ent-BaseMagazineBauer127.desc }
ent-MagazineBauer127 = { ent-BaseMagazineBauer127 }
.desc = { ent-BaseMagazineBauer127.desc }
ent-MagazineBauer127Penetrator = magazine (15mm penetrator)
@ -17,6 +19,15 @@ ent-BaseMagazineDragunov = magazine Dragunov (7,62mmR)
ent-MagazineDragunovExtended = magazine Dragunov (7,62mmR)
.suffix = Sunrise
.desc = { ent-BaseMagazineDragunov.desc }
ent-MagazineDragunovSP = magazine Dragunov (7,62mmR SP)
.suffix = Sunrise
.desc = { ent-BaseMagazineDragunov.desc }
ent-MagazineDragunovHP = magazine Dragunov (7,62mmR HP)
.suffix = Sunrise
.desc = { ent-BaseMagazineDragunov.desc }
ent-MagazineDragunovFMJ = magazine Dragunov (7,62mmR FMJ)
.suffix = Sunrise
.desc = { ent-BaseMagazineDragunov.desc }
ent-MagazineDragunov = magazine Dragunov (7,62mmR)
.suffix = Sunrise
.desc = { ent-BaseMagazineDragunov.desc }

View file

@ -3,6 +3,13 @@ ent-CableDet = explosive cord
ent-CableDetStack = explosive cord
.desc = Explosive cord for removing whatever is in your way.
.suffix = Full
ent-CableDetStack10 = { ent-CableDetStack }
.suffix = 10
ent-CableDetStack1 = { ent-CableDetStack }
.suffix = 1
ent-EmptyDetonator = detonator cap
.desc = A detonator cap. Requires a trigger and wire.

View file

@ -51,3 +51,4 @@ guide-entry-sr-rule-cep = Политика эскалации конфликто
guide-entry-sr-rule-ccp = Политика создания персонажей
guide-entry-sr-rule-pana = Препятствование аресту неантагонистами
guide-entry-improvised-grenade-casing = Improvised grenade casing
guide-entry-detonator-triggers = Explosive cord and triggers

View file

@ -1,4 +1,4 @@
## Targets
## Targets
uplink-core-extraction-toolbox-name = Core Extraction Toolbox
uplink-core-extraction-toolbox-desc = A toolbox containing everything you need to remove a nuclear bomb's plutonium core. Instructions not included.
@ -149,3 +149,4 @@ uplink-energy-dome-desc = A personal shield generator that protects the wearer f
uplink-syndicate-teleporter-name = Hand syndicate teleporter
uplink-syndicate-teleporter-desc = An experimental hand teleporting device. Teleports its owner forward in a small area. Be careful not to end up in the wall.

View file

@ -46,6 +46,10 @@ messenger-emoji-favorite-title = Favorites
messenger-emoji-favorite-hint = ПКМ по общему списку для добавления.
ПКМ по избраном для удаления.
messenger-emoji-all-title = All emojis
messenger-notification-message = Новое сообщение от { $name }
messenger-group-notification-message = Новое сообщение в группе { $name }
messenger-invite-notification-message = Вас пригласили в группу { $name }
messenger-user-unknown = Unknown
messenger-system-name = System
messenger-leave-group = Leave group

View file

@ -36,3 +36,7 @@ news-write-ui-richtext-tooltip = News articles support rich text
news-pda-notification-header = New news article
news-publish-admin-announcement = {$actor} published news article {$title} by {$author}
news-write-ui-photos-label = Photos:
news-write-ui-add-photo-text = Add Photo
news-write-ui-select-photo-title = Select PDA Photo
news-write-ui-no-photos = No photos found on PDA

View file

@ -0,0 +1,208 @@
# Casino
messenger-spam-casino-sender-1 = МегаСтавка — ставь или проиграешь!
messenger-spam-casino-sender-2 = Онлайн казино МегаСтавка — 256 лет на рынке полулегальных азартных игр
messenger-spam-casino-sender-3 = Сэкси дилеры в лучшем онлайн казино
messenger-spam-casino-sender-4 = Все остальные онлайн казино — кидалы. Мы не такие.
messenger-spam-casino-message-1 = Думаете жать одну кнопку на автомате это занятие для умственно неполноценных? Докажите что это не так сыграв у нас!
messenger-spam-casino-message-2 = В нашем казино нет никаких «подкруток» или прочих нечестных приёмов. Вам просто не везёт!
messenger-spam-casino-message-3 = Проиграли всю зарплату в друго казино? Проиграйте ещё одну у нас! В этот раз точно повезёт!
messenger-spam-casino-message-4 = Зачем работать на корпорации, когда можно спустить свою жизнь чуть более приятным способом?
messenger-spam-casino-message-5 = Игровая зависимость этом миф! Они просто не могут оторваться от 450 увлекательных игр в нашем казино!
messenger-spam-casino-message-6 = Отделения МегаСтавки есть везде — от Элизиума до Аурума! Да, может даже на вашей станции!
messenger-spam-casino-message-7 = В Онлайн Казино МегаСтавка мы не чешем колоду, гарантируем честную раздачу и наши колоды заряжены не в киосках как у конкурентов!
# Dating
messenger-spam-dating-sender-1 = СладкаяЦыпа
messenger-spam-dating-sender-2 = Одинокая русская невеста
messenger-spam-dating-sender-3 = ТаярскаяКрасотка57
messenger-spam-dating-sender-4 = СкРеЛлОчКа))0
messenger-spam-dating-sender-5 = Унаточка_чмаффки
messenger-spam-dating-sender-6 = Фанатка_ЕРП_82
messenger-spam-dating-sender-7 = Босс_Качалки93
messenger-spam-dating-message-1 = Классные фотки на БыстрыхСвиданиях! Я бы пообщалась побольше, но у меня кончаются кредиты… если ты мне не скинешь немного XD (БыстрыеСвидания).
messenger-spam-dating-message-2 = Подпишись на мой профиль — мой аккаунт [gender-female]@[gender-female].[pick("ru","ck","tj","ur","nt")], и получишь доступ к моим фото~
messenger-spam-dating-message-3 = Слушай, у меня мало времени. Ты хочешь большой и чистой любви? Если хочешь, то ответь. Ещё никто не ответил…
messenger-spam-dating-message-4 = У вас (1) новое сообщение от Куколки с 4 размером!
messenger-spam-dating-message-5 = Я просто ОБОЖАЮ людей! Такие властные, сильные и умные… ммф… встретимся?
messenger-spam-dating-message-6 = Я просто БЕЗ УМА от вульпакин! Хочу пошевелить с тобой хвостиками! Скинь аудио как ты воешь!
messenger-spam-dating-message-7 = Я, как это говорится у меня на родине, «перфоманс артист».
messenger-spam-dating-message-8 = Мой отец учил меня не стесняться, поэтому… не хочешь вступить в наш клуб? Всего 250 кредитов…
messenger-spam-dating-message-9 = Я просто ВЛЮБЛЕНА в таких скреллов как ты! Такие большие, упругие и сочные щупальца на голове!
messenger-spam-dating-message-10 = Я просто СХОЖУ С УМА от греев! Скинешь фотку своих больших глаз?
messenger-spam-dating-message-11 = Плазмамен? А там внизу так же горячо?~
messenger-spam-dating-message-12 = Все таяры такие милые? Обещаю почесать за ушком когда мы встретимся <3
messenger-spam-dating-message-13 = Говорят что КПБ не устают в постели… это правда? Если так, то это свидание!
messenger-spam-dating-message-14 = У Кидан такие большие глаза… скажем так, им есть на что посмотреть…
messenger-spam-dating-message-15 = Легендарная гибкость и влажность слаймоменов… Хочу опробовать на себе!
messenger-spam-dating-message-16 = Всегда хотелось пригреть драска… кстати, ты сегодня мальчик или девочка?
messenger-spam-dating-message-17 = Надеюсь что диона переживёт отсутствие света под одеялом?~
messenger-spam-dating-message-18 = Все остальные такие неженки… а вот воксы — самое то. Особенно пираты, ух… Встретимся?
messenger-spam-dating-message-19 = У вас (2) новых просмотров профиля: Секси Скреллка и Сосед-по-Качалке
# Finance
messenger-spam-finance-sender-1 = Ассоциация Галактических Платежей
messenger-spam-finance-sender-2 = Бюро Отличного Бизнеса
messenger-spam-finance-sender-3 = Надёжные Электронные Платежи
messenger-spam-finance-sender-4 = Финансовый Департамент NanoTransen
messenger-spam-finance-sender-5 = Главное Казначейство
messenger-spam-finance-message-1 = Роскошные часы по бросовым ценам! Есть экземпляры с древнего Альтама!
messenger-spam-finance-message-2 = Часы, Ювелирные Изделия и Аксессуары из костей, Сумки и Кошельки из кожи
messenger-spam-finance-message-3 = Внесите на счёт $100 и мгновенно получите назад $300! «Реально работает!» — Бобби
messenger-spam-finance-message-4 = Кредиты для граждан ТСФ! Всего лишь от 99% годовых! Для остальных — 100%!
messenger-spam-finance-message-5 = К нам поступила жалоба от одного из Ваших коллег по поводу его отношениий с Вами. После разбирательств вы были объявлены в розыск за домогательства. Я могу убедить его отозвать заявление за символические 5000 кредитов.
messenger-spam-finance-message-6 = Мы убедительно просим Вас открыть ОТЧЕТ О ЖАЛОБЕ (прилагается), чтобы ответить на поступившую на вас жалобу, иначе вам будет начислен штраф.
# Medicine
messenger-spam-medicine-sender-1 = На часах полшестого?
messenger-spam-medicine-sender-2 = Имеете проблемы с дисфункцией?
messenger-spam-medicine-sender-3 = Слишком часто «болит голова»?
messenger-spam-medicine-message-1 = Доктор Максман: РЕАЛЬНЫЕ доктора, РЕАЛЬНАЯ наука, РЕАЛЬНЫЕ результаты! Уникальная мазь на основе дегидроза фуфломицина!
messenger-spam-medicine-message-2 = Доктор Максман был создан Джорджем Окуляром, сертифицированным ЦК урологом который лишь в этом секторе помог больше 70 000 пациентов с «мужскими проблемами». Может даже вашему начальнику!
messenger-spam-medicine-message-3 = После семи лет исследований доктор Окуляр и его команда разработали эту простую и революционную формулу улучшения для мужчин. Редкий фуфломицин удалось синтезировать патентованым способом с помощью дегидроза монооксида дигидрогена!
messenger-spam-medicine-message-4 = Мужчины всех видов сообщают об УДИВИТЕЛЬНОМ увеличении длины, ширины и выносливости.
# Prince
messenger-spam-prince-sender-1 = Др.
messenger-spam-prince-sender-2 = Наследный принц
messenger-spam-prince-sender-3 = Король-регент
messenger-spam-prince-sender-4 = Профессор
messenger-spam-prince-sender-5 = Капитан
messenger-spam-prince-name-1 = Роберт
messenger-spam-prince-name-2 = Альфред
messenger-spam-prince-name-3 = Джозефат
messenger-spam-prince-name-4 = Кингсли
messenger-spam-prince-name-5 = Сехи
messenger-spam-prince-name-6 = Жуан
messenger-spam-prince-surname-1 = Мугавэ
messenger-spam-prince-surname-2 = Нкем
messenger-spam-prince-surname-3 = Гвембеш
messenger-spam-prince-surname-4 = Абимбола
messenger-spam-prince-surname-5 = Ндим
messenger-spam-prince-surname-6 = Эну
messenger-spam-prince-message-1 = ВАШИ СРЕДСТВА БЫЛИ ПЕРЕВЕДЕНЫ В БАНК РАЗВИТИЯ. ДЛЯ ДАЛЬНЕЙШЕГО ПЕРЕВОДА ДЕНЕЖНЫХ СРЕДСТВ СООБЩИТЕ СВОЙ НОМЕР АККАУНТА И ПИН-КОД.
messenger-spam-prince-message-2 = Мы рады сообщить вам, что в связи с задержкой нам было поручено НЕМЕДЛЕННО перевести все средства на ваш счет. Для подтверждения счёта переведите 1000 кредитов. Не стоит волноваться, мы вернём вам кредиты после проверки.
messenger-spam-prince-message-3 = Уважаемый получатель средств, сообщаем Вам, что перевод наследства окончательно одобрен и деньги готовы для получения Вами. Всё что Вам нужно сделать — отправить на этот же номер столько кредитов сколько Вы сможете. Это простая банковская формальность. В качестве благодарности мы вышлем Вам в 10 раз больше кредитов.
messenger-spam-prince-message-4 = Из-за отсутствия у меня доверенных лиц мне требуется финансовый счет за пределами моего мира чтобы немедленно внести сумму в размере ПЯТИ МИЛЛИОНОВ кредитов.
messenger-spam-prince-message-5 = Приветствую вас, сэр или мэм. Я с огромным сожалением сообщаю вам, что я умираю, и, из-за отсутствия наследников я выбрал вас, чтобы вы получили все мои сбережения за всю мою жизнь в размере 1,5 миллиарда кредитов. Но у меня нет вашего номера аккаунта и пин-кода, пожалуйста, сообщите их пока не поздно.
# Adult
messenger-spam-adult-sender-1 = Подразделение Морального Духа NanoTransen
messenger-spam-adult-sender-2 = Вы одиноки?
messenger-spam-adult-sender-3 = Дон Солевая
messenger-spam-adult-sender-4 = www.wetskrell.nt
messenger-spam-adult-message-1 = Подразделение Морального Духа NanoTransen предоставляет вам качественные развлекательные сайты. www.wetskrell.nt — один из таких сайтов и, специально для вас, ЦК сделало его бесплатным! Осталось только перейти по ссылке!
messenger-spam-adult-message-2 = WetSkrell.nt является ксенофильским веб-сайтом, одобренным NT для использования членами экипажа мужского пола среди множества станций и аванпостов.
messenger-spam-adult-message-3 = Wetskrell.nt обеспечивает высочайшее качество мужских развлечений для сотрудников Nanotrasen. Почти все модели старше 18 лет!
messenger-spam-adult-message-4 = Просто введите номер и пин-код своего банковского аккаунта Nanotrasen. После этого вы получите безлимитный доступ ко ВСЕМУ контенту www.wetskrell.nt!
# Lottery
messenger-spam-lottery-sender-1 = Вы выиграли бесплатные билеты!
messenger-spam-lottery-sender-2 = Нажмите здесь, чтобы получить свой приз!
messenger-spam-lottery-sender-3 = Вы 1000-й посетитель!
messenger-spam-lottery-sender-4 = Вы - счастливый обладатель главного приза!
messenger-spam-lottery-message-1 = Вы выиграли билеты на новейший боевик БИТВА ЗА СТАНЦИЮ
messenger-spam-lottery-message-2 = Вы выиграли билеты на новейшую криминальную драму ПО СЛЕДАМ МАРТЫШЕК
messenger-spam-lottery-message-3 = Вы выиграли билеты на новейшую романтическую комедию БОЛЬШАЯ РАЗБОРКА В МАЛЕНЬКОЙ КРОВАТИ
messenger-spam-lottery-message-4 = Вы выиграли билеты на скандальный блокбастер ПОЛНЫЙ ДОСТУП
messenger-spam-lottery-message-5 = Вы выиграли билеты на скандальную комедию КАПИТАН МЁРТВ!
messenger-spam-lottery-message-6 = Вы выиграли билеты на фильм ужасов УДАР СПЯЩЕГО КАРПА
messenger-spam-lottery-message-7 = Вы выиграли билеты на слэшер ТУННЕЛЬНЫЙ СНЕГОВИК
messenger-spam-lottery-message-8 = Вы выиграли билеты на трагикомедию БУДНИ ОХРАНЫ
messenger-spam-lottery-message-9 = Вы выиграли билеты на %УДАЛЕНО АВТОМАТИЧЕСКИМ ФИЛЬТРОМ. СЛАВА NANOTRASEN%
messenger-spam-lottery-message-10 = Вы выиграли билеты на короткометражный фильм ВСЕ КОНТРАКТЫ РАСТОРГНУТЫ
messenger-spam-lottery-message-11 = Вы выиграли билеты на романтическую комедию ПЕРВЫЙ ХОНК!
messenger-spam-lottery-message-12 = Вы выиграли билеты на приключенческий фильм ВОЛШЕБНИКИ ИЗ ДАЛЁКОГО КОСМОСА
messenger-spam-lottery-message-13 = Вы выиграли билеты на фэнтези ПОСЛЕДНИЙ СЫН ХОНКОМАТЕРИ
messenger-spam-lottery-message-14 = Вы выиграли билеты на боевик МЕСТЬ СИНДИКАТА, ЭПИЗОД III!
messenger-spam-lottery-message-15 = Вы выиграли билеты на документальный фильм СЛАВА НТ
messenger-spam-lottery-message-16 = Вы выиграли билеты на эротический фильм МОХНАТАЯ ПОДРУГА
messenger-spam-lottery-message-17 = Вы выиграли билеты на эротический фильм КЛУБ КОЖЕВЕННОГО МАСТЕРСТВА
messenger-spam-lottery-message-18 = Вы выиграли билеты на боевик РОБАСТБОРГ
messenger-spam-lottery-message-19 = Вы выиграли билеты на драму 28 ОТТЕНКОВ ВУЛЬПАКИН
messenger-spam-lottery-message-20 = Вы выиграли билеты на новейший триллер ЕРЕСЬ В ЦИРКЕ
# Events
messenger-spam-events-sender-1 = Тамада, баян, услуги
messenger-spam-events-sender-2 = Баянист Тамада Дискотека
messenger-spam-events-sender-3 = Свадьба за 1000 кредитов!
messenger-spam-events-sender-4 = Организуем праздники несмотря на легальность!
messenger-spam-events-message-1 = Увлекательные конкусы для ВАШЕЙ свадьбы!
messenger-spam-events-message-2 = Невесты РЫДАЮТ от радости! Женихи теряют сознание от счастья!
messenger-spam-events-message-3 = 7227051245544 — Игорь
messenger-spam-events-message-4 = Только лучшие аниматоры со всего сектора! Специализированные конкурсы для сотрудников Nanotrasen! Посвящение в капитаны! Торт для начальника службы безопасности!
messenger-spam-events-message-5 = я играть музыка кляссный беру дешево пиши да
# Emergency
messenger-spam-emergency-sender-1 = МАМА ПОМОГИ!
messenger-spam-emergency-sender-2 = СРОЧНО!
messenger-spam-emergency-sender-3 = Папа, я вляпался
messenger-spam-emergency-sender-4 = ПОЖАЛУЙСТА!
messenger-spam-emergency-message-1 = я врезался в корабль и офицер говорит что можно всё замять за 10000 кредитов, переведи пожалуйста
messenger-spam-emergency-message-2 = ОНА САМА УМЕРЛА, Я НЕ ВИНОВАТ! УМОЛЯЮ, МНЕ НУЖНО 5000 НА НОВЫЙ МУЛЬТИПАСПОРТ! СКОРЕЕ, ОНИ СКОРО БУДУТ ЗДЕСЬ
messenger-spam-emergency-message-3 = Меня машина убила! Переведи сколько можешь, иначе они не пришьют мне ноги обратно!
messenger-spam-emergency-message-4 = Это твой сын, памаги! пропорщик говорид что может памочь! Иго номир щёта 133782, банк развития Цыфея
# Cult
messenger-spam-cult-sender-1 = Церковь Всех-и-Сразу
messenger-spam-cult-sender-2 = Общественная организация «Слава Ран'ис»
messenger-spam-cult-sender-3 = Орден «ПРОТИВ ВУЛЬП»
messenger-spam-cult-sender-4 = Движение «ЗА ВУЛЬП»
messenger-spam-cult-sender-5 = КРОВЬ БОГУ КРОВИ
messenger-spam-cult-message-1 = Если вам небезразлично наше дело, заходите к нам по адресу: Сектор Ардей, планета Тифон-14, здание №1756B, этаж -76, офис 4
messenger-spam-cult-message-2 = Наши идею могут показаться немного радикальными, но, уверяю вас, это всего лишь слухи. Напоминаем, что следущая всеобщая молитва в пятницу, и приносите жертвоприношения с собой.
messenger-spam-cult-message-3 = Привет! Тебе одиноко? Не видишь смысла в жизни? Ну что ж, тогда мы будем рады тебе! Всего за 500 кредитов в месяц ты можешь вступить в наше дружное общество.
messenger-spam-cult-message-4 = Вам же не всё равно? А?! Или всё равно?! Ты бесхребетная крыса или уверенный в себе гуманоид?! Если у тебя кишка не тонка, то найдёшь нас сам.
messenger-spam-cult-message-5 = Пожалуйста, спасите меня, я здесь против своей воли.
messenger-spam-cult-message-6 = Вы уверены что живёте праведной жизнью? Если нет, пошлите нам 100 кредитов и мы помолимся за вас. Если да, то тоже пошлите, нам нужны пожертвования.
messenger-spam-cult-message-7 = Мы знаем. Мы найдём тебя сами, не нужно ничего делать.
# Pets
messenger-spam-pets-sender-1 = Экзотические питомцы
messenger-spam-pets-sender-2 = Друзья наши меньшие
messenger-spam-pets-sender-3 = Питомцы, домашние и не очень
messenger-spam-pets-sender-4 = Торговцы Элизиума
messenger-spam-pets-message-1 = Торговый корабль «Пупсик» приглашает вас к себе на борт. Большой выбор свиней и кабанов самых разных пород, включая редких взрывных.
messenger-spam-pets-message-2 = Дикие родственники таяр, вульпакин, воксов, киданов, унатхов, людей в ассортименте. Внимание! Владение нашим товаром может быть запрещено вашим законодательством!
messenger-spam-pets-message-3 = Если вы не купите у нас хоть что-то, мы пристрелим щенка. Вы думаете я шучу? У вас есть 1 час.
messenger-spam-pets-message-4 = ГРОМ 74 27 99 14. Геннадий, Роман, Олег, Михаил, 7 4 2 7 9 9 1 4
messenger-spam-pets-message-5 = Магазин «Мышеловка»! Мыши, крысы, большие и маленькие, плотоядные и не очень! При покупке колесо сыра в подарок! Одобрено Федерацией Мышей!
messenger-spam-pets-message-6 = Огромный выбор питомцев, вкусные корма, защита от блох и вшей и акссесуары для друзей наших меньших, а так же вульпакин и таяр.
# Weapons
messenger-spam-weapons-sender-1 = Оружие для всей семьи
messenger-spam-weapons-sender-2 = Стволы по дешёвке
messenger-spam-weapons-sender-3 = ПУШКИ ПУШКИ ПУШКИ
messenger-spam-weapons-sender-4 = Большие пушки для больших парней
messenger-spam-weapons-sender-5 = Мануфактура «У Бобби»
messenger-spam-weapons-message-1 = Самый большой выбор огнестрела в секторе. От дамских пистолетов до снайперской винтовки! Для совершения покупки требуется лицензия на владение оружием.
messenger-spam-weapons-message-2 = Лучшее от Синдиката. Пистолет %УДАЛЕНО%, усыпляющая %УДАЛЕНО%, пробивающая стены %УДАЛЕНО% и даже легендарный %УДАЛЕНО% меч. Агентам скидки, сотрудникам НТ бесплатн…9э%8%?3Р1… ВНИМАНИЕ! Перехвачено враждебное сообщение. Не обращайте внимания. Возвращайтесь к работе. Слава Nanotrasen!
messenger-spam-weapons-message-3 = Нужно что-то особое? Как насчёт магнума что сделает дыру в стене из пластали? Пневматический зонтик-ружьё? Надувной клоун со взрывчаткой внутри? Если вы об этом подумали, у нас это есть.
messenger-spam-weapons-message-4 = Давай к делу. Тебе нужно оружие? Меня зовут Борис и у меня для тебя есть пушки. Не спрашивай откуда, просто покупай.
messenger-spam-weapons-message-5 = Гранаты. Дымовые шашки. Тротил. Бомбы. Снаряды. Мины. С4. Ракеты. Биологическое оружие. Ядерные боеголовки. Распродажа! Успей, пока не урвали! Никаких лицензий, только бабки!
# Error
messenger-spam-error-sender-1 = ОШ;Б…;кА
messenger-spam-error-sender-2 = 25-j*%...o1q
messenger-spam-error-sender-3 = СБОЙ СИСТЕМЫ СООБЩЕНИЙ
messenger-spam-error-sender-4 = ОШИБКА
messenger-spam-error-message-1 = …от этого корабля, блядь. Ты кто такой, сука? Не пиши сюда больше блядь, я тебя найд…
messenger-spam-error-message-2 = …имание, агент. Дальше будут перечислены ваши цели. Никто не должен уйти живым. Первую цель зов…
messenger-spam-error-message-3 = …ася, хватить, блядь! Водки он напился, идиот. У НАС ЗАВТРА НА ТРАНС СОЛНЕЧНУЮ ФЕДЕРАЦИЮ НАЛЁТ, КАКАЯ ВОДКА! Сука, офицер СССП ещё называе…
messenger-spam-error-message-4 = …ы меня не любишь? Потому что я скрелл?! …извини, я сорвалась. Я вся на нервах из-за работы… Прилетай ко мне, я соску…
messenger-spam-error-message-5 = …еальные стулья! У нас лучшие стулья в секторе! С обивкой и без, с кожей и с инкрустированным ураном! Есть даже передвижные стулья с педалями! Кроме тог…
messenger-spam-error-message-6 = …а сковороду тем временем налейте еще немного масла и выложите тертую свеклу. Обжарьте пару минут и добавьте уксус. Тушите еще минут 5, а после выложите томатную пасту. Томите на медленном огне еще 5-7 минут. Зате…

View file

@ -31,7 +31,7 @@ ent-ClothingOuterArmorAmberNewJan = { ent-ClothingOuterArmorAmberJan }
ent-ClothingOuterArmorAbductor = жилет абдуктора
.desc = { ent-ClothingOuterArmorBasic.desc }
ent-ClothingOuterArmorPlate = плитоносец
.desc = Стандартный бронежилет типа II, обеспечивающий хорошую защиту от пуль и лазеров благодаря вставленным фронтальной и тыльной плитам, но не дающий особой защиты от остального.
.desc = Стандартный бронежилет типа II, обеспечивающий хорошую защиту от пуль благодаря вставленным фронтальной и тыльной плитам, но не дающий особой защиты от остального.
ent-ClothingOuterArmorStab = противоножевой жилет
.desc = Несмотря на название, это обычный бронежилет типа II, обеспечивающий хорошую защиту от ударов и колющих атак, но не более.
ent-ClothingOuterArmorHeatAbsorb = теплозащитный жилет

View file

@ -72,3 +72,5 @@ ent-ClothingOuterLeatherCoat = кожаная куртка
.desc = Прекрасно подходит для выступлений!
ent-ClothingOuterLeatherCoatOpened = кожаная куртка
.desc = Прекрасно подходит для выступлений!
ent-ClothingOuterAerostaticBomberJacketArmored = бронированная аэростатическая куртка-бомбер
.desc = Куртка, которую носили революционные воздушные бригады во время Мировой революции. Внутри довольно много карманов, в основном для хранения блокнотов и компасов.

View file

@ -76,6 +76,18 @@ ent-MobPirateT2ShotgunGlass = мушкетонщик пиратов
.desc = Знает толк в дробовиках, вооружен мушкетоном. Меньше дробовик, больше ручная пушка. Стреляет всем, что влезло в ствол.
ent-MobPirateT2Musket = мушкетер пиратов
.desc = Пират вооруженый старинным мушкетом крупного калибра и штыком.
ent-MobPirateT2MusketEMP = { ent-MobPirateT2Musket }
.suffix = ЭМИ
.desc = { ent-MobPirateT2Musket.desc }
ent-MobPirateT2MusketBlast = { ent-MobPirateT2Musket }
.suffix = Взрыв
.desc = { ent-MobPirateT2Musket.desc }
ent-MobPirateT2MusketFrag = { ent-MobPirateT2Musket }
.suffix = Осколки
.desc = { ent-MobPirateT2Musket.desc }
ent-MobPirateT2MusketPenetrator = { ent-MobPirateT2Musket }
.suffix = Бронебой
.desc = { ent-MobPirateT2Musket.desc }
ent-MobPirateT2MachineCannon = ядромётчик пиратов
.desc = Пират с ручной пушкой с ленточным питанием... Что?!.
ent-MobPirateT2Mech = Большой Ярк
@ -130,3 +142,238 @@ ent-MobPirateT3Boss = Красный Черепень
.desc = Тяжёлый боевой экзокостюм СССП разновидности «Дюранда». Медленный, но неумолимый. Вооружён старым пулемётом крупного калибра. Может выдать рывок, выпустить огонь или скрыться в дыму, сопровождая всё жутким смехом.
ent-MobPirateT3Tachanka = Тачанка
.desc = Пулемёт! Установлен и заряжен!
# timed despawn variants reuse base names/descriptions
ent-MobPirateT1KnifeTimed = { ent-MobPirateT1Knife }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1Knife.desc }
ent-MobPirateT1MonkeyKnuckleDustersTimed = { ent-MobPirateT1MonkeyKnuckleDusters }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1MonkeyKnuckleDusters.desc }
ent-MobPirateT1PickaxeTimed = { ent-MobPirateT1Pickaxe }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1Pickaxe.desc }
ent-MobPirateT1PistolTimed = { ent-MobPirateT1Pistol }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1Pistol.desc }
ent-MobPirateT1MosinTimed = { ent-MobPirateT1Mosin }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1Mosin.desc }
ent-MobPirateT1PKATimed = { ent-MobPirateT1PKA }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1PKA.desc }
ent-MobPirateT1MonkeyFlareGunTimed = { ent-MobPirateT1MonkeyFlareGun }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1MonkeyFlareGun.desc }
ent-MobPirateT1MonkeyFlareGunCoinTimed = { ent-MobPirateT1MonkeyFlareGunCoin }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1MonkeyFlareGunCoin.desc }
ent-MobPirateT1MonkeyFlareGunIncendiaryTimed = { ent-MobPirateT1MonkeyFlareGunIncendiary }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1MonkeyFlareGunIncendiary.desc }
ent-MobPirateT1MonkeyFlareGunUraniumTimed = { ent-MobPirateT1MonkeyFlareGunUranium }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1MonkeyFlareGunUranium.desc }
ent-MobPirateT1ScrapSMGTimed = { ent-MobPirateT1ScrapSMG }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1ScrapSMG.desc }
ent-MobPirateT1SMGTimed = { ent-MobPirateT1SMG }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1SMG.desc }
ent-MobPirateT1FlintlockTimed = { ent-MobPirateT1Flintlock }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1Flintlock.desc }
ent-MobPirateT1MonkeyFireBombTimed = { ent-MobPirateT1MonkeyFireBomb }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1MonkeyFireBomb.desc }
ent-MobPirateT1MonkeyEDaggerTimed = { ent-MobPirateT1MonkeyEDagger }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1MonkeyEDagger.desc }
ent-MobPirateT1HunterTimed = { ent-MobPirateT1Hunter }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1Hunter.desc }
ent-MobPirateT1HunterSmashTimed = { ent-MobPirateT1HunterSmash }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1HunterSmash.desc }
ent-MobPirateT1BombardierFireTimed = { ent-MobPirateT1BombardierFire }
.suffix = Деспавн 20мин, Зажиг
.desc = { ent-MobPirateT1BombardierFire.desc }
ent-MobPirateT1BombardierGlassTimed = { ent-MobPirateT1BombardierGlass }
.suffix = Деспавн 20мин, Картечь
.desc = { ent-MobPirateT1BombardierGlass.desc }
ent-MobPirateT1BombardierPipeTimed = { ent-MobPirateT1BombardierPipe }
.suffix = Деспавн 20мин, Бомба
.desc = { ent-MobPirateT1BombardierPipe.desc }
ent-MobPirateT1ECutlassTimed = { ent-MobPirateT1ECutlass }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1ECutlass.desc }
ent-MobPirateT1EPickaxeTimed = { ent-MobPirateT1EPickaxe }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1EPickaxe.desc }
ent-MobPirateT1PlasmaCutterTimed = { ent-MobPirateT1PlasmaCutter }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1PlasmaCutter.desc }
ent-MobPirateT1ScrapRifleTimed = { ent-MobPirateT1ScrapRifle }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1ScrapRifle.desc }
ent-MobPirateT1RifleTimed = { ent-MobPirateT1Rifle }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1Rifle.desc }
ent-MobPirateT1BorgAssaultTimed = { ent-MobPirateT1BorgAssault }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1BorgAssault.desc }
ent-MobPirateT1BorgMiningTimed = { ent-MobPirateT1BorgMining }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1BorgMining.desc }
ent-MobPirateT1BorgDerelictTimed = { ent-MobPirateT1BorgDerelict }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1BorgDerelict.desc }
ent-MobPirateT2CutlassTimed = { ent-MobPirateT2Cutlass }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT2Cutlass.desc }
ent-MobPirateT2ECutlassTimed = { ent-MobPirateT2ECutlass }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT2ECutlass.desc }
ent-MobPirateT2SledgehammerTimed = { ent-MobPirateT2Sledgehammer }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT2Sledgehammer.desc }
ent-MobPirateT2GauntletDrillTimed = { ent-MobPirateT2GauntletDrill }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT2GauntletDrill.desc }
ent-MobPirateT2ShotgunTimed = { ent-MobPirateT2Shotgun }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT2Shotgun.desc }
ent-MobPirateT2ShotgunMiniBallTimed = { ent-MobPirateT2ShotgunMiniBall }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT2ShotgunMiniBall.desc }
ent-MobPirateT2ShotgunGlassTimed = { ent-MobPirateT2ShotgunGlass }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT2ShotgunGlass.desc }
ent-MobPirateT2MusketTimed = { ent-MobPirateT2Musket }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT2Musket.desc }
ent-MobPirateT2MusketEMPTimed = { ent-MobPirateT2MusketEMP }
.suffix = Деспавн 20мин, ЭМИ
.desc = { ent-MobPirateT2Musket.desc }
ent-MobPirateT2MusketBlastTimed = { ent-MobPirateT2MusketBlast }
.suffix = Деспавн 20мин, Взрыв
.desc = { ent-MobPirateT2Musket.desc }
ent-MobPirateT2MusketFragTimed = { ent-MobPirateT2MusketFrag }
.suffix = Деспавн 20мин, Осколки
.desc = { ent-MobPirateT2Musket.desc }
ent-MobPirateT2MusketPenetratorTimed = { ent-MobPirateT2MusketPenetrator }
.suffix = Деспавн 20мин, Бронебой
.desc = { ent-MobPirateT2Musket.desc }
ent-MobPirateT2MachineCannonTimed = { ent-MobPirateT2MachineCannon }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT2MachineCannon.desc }
ent-MobPirateT2DoubleFlintlockTimed = { ent-MobPirateT2DoubleFlintlock }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT2DoubleFlintlock.desc }
ent-MobPirateT2DoubleRevolverTimed = { ent-MobPirateT2DoubleRevolver }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT2DoubleRevolver.desc }
ent-MobPirateT2RevolverCutlassTimed = { ent-MobPirateT2RevolverCutlass }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT2RevolverCutlass.desc }
ent-MobPirateT2GrapeshotTimed = { ent-MobPirateT2Grapeshot }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT2Grapeshot.desc }
ent-MobPirateT2CannoballTimed = { ent-MobPirateT2Cannoball }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT2Cannoball.desc }
ent-MobPirateT2MechTimed = { ent-MobPirateT2Mech }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT2Mech.desc }
ent-MobPirateT2JuggernautTimed = { ent-MobPirateT2Juggernaut }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT2Juggernaut.desc }
ent-MobPirateT3BolaTimed = { ent-MobPirateT3Bola }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT3Bola.desc }
ent-MobPirateT3StunTimed = { ent-MobPirateT3Stun }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT3Stun.desc }
ent-MobPirateT3Shield1984Timed = { ent-MobPirateT3Shield1984 }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT3Shield1984.desc }
ent-MobPirateT3ShieldSMGTimed = { ent-MobPirateT3ShieldSMG }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT3ShieldSMG.desc }
ent-MobPirateT3DMRTimed = { ent-MobPirateT3DMR }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT3DMR.desc }
ent-MobPirateT3Rifle2Timed = { ent-MobPirateT3Rifle2 }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT3Rifle2.desc }
ent-MobPirateT3RifleTimed = { ent-MobPirateT3Rifle }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT3Rifle.desc }
ent-MobPirateT3LaserSMGTimed = { ent-MobPirateT3LaserSMG }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT3LaserSMG.desc }
ent-MobPirateT3LaserCannonTimed = { ent-MobPirateT3LaserCannon }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT3LaserCannon.desc }
ent-MobPirateT3RevolverTimed = { ent-MobPirateT3Revolver }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT3Revolver.desc }
ent-MobPirateT3SMGTimed = { ent-MobPirateT3SMG }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT3SMG.desc }
ent-MobPirateT3L6Timed = { ent-MobPirateT3L6 }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT3L6.desc }
ent-MobPirateT3TachankaTimed = { ent-MobPirateT3Tachanka }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT3Tachanka.desc }
ent-MobPirateT3RPDTimed = { ent-MobPirateT3RPD }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT3RPD.desc }
ent-MobPirateT3GrenadierTimed = { ent-MobPirateT3Grenadier }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT3Grenadier.desc }
ent-MobPirateT3InfiltratorTimed = { ent-MobPirateT3Infiltrator }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT3Infiltrator.desc }
ent-MobPirateT3SniperTimed = { ent-MobPirateT3Sniper }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT3Sniper.desc }
ent-MobPirateT1BossTimed = { ent-MobPirateT1Boss }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT1Boss.desc }
ent-MobPirateT2BossTimed = { ent-MobPirateT2Boss }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT2Boss.desc }
ent-MobPirateT3BossTimed = { ent-MobPirateT3Boss }
.suffix = Деспавн 20мин
.desc = { ent-MobPirateT3Boss.desc }

View file

@ -2,7 +2,7 @@ ent-SmokeScreenImplanter = { ent-Implanter }
.desc = { ent-BaseImplantOnlyImplanterSyndi.desc }
.suffix = Дым
ent-CreepyLaughImplanter = { ent-Implanter }
.suffix = Санрайз, Смех
.suffix = Смех ужаса
.desc = { ent-BaseImplantOnlyImplanterSyndi.desc }
ent-RadioImplanterFreelance = { ent-Implanter }
.suffix = Фриланс, радио
@ -11,5 +11,5 @@ ent-DeathRattleImplanterFreelance = { ent-Implanter }
.suffix = Фриланс, предсмертный хрип
.desc = { ent-BaseImplantOnlyImplanterSyndi.desc }
ent-ScramImplanterProto = { ent-ScramImplanter }
.suffix = прототип
.suffix = прототип побега
.desc = { ent-ScramImplanter.desc }

View file

@ -58,7 +58,7 @@ ent-WeaponIAR52Borg = IAR-52 киборга
.desc = Высокоскорострельный ПП, оборудован универсальным разъёмом для других типов пистолетных магазинов.
ent-HypoBorgSaboteurSyndicate = гипоспрей киборга диверсанта
.desc = { ent-BaseItem.desc }
ent-HandheldCrewMonitorBorg = портативный монитор экипажа киборга
ent-HandheldEmergencyCrewMonitorBorg = портативный монитор экипажа киборга
.desc = { ent-HandheldCrewMonitor.desc }
ent-WeaponPlasmaCutterBorg = плазменный резак борга
.desc = Инструмент для добычи, стреляющий плазменными зарядами с малым уроном на близком расстоянии. Этот модифицирован для использования киборгами и имеет автоматический режим стрельбы.

View file

@ -1,6 +1,6 @@
ent-BaseMagazinePistolCaselessRifleExtended = расширенный пистолетный магазин (.20 безгильзовый)
ent-BaseMagazinePistolCaselessRifleExtended = расширенный пистолетный магазин (.25 безгильзовый)
.desc = { ent-BaseMagazinePistolCaselessRifle.desc }
ent-MagazineCannonBallMini = чемодан с ядрами
.desc = Чемодан для аккуратного хранения ядер от пиратской пушки с ленточной подачей.
ent-MagazinePistolSubMachineGunCaselessExtended = Расширенный магазин (.20 безгильзовые)
ent-MagazinePistolSubMachineGunCaselessExtended = Расширенный магазин (.25 безгильзовые)
.desc = { ent-BaseMagazineLightRifle.desc }

View file

@ -14,23 +14,24 @@ ent-MagazineBauer127Frag = магазин (15мм осколочные)
.desc = Большой магазин для тяжёлого оружия, вмещает патроны 15x115 мм осколочного действия.
ent-MagazineBauer127Emp = магазин (15мм ЭМИ)
.desc = Большой магазин для тяжёлого оружия, вмещает патроны 15x115 мм ЭМИ действия.
ent-BaseMagazineDragunov = магазин Драгунова (7,62ммR)
ent-BaseMagazineDragunov = магазин (7,62ммR)
.desc = { ent-BaseItem.desc }
ent-MagazineDragunovExtended = магазин Драгунова (7,62ммR)
.suffix = Восход
ent-MagazineDragunovExtended = магазин (7,62ммR)
.desc = { ent-BaseMagazineDragunov.desc }
ent-MagazineDragunov = магазин Драгунова (7,62ммR)
.suffix = Восход
ent-MagazineDragunovSP = магазин (7,62ммR SP)
.desc = { ent-BaseMagazineDragunov.desc }
ent-MagazineDragunovHP = магазин (7,62ммR HP)
.desc = { ent-BaseMagazineDragunov.desc }
ent-MagazineDragunovFMJ = магазин (7,62ммR FMJ)
.desc = { ent-BaseMagazineDragunov.desc }
ent-MagazineDragunov = магазин (7,62ммR)
.desc = { ent-BaseMagazineDragunov.desc }
ent-MagazineDragunovEmpty = магазин (7,62ммR любой)
.suffix = Восход, пустой
.suffix = пустой
.desc = { ent-MagazineDragunov.desc }
ent-MagazineDragunovIncendiary = магазин (7,62ммR зажигательные)
.suffix = Восход
.desc = { ent-MagazineDragunov.desc }
ent-MagazineDragunovPractice = магазин (7,62ммR учебные)
.suffix = Восход
.desc = { ent-BaseMagazineDragunov.desc }
ent-MagazineDragunovUranium = магазин (7,62ммR урановые)
.suffix = Восход
.desc = { ent-BaseMagazineDragunov.desc }

View file

@ -27,7 +27,7 @@ ent-WeaponPistolDeagleMetus = Desert Eagle Metus
.desc = На рукояти потёртости от старых рук, тяжёлое и уверенное, как приговор, что не раз выносился без разбирательств.
.suffix = Санрайз
ent-WeaponRevolverSpearhead = Авангард
.desc = Громоздкий авторевольвер револьвер, который иногда используется штурмовыми отрядами и офицерами спецподразделений, а также гражданскими правоохранительными органами. Стреляет патронами .45 Магнум.
.desc = Громоздкий авторевольвер, который иногда используется штурмовыми отрядами и офицерами спецподразделений, а также гражданскими правоохранительными органами. Стреляет патронами .45 Магнум.
ent-WeaponRevolverSpearheadBlack = Авангард
.desc = { ent-WeaponRevolverSpearhead.desc }
ent-WeaponPistolM1984 = D1984

View file

@ -78,6 +78,12 @@ ent-CableDet = взрывной шнур
ent-CableDetStack = взрывной шнур
.desc = Взрывной шнур, чтобы убрать всё, что стоит на пути.
.suffix = Полный
ent-CableDetStack10 = { ent-CableDetStack }
.desc = { ent-CableDetStack.desc }
.suffix = 10
ent-CableDetStack1 = { ent-CableDetStack }
.desc = { ent-CableDetStack.desc }
.suffix = 1
ent-ParcelWrapAdmeme = блюспейс-обёртка
.desc = Бумага для упаковки предметов при транспортировке. Эта, кажется, способна прятать внутри подозрительно много пространства.

View file

@ -2,6 +2,7 @@
guide-entry-ammunition = Боеприпасы
guide-entry-improvised-grenade-casing = Самодельная граната (корпус)
guide-entry-detonator-triggers = Взрывной шнур и триггеры
guide-entry-disease = Разумная болезнь
guide-entry-expeditions = Экспедиции

View file

@ -4,7 +4,7 @@ research-technology-basic-cyberlimbs = Базовые кибер-конечно
research-technology-implant-extractor-safety = Безопасное извлечение имплантов
research-technology-implant-extractor = Извлечение имплантов
research-technology-advanced-surgery = Продвинутая хирургия
research-technology-hanheld-crew-monitor = Портативный мониторинг
research-technology-hanheld-crew-monitor = Портативный мониторинг Пульс-Гард
research-technology-mechanized-medical-treatment = Механизированное лечение
research-technology-handcraft-nvd = Кустарные ПНВ
research-technology-basic-nvd = Продвинутое ПНВ

View file

@ -33,6 +33,10 @@ uplink-magazine-bauer127-extended-desc = Магазин для Bauer127. Сод
uplink-magazine-dl6902-name = Короб-магазин DL6902 (7,62мм)
uplink-magazine-dl6902-desc = Магазин для Dl6902. Содержит 200 патронов калибра 7,62х39мм.
uplink-magazine-dragunov-desc = Магазин для Драгунова. Содержит 10 патронов калибра 7,62х54R.
uplink-magazine-dragunov-sp-desc = Магазин для Драгунова. Содержит 10 патронов SP калибра 7,62х54R.
uplink-magazine-dragunov-hp-desc = Магазин для Драгунова. Содержит 10 патронов HP калибра 7,62х54R.
uplink-magazine-dragunov-fmj-desc = Магазин для Драгунова. Содержит 10 патронов FMJ калибра 7,62х54R.
uplink-magazine-dragunov-incendiary-desc = Магазин для Драгунова. Содержит 10 зажигательных патронов калибра 7,62х54R.
uplink-magazine-dragunov-extended-desc = Магазин для Драгунова. Содержит 20 патронов калибра 7,62х54R.
uplink-magazine-bulldog-uraniumslug-desc = Барабанный магазин на 8 урановых пулевых патронов. Совместим с "Бульдогом".
uplink-magazine-bulldog-uranium-desc = Барабанный магазин на 8 патронов урановой дроби, легко поражает экипаж нанотрейзен лучевой болезнью.
@ -46,10 +50,10 @@ uplink-skm24-ammo-desc = Винтовочный магазин на 30 патр
uplink-estoc-ammo-name = Магазин для винтовки (.20)
uplink-estoc-ammo-desc = Магазин на 25 патронов. Совместим с Эсток.
## Weapon (Sunrise)
uplink-c40r-name = C-40r
uplink-c40r-desc = Безгильзовый пистолет-пулемёт C-40r, великолепно работает на ближней дистанции.
uplink-c40r-bundle-name = Набор "C-40r"
uplink-c40r-bundle-desc = Включает C-40r вместе с несколькими магазинами для быстрой перестрелки.
uplink-c40r-bundle-desc = Более старый: Культовый пистолет-пулемет C-40r в комплекте с тремя магазинами тяжелого калибра.
uplink-c40r-name = C-40r биокодированный
uplink-c40r-desc = Культовый пистолет-пулемет C-40r в комплекте с коробкой стандартных патронов 40-го калибра.
uplink-magazine-127-desc = Магазин Bauer SR-127 на 7 патронов предназначеных для уничтожения мехов, киборгов или стркутур таких как решетки и окна, пары попаданий достаточно для пролома стены.
uplink-magazine-127pen-desc = Магазин Bauer SR-127 на 7 патронов предназначеных для ликвидации защищенных противников а так же целей за укрытиями и стенами, прекрасно сочетаются с термальным зрением.
@ -202,8 +206,8 @@ uplink-smoke-screen-implanter-name = Имплантер Дымовой Заве
uplink-smoke-screen-implanter-desc = Создает небольшое облако дыма, в котором вы можете скрыться. Можно использовать до трех раз, прежде чем у вас закончится газ.
uplink-creepy-laugh-implanter-name = Имплантер Жуткого Смеха
uplink-creepy-laugh-implanter-desc = Аудиоимплант, воспроизводящий фирменный смех синди-киборга. Раздражает, пугает, стиль гарантирован.
uplink-scram-implanter-proto-name = Имплантер Прототип-Побег
uplink-scram-implanter-proto-desc = Имплант на 2 заряда с огромной перезарядкой в 20 минут. Телепортирует вас в крупном радиусе, пытается перенести на свободную клетку, иногда может сбоить. Он точно безопасен?
uplink-scram-implanter-proto-name = Имплантер Прототип-Побега
uplink-scram-implanter-proto-desc = Имплант на 1 заряд и огромной перезарядкой в 15 минут. Телепортирует вас в крупном радиусе, пытается перенести на свободную клетку, иногда может сбоить. Он точно безопасен?
## Ammo Kits and Bundle

View file

@ -47,6 +47,10 @@ messenger-emoji-favorite-title = Избранные
messenger-emoji-favorite-hint = ПКМ по общему списку для добавления.
ПКМ по избранным для удаления.
messenger-emoji-all-title = Все смайлики
messenger-notification-message = Новое сообщение от { $name }
messenger-group-notification-message = Новое сообщение в группе { $name }
messenger-invite-notification-message = Вас пригласили в группу { $name }
messenger-user-unknown = Неизвестно
messenger-system-name = Система
messenger-leave-group = Выйти из группы

View file

@ -1,4 +1,4 @@
# also used in MakeGhostRuleWindow and MakeGhostRoleCommand
# also used in MakeGhostRuleWindow and MakeGhostRoleCommand
ghost-role-component-default-rules =
Вы не помните ничего из своей предыдущей жизни, если администратор не сказал вам обратное.
Вы не помните ничего из своей предыдущей жизни и не помните ничего из того, что узнали, будучи призраком.
@ -292,3 +292,9 @@ ghost-role-information-artifact-description = Осуществляйте сво
ghost-role-information-syndie-assaultborg-name = Штурмовой киборг Синдиката
ghost-role-information-syndie-assaultborg-description = Ядерным оперативникам требуется подкрепление. Вы, хладнокремниевая машина для убийств, будете им помогать. Больше дакки!
ghost-role-information-expedition-pirate-rules = Вы [color=red][bold]Умный Пират[/bold][/color] более умный ваших товарищей, не дайте утилизаторам вас ограбить. Вам [color=red][bold]запрещено[/bold][/color] покидать комплекс и саму экспедицию на шаттле.
ghost-role-information-rules-expedition-pirate-antagonist =
Вы — [color=red][bold]NPC-пират[/bold][/color]. Ваши намерения вредят станции и её экипажу.
Вы должны [bold]защищать[/bold] свою территорию и сотрудничать с другими пиратами.
Вам [color=green][bold]можно[/bold][/color] покинуть экспедицию на шаттле, но [color=red][bold]только[/bold][/color] если [color=orange][bold]утилизаторы[/bold][/color] (живые или мёртвые) находятся на шаттле.
Помните, что вы как [color=red][bold]NPC-пират[/bold][/color] [bold]ограничены[/bold] временем: через [color=red][bold]20 минут[/bold][/color] вас автоматически переместят в наблюдателя.
Вы не помните ничего из своей предыдущей жизни и не помните ничего из того, что узнали, будучи призраком.

View file

@ -41,3 +41,7 @@ news-write-ui-richtext-tooltip =
{ "[bullet/]bullet[/color]" }
news-pda-notification-header = Новая новостная статья
news-publish-admin-announcement = { $actor } опубликовал(а) новостную статью { $title } за авторством { $author }
news-write-ui-photos-label = Фотографии:
news-write-ui-add-photo-text = Добавить фото
news-write-ui-select-photo-title = Выберите фото с КПК
news-write-ui-no-photos = На КПК нет фотографий

View file

@ -211,16 +211,10 @@ uplink-sniper-bundle-name = Набор снайпера
uplink-sniper-bundle-desc = Неприметный чемодан, в котором находятся Христов, 10 запасных патронов и удобная маскировка, маскировка обладает умеренной базовой защитой.
uplink-c20r-bundle-name = Набор "C-20r"
uplink-c20r-bundle-desc = Старый добрый: Классический пистолет-пулемёт C-20r в комплекте с тремя магазинами.
uplink-c40r-bundle-name = Набор "C-40r"
uplink-c40r-bundle-desc = Более старый: Культовый пистолет-пулемет C-40r в комплекте с тремя магазинами тяжелого калибра.
uplink-c40r-name = C-40r биокодированный
uplink-c40r-desc = Культовый пистолет-пулемет C-40r в комплекте с коробкой стандартных патронов 40-го калибра.
uplink-bulldog-bundle-name = Набор "Бульдог"
uplink-bulldog-bundle-desc = Простой и надёжный: содержит популярный дробовик Бульдог, барабан пуль и три барабана дроби а так же термальный визор.
uplink-grenade-launcher-china-lake-name = Набор "China-Lake"
uplink-grenade-launcher-china-lake-desc = Старый гранатомёт China-Lake и сумкой запасных снарядов. Может стрелять как контактными, так и неконтактными гранатами.
uplink-grenade-launcher-m79-bundle-name = Набор "М79"
uplink-grenade-launcher-m79-bundle-desc = Набор однозарядного гранатомёта вместе с сумкой запасных снарядов, чтобы начать гранатомётную вечеринку в джунглях.
uplink-grenade-launcher-gl70-name = Набор "GL-70"
uplink-grenade-launcher-gl70-desc = Набор с многозарядным автоматическим гранатомётом с барабаном на 6 снарядов и сумкой запасных снарядов. Может стрелять как контактными, так и неконтактными гранатами.
uplink-l6-saw-bundle-name = Набор "L6 Saw"

View file

@ -73,6 +73,7 @@ entities:
- type: SpreaderGrid
- type: Shuttle
dampingModifier: 0.25
- type: AssaultOpsShuttle
- type: ImplicitRoof
- type: GridPathfinding
- type: Gravity

View file

@ -530,6 +530,7 @@ entities:
- type: OccluderTree
- type: Shuttle
dampingModifier: 0.25
- type: NukeOpsShuttle
- type: RadiationGridResistance
- type: GravityShake
shakeTimes: 10

View file

@ -579,6 +579,7 @@ entities:
- type: OccluderTree
- type: Shuttle
dampingModifier: 0.25
- type: NukeOpsShuttle
- type: RadiationGridResistance
- type: GravityShake
shakeTimes: 10

View file

@ -655,15 +655,7 @@ entities:
- type: GridPathfinding
- type: ImplicitRoof
- type: ExplosionAirtightGrid
- proto: AirlockExternalGlassNukeopLocked
entities:
- uid: 344
components:
- type: Transform
rot: 1.5707963267948966 rad
pos: -8.5,2.5
parent: 1
- proto: AirlockExternalNukeopLocked
- proto: AirlockExternalGlassCargoLocked
entities:
- uid: 83
components:
@ -739,6 +731,12 @@ entities:
rot: -1.5707963267948966 rad
pos: -4.5,-6.5
parent: 1
- uid: 344
components:
- type: Transform
rot: 1.5707963267948966 rad
pos: -8.5,2.5
parent: 1
- proto: AirlockSalvageLocked
entities:
- uid: 4

View file

@ -88,6 +88,7 @@ entities:
- type: SpreaderGrid
- type: Shuttle
dampingModifier: 0.25
- type: SecurityShuttle
- type: GridPathfinding
- type: Gravity
gravityShakeSound: !type:SoundPathSpecifier

View file

@ -148,10 +148,9 @@
maxCharges: 3
# Sunrise-Start
- type: AutoRecharge
rechargeDuration: 600
rechargeDuration: 300
# Sunrise-End
- type: Action
useDelay: 5 # Sunrise-Edit
checkCanInteract: false
itemIconStyle: BigAction
priority: -20

View file

@ -122,7 +122,7 @@
id: LockerFillParamedic
table: !type:AllSelector
children:
- id: HandheldCrewMonitor # Sunrise-Edit
- id: HandheldEmergencyCrewMonitor # Sunrise-Edit
- id: ClothingOuterHardsuitVoidParamed
- id: ClothingOuterCoatParamedicWB
- id: ClothingHeadHatParamedicsoft

View file

@ -19,3 +19,4 @@
ClothingEyesGlassesSunglasses: 2
contrabandInventory:
ToyFigurineBartender: 1
BoxBeanbag: 1 # Sunrise Edit

View file

@ -11,4 +11,10 @@
contrabandInventory:
FoodBakedDumplings: 2
FoodSoupMiso: 2
# Sunrise start
FoodRiceBoiled: 2
FoodRiceEgg: 2
FoodRicePork: 2
# Sunrise end
# rice?
# Yes for rice

View file

@ -25,4 +25,11 @@
FoodBoxDonkpocket: 1
FoodFrozenSandwich: 2
FoodFrozenSandwichStrawberry: 2
# Sunrise Start
FoodMeatFish: 3
FoodMeatHuman: 3
FoodMeatClown: 2
FoodMeatAnomaly: 1
emaggedInventory:
FoodMeatDragon: 2
# Sunrise End

View file

@ -18,3 +18,11 @@
ClothingHeadsetSecurity: 2
contrabandInventory:
ToyFigurineDetective: 1
# Sunrise Start
ClothingEyesBinoclardLenses: 1
ClothingHandsGlovesAerostatic: 1
ClothingNeckHorrific: 1
ClothingOuterAerostaticBomberJacketArmored: 1
ClothingShoesGreenLizardskin: 1
ClothingUniformJumpsuitSuperstarCop: 1
# Sunrise End

View file

@ -12,7 +12,7 @@
ClothingUniformJumpsuitLawyerGood: 1
ClothingUniformJumpskirtLawyerGood: 1
ClothingShoesBootsLaceup: 2
ClothingHeadsetIAA: 2
ClothingHeadsetLawyer: 2 # Sunrise Edit no more IAA headset abuse
ClothingNeckLawyerbadge: 2
BriefcaseBrown: 2
ClothingHandsGlovesColorWhite: 1

View file

@ -12,3 +12,6 @@
WeaponProtoKineticAccelerator: 4
contrabandInventory:
PlushieCarp: 1
PlushieMagicarp: 1 # Sunrise-Edit
PlushieHolocarp: 1 # Sunrise-Edit
# no rainbow carp((

View file

@ -19,9 +19,9 @@
ToyFigurineFootsoldier: 1
ToyFigurineNukieElite: 1
ToyFigurineNukieCommander: 1
ClothingUniformJumpskirtTacticalMaid: 5
ClothingHandsTacticalMaidGloves: 5
emaggedInventory:
ClothingOuterCoatSyndieCapArmored: 1
ClothingOuterWinterSyndieCapArmored: 1
ClothingHeadHatTacticalMaidHeadband: 5
ClothingUniformJumpskirtTacticalMaid: 5 # Sunrise-edit
ClothingHandsTacticalMaidGloves: 5 # Sunrise-edit

View file

@ -34,9 +34,6 @@
- type: ArmorSparkEffect #starlight
- type: StaminaResistance # Sunrise-Add
damageCoefficient: 0.9 # Sunrise-Add
- type: Item
shape:
- 0,0,2,2
# Sunrise-End
#Standard armor vest, allowed for security and bartenders

View file

@ -1165,11 +1165,6 @@
- WantedListCartridge
- MedTekCartridge
- AstroNavCartridge
# Sunrise-Start
- NavigatorCartridge
- MessengerCartridge
- PhotoCartridge
# Sunrise-End
- type: Tag # Ignore Chameleon tags
tags:
- DoorBumpOpener

Some files were not shown because too many files have changed in this diff Show more