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

This commit is contained in:
Vigers Ray 2025-09-11 19:03:21 +03:00
commit ed619598f1
88 changed files with 9293 additions and 82 deletions

53
.github/workflows/publish-test.yml vendored Normal file
View file

@ -0,0 +1,53 @@
name: Publish Test
concurrency:
group: publish
cancel-in-progress: true
on:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.x'
- name: Install Dependencies
run: pip install pyyaml requests
- uses: actions/checkout@v4.2.2
with:
submodules: 'recursive'
- name: Setup .NET Core
uses: actions/setup-dotnet@v4.1.0
with:
dotnet-version: 9.0.x
- name: Get Engine Tag
run: |
cd RobustToolbox
git fetch --depth=1
- name: Install dependencies
run: dotnet restore
- name: Build Packaging
run: dotnet build Content.Packaging --configuration Release --no-restore /m
- name: Package server
run: dotnet run --project Content.Packaging server --platform win-x64 --platform linux-x64 --platform osx-x64 --platform linux-arm64
- name: Package client
run: dotnet run --project Content.Packaging client --no-wipe-release
- name: Publish version
run: Tools/publish_multi_request.py --fork-id sunrise_station_test
env:
PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }}
GITHUB_REPOSITORY: ${{ vars.GITHUB_REPOSITORY }}

View file

@ -0,0 +1,31 @@
using Content.Shared.Administration;
namespace Content.Client.Administration.Systems;
/// <summary>
/// Client system for handling admin who requests
/// </summary>
public sealed class AdminWhoSystem : EntitySystem
{
public event Action<List<AdminWhoEntry>>? OnAdminWhoUpdate;
public override void Initialize()
{
base.Initialize();
SubscribeNetworkEvent<AdminWhoResponseEvent>(OnAdminWhoResponse);
}
/// <summary>
/// Request the list of online administrators from the server
/// </summary>
public void RequestAdminWho()
{
RaiseNetworkEvent(new RequestAdminWhoEvent());
}
private void OnAdminWhoResponse(AdminWhoResponseEvent args, EntitySessionEventArgs session)
{
OnAdminWhoUpdate?.Invoke(args.Admins);
}
}

View file

@ -12,13 +12,25 @@ namespace Content.Client.Administration.Systems
[Dependency] private readonly IGameTiming _timing = default!;
public event EventHandler<BwoinkTextMessage>? OnBwoinkTextMessageRecieved;
public event EventHandler<BwoinkCooldownMessage>? OnBwoinkCooldownReceived;
private (TimeSpan Timestamp, bool Typing) _lastTypingUpdateSent;
public override void Initialize()
{
base.Initialize();
SubscribeNetworkEvent<BwoinkCooldownMessage>(OnBwoinkCooldownMessage);
}
protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySessionEventArgs eventArgs)
{
OnBwoinkTextMessageRecieved?.Invoke(this, message);
}
private void OnBwoinkCooldownMessage(BwoinkCooldownMessage message, EntitySessionEventArgs eventArgs)
{
OnBwoinkCooldownReceived?.Invoke(this, message);
}
public void Send(NetUserId channelId, string text, bool playSound, bool adminOnly)
{
// Reuse the channel ID as the 'true sender'.

View file

@ -0,0 +1,65 @@
using Content.Client.Administration.Systems;
using JetBrains.Annotations;
using Robust.Client.UserInterface.Controllers;
namespace Content.Client.Administration.UI.Bwoink
{
[UsedImplicitly]
public sealed class AdminWhoUIController : UIController, IOnSystemChanged<AdminWhoSystem>
{
private AdminWhoWindow? _dialog;
private AdminWhoSystem? _adminWhoSystem;
protected override string SawmillName => "c.c.admin.adminwho";
public void OnSystemLoaded(AdminWhoSystem system)
{
_adminWhoSystem = system;
_dialog = new AdminWhoWindow();
_dialog.Initialize(system);
}
public void OnSystemUnloaded(AdminWhoSystem system)
{
if (_dialog != null)
{
_dialog.Close();
_dialog.Uninitialize();
_dialog.Dispose();
_dialog = null;
}
_adminWhoSystem = null;
}
public void Open()
{
if (_dialog == null)
{
if (_adminWhoSystem == null)
return;
_dialog = new AdminWhoWindow();
_dialog.Initialize(_adminWhoSystem);
}
_dialog.OpenCentered();
_dialog.RefreshAdminList();
}
public void Toggle()
{
if (_dialog?.IsOpen == true)
Close();
else
Open();
}
public void Close()
{
_dialog?.Close();
}
}
}

View file

@ -0,0 +1,23 @@
<ui:FancyWindow xmlns="https://spacestation14.io"
xmlns:ui="clr-namespace:Content.Client.UserInterface.Controls"
VerticalExpand="True" HorizontalExpand="True"
Title="{Loc admin-who-title}"
SetSize="400 300"
Resizable="False">
<PanelContainer StyleClasses="BackgroundDark">
<BoxContainer Orientation="Vertical" Margin="8">
<Label Name="LoadingLabel" Access="Public" Text="{Loc admin-who-loading}" StyleClasses="LabelText"
HorizontalAlignment="Center" Visible="False" />
<Label Name="NoAdminsLabel" Access="Public" Text="{Loc admin-who-no-admins}" StyleClasses="LabelText"
HorizontalAlignment="Center" Visible="False" />
<ScrollContainer VerticalExpand="True" HorizontalExpand="True" HScrollEnabled="False">
<BoxContainer Orientation="Vertical" VerticalExpand="True">
<BoxContainer Orientation="Vertical" Name="AdminsContainer" Access="Public" VerticalExpand="True" />
</BoxContainer>
</ScrollContainer>
<BoxContainer Orientation="Horizontal" HorizontalAlignment="Center" Margin="0 8 0 0">
<Button Name="RefreshButton" Access="Public" Text="{Loc admin-who-refresh}" />
</BoxContainer>
</BoxContainer>
</PanelContainer>
</ui:FancyWindow>

View file

@ -0,0 +1,102 @@
using System.Text;
using Content.Client.Administration.Managers;
using Content.Client.Administration.Systems;
using Content.Client.UserInterface.Controls;
using Content.Shared.Administration;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
namespace Content.Client.Administration.UI.Bwoink
{
[GenerateTypedNameReferences]
public sealed partial class AdminWhoWindow : FancyWindow
{
private AdminWhoSystem? _adminWhoSystem;
public AdminWhoWindow()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
RefreshButton.OnPressed += _ => RefreshAdminList();
CloseButton.OnPressed += _ => Close();
}
public void Initialize(AdminWhoSystem system)
{
_adminWhoSystem = system;
_adminWhoSystem.OnAdminWhoUpdate += OnAdminListReceived;
// Request the list when the window is initialized
RefreshAdminList();
}
public void Uninitialize()
{
if (_adminWhoSystem != null)
{
_adminWhoSystem.OnAdminWhoUpdate -= OnAdminListReceived;
_adminWhoSystem = null;
}
}
public void RefreshAdminList()
{
AdminsContainer.RemoveAllChildren();
NoAdminsLabel.Visible = false;
LoadingLabel.Visible = true;
_adminWhoSystem?.RequestAdminWho();
}
private void OnAdminListReceived(List<AdminWhoEntry> admins)
{
LoadingLabel.Visible = false;
if (admins.Count == 0)
{
NoAdminsLabel.Visible = true;
return;
}
NoAdminsLabel.Visible = false;
// Add each admin
foreach (var admin in admins)
{
var adminText = new StringBuilder();
adminText.Append(admin.Name);
if (!string.IsNullOrEmpty(admin.Title))
adminText.Append($": [{admin.Title}]");
if (admin.IsStealth)
adminText.Append(" (S)");
if (admin.IsAfk)
adminText.Append(" [AFK]");
var adminLabel = new Label
{
Text = adminText.ToString(),
StyleClasses = { "LabelText" },
HorizontalAlignment = Control.HAlignment.Left,
Margin = new Thickness(16, 2, 8, 2)
};
AdminsContainer.AddChild(adminLabel);
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
Uninitialize();
}
base.Dispose(disposing);
}
}
}

View file

@ -16,6 +16,8 @@
<CheckBox Name="PlaySound" Access="Public" Text="{Loc 'admin-bwoink-play-sound'}" Pressed="True" />
<Control HorizontalExpand="True" MinWidth="5" />
<Button Visible="True" Name="PopOut" Access="Public" Text="{Loc 'admin-logs-pop-out'}" StyleClasses="OpenBoth" HorizontalAlignment="Left" />
<Control HorizontalExpand="True" MinWidth="5" />
<Button Visible="True" Name="AdminWho" Access="Public" Text="{Loc 'admin-who-button'}" StyleClasses="OpenBoth" HorizontalAlignment="Left" />
<Control HorizontalExpand="True" />
<Button Visible="False" Name="Bans" Text="{Loc 'admin-player-actions-bans'}" StyleClasses="OpenRight" />
<Button Visible="False" Name="Notes" Text="{Loc 'admin-player-actions-notes'}" StyleClasses="OpenBoth" />

View file

@ -203,6 +203,14 @@ namespace Content.Client.Administration.UI.Bwoink
{
uiController.PopOut();
};
// Sunrise-Start
AdminWho.OnPressed += _ =>
{
var ctrl = _ui.GetUIController<AdminWhoUIController>();
ctrl.Toggle();
};
// Sunrise-End
}
public void OnBwoink(NetUserId channel)

View file

@ -3,8 +3,14 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Orientation="Vertical"
HorizontalExpand="True">
<RichTextLabel Name="AHelpDescLabel" Text="{Loc 'help-choice-ahelp-desc-label'}" Visible="False" Access="Public"/> <!-- Sunrise-Edit -->
<OutputPanel Name="TextOutput" VerticalExpand="true" Access="Public" /> <!-- Sunrise-Edit -->
<RichTextLabel Name="TypingIndicator" Access="Public" />
<HistoryLineEdit Name="SenderLineEdit" />
<RichTextLabel Name="RelayedToDiscordLabel" Access="Public" Visible="False" />
<!-- Sunrise-Start -->
<BoxContainer Orientation="Horizontal" Margin="0 4 0 0">
<Button Name="AdminWhoButton" Access="Public" Text="{Loc 'admin-who-button'}" Visible="False" HorizontalAlignment="Right" />
</BoxContainer>
<!-- Sunrise-End -->
</BoxContainer>

View file

@ -1,15 +1,19 @@
using Content.Shared.Administration;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
using System.Threading;
namespace Content.Client.Administration.UI.Bwoink
{
[GenerateTypedNameReferences]
public sealed partial class BwoinkPanel : BoxContainer
{
[Dependency] private readonly IUserInterfaceManager _ui = default!;
private readonly Action<string> _messageSender;
public int Unread { get; private set; } = 0;
@ -20,11 +24,14 @@ namespace Content.Client.Administration.UI.Bwoink
// Sunrise-Start
private DateTime? _lastDateHeader;
public bool LoadDb { get; set; }
private DateTime _cooldownEnd = DateTime.MinValue;
private CancellationTokenSource? _cooldownCancellationTokenSource;
// Sunrise-End
public BwoinkPanel(Action<string> messageSender)
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this); // Sunrise-Edit
var msg = new FormattedMessage();
msg.PushColor(Color.LightGray);
@ -41,6 +48,15 @@ namespace Content.Client.Administration.UI.Bwoink
};
SenderLineEdit.OnTextEntered += Input_OnTextEntered;
SenderLineEdit.OnTextChanged += Input_OnTextChanged;
// Sunrise-Start
AdminWhoButton.OnPressed += _ =>
{
var ctrl = _ui.GetUIController<AdminWhoUIController>();
ctrl.Toggle();
};
// Sunrise-End
UpdateTypingIndicator();
}
@ -49,6 +65,10 @@ namespace Content.Client.Administration.UI.Bwoink
if (string.IsNullOrWhiteSpace(args.Text))
return;
// Check if we're still on cooldown
if (DateTime.Now < _cooldownEnd)
return;
_messageSender.Invoke(args.Text);
SenderLineEdit.Clear();
}
@ -107,7 +127,7 @@ namespace Content.Client.Administration.UI.Bwoink
return;
PeopleTyping.Add(name);
Timer.Spawn(TimeSpan.FromSeconds(10), () =>
Robust.Shared.Timing.Timer.Spawn(TimeSpan.FromSeconds(10), () =>
{
if (Disposed)
return;
@ -124,11 +144,38 @@ namespace Content.Client.Administration.UI.Bwoink
UpdateTypingIndicator();
}
public void OnCooldownReceived(BwoinkCooldownMessage message)
{
// Set cooldown end time
_cooldownEnd = DateTime.Now.Add(message.RemainingCooldown);
// Disable input field and show feedback
SenderLineEdit.Editable = false;
SenderLineEdit.PlaceHolder = Loc.GetString("bwoink-cooldown-message",
("seconds", $"{message.RemainingCooldown.TotalSeconds:F1}"));
// Clean up existing timer
_cooldownCancellationTokenSource?.Cancel();
_cooldownCancellationTokenSource = new CancellationTokenSource();
// Set timer to re-enable input
Robust.Shared.Timing.Timer.Spawn(message.RemainingCooldown, () =>
{
if (Disposed)
return;
SenderLineEdit.Editable = true;
SenderLineEdit.PlaceHolder = Loc.GetString("bwoink-input-placeholder");
}, _cooldownCancellationTokenSource.Token);
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
InputTextChanged = null;
_cooldownCancellationTokenSource?.Cancel();
_cooldownCancellationTokenSource = null;
}
}
}

View file

@ -27,6 +27,7 @@ namespace Content.Client.Communications.UI
_menu.OnBroadcast += BroadcastButtonPressed;
_menu.OnAlertLevel += AlertLevelSelected;
_menu.OnEmergencyLevel += EmergencyShuttleButtonPressed;
_menu.OnToggleRelay += ToggleRelayPressed; // Sunrise-Edit
}
public void AlertLevelSelected(string level)
@ -58,6 +59,13 @@ namespace Content.Client.Communications.UI
SendMessage(new CommunicationsConsoleBroadcastMessage(message));
}
// Sunrise-Start
private void ToggleRelayPressed()
{
SendMessage(new CommunicationsConsoleToggleRelayMessage());
}
// Sunrise-End
public void CallShuttle()
{
SendMessage(new CommunicationsConsoleCallEmergencyShuttleMessage());
@ -91,6 +99,14 @@ namespace Content.Client.Communications.UI
_menu.EmergencyShuttleButton.Disabled = !_menu.CanCall;
_menu.AnnounceButton.Disabled = !_menu.CanAnnounce;
_menu.BroadcastButton.Disabled = !_menu.CanBroadcast;
// Sunrise-Start
_menu.CanRelay = commsState.CanRelay;
_menu.IsRelaying = commsState.IsRelaying;
_menu.RelayCooldownRemaining = commsState.RelayCooldownRemaining;
_menu.RelayTimeRemaining = commsState.RelayTimeRemaining;
_menu.UpdateRelayUi();
// Sunrise-End
}
}
}

View file

@ -9,6 +9,17 @@
VerticalExpand="True"
Margin="6 6 6 5">
<BoxContainer Orientation="Vertical"
Margin="0 2">
<controls:StripeBack>
<BoxContainer Orientation="Horizontal">
<Label HorizontalExpand="True" Text="{Loc 'comms-console-menu-announcement-header'}"
Name="AnnouncementLabel" VAlign="Center"
StyleClasses="LabelHeading" Align="Center"/>
</BoxContainer>
</controls:StripeBack>
<TextEdit Name="MessageInput"
VerticalExpand="True"
HorizontalExpand="True"
@ -16,15 +27,6 @@
HorizontalAlignment="Stretch"
MinHeight="100"/>
<!-- ButtonsPart -->
<BoxContainer Orientation="Vertical"
VerticalAlignment="Bottom"
SeparationOverride="4">
<!-- AnnouncePart -->
<BoxContainer Orientation="Vertical"
Margin="0 2">
<Button Name="AnnounceButton"
Access="Public"
Text="{Loc 'comms-console-menu-announcement-button'}"
@ -38,17 +40,54 @@
Text="{Loc 'comms-console-menu-broadcast-button'}"
ToolTip="{Loc 'comms-console-menu-broadcast-button-tooltip'}"
StyleClasses="OpenBoth"/>
</BoxContainer>
<BoxContainer Orientation="Vertical"
Margin="0 2">
<controls:StripeBack>
<BoxContainer Orientation="Horizontal">
<Label HorizontalExpand="True" Text="{Loc 'comms-console-menu-alert-level-header'}"
Name="AlertLevelLabel" VAlign="Center"
StyleClasses="LabelHeading" Align="Center"/>
</BoxContainer>
</controls:StripeBack>
<OptionButton Name="AlertLevelButton"
Access="Public"
ToolTip="{Loc 'comms-console-menu-alert-level-button-tooltip'}"
StyleClasses="OpenRight"/>
</BoxContainer>
<!-- EmergencyPart -->
<BoxContainer Orientation="Vertical"
SeparationOverride="6">
Margin="0 2">
<controls:StripeBack>
<BoxContainer Orientation="Horizontal">
<Label HorizontalExpand="True" Text="{Loc 'comms-console-menu-relay-header'}"
Name="RelayLabel" VAlign="Center"
StyleClasses="LabelHeading" Align="Center"/>
</BoxContainer>
</controls:StripeBack>
<Button Name="RelayButton"
Access="Public"
Text="{Loc 'comms-console-menu-relay-button'}"
ToolTip="{Loc 'comms-console-menu-relay-button-tooltip'}"
StyleClasses="OpenBoth"/>
<RichTextLabel Name="RelayStatusLabel"/>
</BoxContainer>
<BoxContainer Orientation="Vertical"
Margin="0 2">
<controls:StripeBack>
<BoxContainer Orientation="Horizontal">
<Label HorizontalExpand="True" Text="{Loc 'comms-console-menu-emergency-header'}"
Name="EmergencyLabel" VAlign="Center"
StyleClasses="LabelHeading" Align="Center"/>
</BoxContainer>
</controls:StripeBack>
<RichTextLabel Name="CountdownLabel"/>
@ -58,5 +97,4 @@
ToolTip="{Loc 'comms-console-menu-emergency-shuttle-button-tooltip'}"/>
</BoxContainer>
</BoxContainer>
</BoxContainer>
</controls:FancyWindow>

View file

@ -19,6 +19,12 @@ namespace Content.Client.Communications.UI
public bool CanAnnounce;
public bool CanBroadcast;
public bool CanCall;
// Sunrise-Start
public bool CanRelay;
public bool IsRelaying;
public float RelayCooldownRemaining;
public float RelayTimeRemaining;
// Sunrise-End
public bool AlertLevelSelectable;
public bool CountdownStarted;
public string CurrentLevel = string.Empty;
@ -28,6 +34,7 @@ namespace Content.Client.Communications.UI
public event Action<string>? OnAlertLevel;
public event Action<string>? OnAnnounce;
public event Action<string>? OnBroadcast;
public event Action? OnToggleRelay; // Sunrise-Edit
public CommunicationsConsoleMenu()
{
@ -58,6 +65,11 @@ namespace Content.Client.Communications.UI
BroadcastButton.OnPressed += _ => OnBroadcast?.Invoke(Rope.Collapse(MessageInput.TextRope));
BroadcastButton.Disabled = !CanBroadcast;
// Sunrise-Start
RelayButton.OnPressed += _ => OnToggleRelay?.Invoke();
RelayButton.Disabled = !CanRelay;
// Sunrise-End
AlertLevelButton.OnItemSelected += args =>
{
var metadata = AlertLevelButton.GetItemMetadata(args.Id);
@ -78,6 +90,7 @@ namespace Content.Client.Communications.UI
{
base.FrameUpdate(args);
UpdateCountdown();
UpdateRelayUi(); // Sunrise-Edit
}
// The current alert could make levels unselectable, so we need to ensure that the UI reacts properly.
@ -122,6 +135,7 @@ namespace Content.Client.Communications.UI
if (!CountdownStarted)
{
CountdownLabel.SetMessage(string.Empty);
CountdownLabel.Visible = false; // Sunrise-Edit
EmergencyShuttleButton.Text = Loc.GetString("comms-console-menu-call-shuttle");
return;
}
@ -132,6 +146,36 @@ namespace Content.Client.Communications.UI
var infoText = Loc.GetString($"comms-console-menu-time-remaining",
("time", diff.ToString(@"hh\:mm\:ss", CultureInfo.CurrentCulture)));
CountdownLabel.SetMessage(infoText);
CountdownLabel.Visible = true; // Sunrise-Edit
}
// Sunrise-Start
public void UpdateRelayUi()
{
RelayButton.Disabled = !CanRelay && !IsRelaying;
if (IsRelaying)
{
RelayButton.Text = Loc.GetString("comms-console-menu-relay-stop");
var remaining = TimeSpan.FromSeconds(Math.Max(0f, RelayTimeRemaining));
var text = Loc.GetString("comms-console-menu-relay-time-left", ("time", remaining.ToString(@"mm\:ss", CultureInfo.CurrentCulture)));
RelayStatusLabel.Visible = true;
RelayStatusLabel.SetMessage(text);
}
else if (RelayCooldownRemaining > 0f)
{
var remaining = TimeSpan.FromSeconds(RelayCooldownRemaining);
RelayButton.Text = Loc.GetString("comms-console-menu-relay-button");
RelayStatusLabel.SetMessage(Loc.GetString("comms-console-menu-relay-cooldown", ("time", remaining.ToString(@"mm\:ss", CultureInfo.CurrentCulture))));
RelayStatusLabel.Visible = true;
RelayButton.Disabled = true;
}
else
{
RelayButton.Text = Loc.GetString("comms-console-menu-relay-button");
RelayStatusLabel.Visible = false;
RelayStatusLabel.SetMessage(string.Empty);
}
}
// Sunrise-End
}
}

View file

@ -26,7 +26,8 @@ namespace Content.Client.Input
common.AddFunction(ContentKeyFunctions.CycleChatChannelBackward);
common.AddFunction(ContentKeyFunctions.EscapeContext);
common.AddFunction(ContentKeyFunctions.ExamineEntity);
common.AddFunction(ContentKeyFunctions.OpenAHelp);
// Sunrise-Edit
//common.AddFunction(ContentKeyFunctions.OpenAHelp);
common.AddFunction(ContentKeyFunctions.TakeScreenshot);
common.AddFunction(ContentKeyFunctions.TakeScreenshotNoUI);
common.AddFunction(ContentKeyFunctions.ToggleFullscreen);
@ -49,6 +50,11 @@ namespace Content.Client.Input
// Not in engine so that the RCD can rotate objects
common.AddFunction(EngineKeyFunctions.EditorRotateObject);
// Sunrise-Start
common.AddFunction(ContentKeyFunctions.OpenMentorHelp);
common.AddFunction(ContentKeyFunctions.OpenHelpChoice);
// Sunrise-End
var human = contexts.GetContext("human");
human.AddFunction(EngineKeyFunctions.MoveUp);
human.AddFunction(EngineKeyFunctions.MoveDown);

View file

@ -177,7 +177,11 @@
HorizontalAlignment="Right" SizeFlagsStretchRatio="1">
<Button Name="AHelpButton" Access="Public" Text="{Loc 'ui-lobby-ahelp-button'}"
StyleClasses="ButtonBig"/>
<Button Name="MHelpButton" Access="Public" Text="{Loc 'ui-lobby-mhelp-button'}"
StyleClasses="ButtonBig"/>
<vote:VoteCallMenuButton Name="CallVoteButton" StyleClasses="ButtonBig" />
<Button Name="ReplaysButton" Access="Public" Text="{Loc 'ui-lobby-replays-button'}"
StyleClasses="ButtonBig"/>
<Button Name="OptionsButton" Access="Public" Text="{Loc 'ui-lobby-options-button'}"
StyleClasses="ButtonBig"/>
<Button Name="LeaveButton" Access="Public" Text="{Loc 'ui-lobby-leave-button'}"

View file

@ -15,6 +15,7 @@ using Robust.Client.Graphics;
using Robust.Client.ResourceManagement;
using Robust.Shared.Configuration;
using Robust.Shared.Input;
using Robust.Shared.Utility;
namespace Content.Client.Lobby.UI
{
@ -27,6 +28,7 @@ namespace Content.Client.Lobby.UI
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly IResourceCache _resourceCache = default!;
[Dependency] private readonly IConfigurationManager _configurationManager = default!;
[Dependency] private readonly IUriOpener _uriOpener = default!;
public string LobbyParallax = "FastSpace"; // Sunrise-edit
public bool ShowParallax; // Sunrise-edit
@ -53,6 +55,7 @@ namespace Content.Client.Lobby.UI
LeaveButton.OnPressed += _ => _consoleHost.ExecuteCommand("disconnect");
OptionsButton.OnPressed += _ => UserInterfaceManager.GetUIController<OptionsUIController>().ToggleWindow();
ReplaysButton.OnPressed += _ => _uriOpener.OpenUri(_configurationManager.GetCVar(SunriseCCVars.InfoLinksReplays));
//CollapseButton.OnPressed += _ => TogglePanel(false);
//ExpandButton.OnPressed += _ => TogglePanel(true);
@ -146,6 +149,7 @@ namespace Content.Client.Lobby.UI
_configurationManager.OnValueChanged(SunriseCCVars.ServersHubEnable, OnServersHubEnableChanged, true);
_configurationManager.OnValueChanged(SunriseCCVars.ServiceAuthEnabled, OnServiceAuthEnableChanged, true);
_configurationManager.OnValueChanged(SunriseCCVars.ServerName, OnServerNameChanged, true);
_configurationManager.OnValueChanged(SunriseCCVars.InfoLinksReplays, OnReplaysLinkChanged, true);
Chat.SetChatOpacity();
@ -216,6 +220,11 @@ namespace Content.Client.Lobby.UI
{
UserProfileBox.Visible = enable;
}
private void OnReplaysLinkChanged(string replaysUrl)
{
ReplaysButton.Visible = !string.IsNullOrEmpty(replaysUrl);
}
// Sunrise-End
private void OnLobbyOpacityChanged(float opacity)

View file

@ -10,6 +10,7 @@
<!-- Sunrise-Start -->
<Button Access="Public" Name="RoadmapButton" Text="{Loc 'ui-roadmap'}" StyleClasses="Caution" />
<Button Access="Public" Name="DonateButton" Text="{Loc 'ui-escape-donate'}" />
<Button Access="Public" Name="ReplaysButton" Text="{Loc 'ui-escape-replays'}" />
<!-- Sunrise-End -->
<PanelContainer StyleClasses="LowDivider" Margin="0 2.5 0 2.5" />
<Button Access="Public" Name="RulesButton" Text="{Loc 'ui-escape-rules'}" />

View file

@ -169,6 +169,8 @@ namespace Content.Client.Options.UI.Tabs
AddButton(ContentKeyFunctions.Reloading);
AddButton(ContentKeyFunctions.Interact);
AddButton(ContentKeyFunctions.LookUp);
AddButton(ContentKeyFunctions.OpenMentorHelp);
AddButton(ContentKeyFunctions.OpenHelpChoice);
AddCheckBox("ui-options-function-hold-look-up", _cfg.GetCVar(SunriseCCVars.HoldLookUp), HandleHoldLookUp);
// Sunrise-End

View file

@ -101,6 +101,7 @@ public sealed class AHelpUIController: UIController, IOnSystemChanged<BwoinkSyst
{
_bwoinkSystem = system;
_bwoinkSystem.OnBwoinkTextMessageRecieved += ReceivedBwoink;
_bwoinkSystem.OnBwoinkCooldownReceived += ReceivedCooldown;
CommandBinds.Builder
.Bind(ContentKeyFunctions.OpenAHelp,
@ -114,6 +115,7 @@ public sealed class AHelpUIController: UIController, IOnSystemChanged<BwoinkSyst
DebugTools.Assert(_bwoinkSystem != null);
_bwoinkSystem!.OnBwoinkTextMessageRecieved -= ReceivedBwoink;
_bwoinkSystem!.OnBwoinkCooldownReceived -= ReceivedCooldown;
_bwoinkSystem = null;
}
@ -158,6 +160,12 @@ public sealed class AHelpUIController: UIController, IOnSystemChanged<BwoinkSyst
UIHelper!.Receive(message);
}
private void ReceivedCooldown(object? sender, BwoinkCooldownMessage message)
{
EnsureUIHelper();
UIHelper?.OnCooldownReceived(message);
}
private void DiscordRelayUpdated(BwoinkDiscordRelayUpdated args, EntitySessionEventArgs session)
{
_discordRelayActive = args.DiscordRelayEnabled;
@ -342,6 +350,7 @@ public interface IAHelpUIHandler : IDisposable
public void ToggleWindow();
public void DiscordRelayChanged(bool active);
public void PeopleTypingUpdated(BwoinkPlayerTypingUpdated args);
public void OnCooldownReceived(BwoinkCooldownMessage message);
public event Action OnClose;
public event Action OnOpen;
public Action<NetUserId, string, bool, bool>? SendMessageAction { get; set; }
@ -432,6 +441,16 @@ public sealed class AdminAHelpUIHandler : IAHelpUIHandler
panel.UpdatePlayerTyping(args.PlayerName, args.Typing);
}
public void OnCooldownReceived(BwoinkCooldownMessage message)
{
// For admins, we might want to show a message in the currently active panel
// For now, we'll pass it to all panels to handle
foreach (var (_, panel) in _activePanelMap)
{
panel.OnCooldownReceived(message);
}
}
// Sunrise-Start
public void SetLoadDb(NetUserId userId)
{
@ -621,6 +640,11 @@ public sealed class UserAHelpUIHandler : IAHelpUIHandler
{
}
public void OnCooldownReceived(BwoinkCooldownMessage message)
{
_chatPanel?.OnCooldownReceived(message);
}
public event Action? OnClose;
public event Action? OnOpen;
public Action<NetUserId, string, bool, bool>? SendMessageAction { get; set; }
@ -637,6 +661,10 @@ public sealed class UserAHelpUIHandler : IAHelpUIHandler
if (_window is { Disposed: false })
return;
_chatPanel = new BwoinkPanel(text => SendMessageAction?.Invoke(_ownerId, text, true, false));
// Sunrise-Start
_chatPanel.AHelpDescLabel.Visible = true;
_chatPanel.AdminWhoButton.Visible = true;
// Sunrise-End
_chatPanel.InputTextChanged += text => InputTextChanged?.Invoke(_ownerId, text);
_chatPanel.RelayedToDiscordLabel.Visible = relayActive;
_window = new DefaultWindow()
@ -644,7 +672,7 @@ public sealed class UserAHelpUIHandler : IAHelpUIHandler
TitleClass="windowTitleAlert",
HeaderClass="windowHeaderAlert",
Title=Loc.GetString("bwoink-user-title"),
MinSize = new Vector2(500, 300),
MinSize = new Vector2(900, 500), // Sunrise-Edit
};
_window.OnClose += () => { OnClose?.Invoke(); };
_window.OnOpen += () => { OnOpen?.Invoke(); };

View file

@ -106,6 +106,11 @@ public sealed class EscapeUIController : UIController, IOnStateEntered<GameplayS
{
_uri.OpenUri(_cfg.GetCVar(SunriseCCVars.InfoLinksDonate));
};
_escapeWindow.ReplaysButton.OnPressed += _ =>
{
_uri.OpenUri(_cfg.GetCVar(SunriseCCVars.InfoLinksReplays));
};
// Sunrise-end
_escapeWindow.WikiButton.OnPressed += _ =>
@ -123,6 +128,8 @@ public sealed class EscapeUIController : UIController, IOnStateEntered<GameplayS
_escapeWindow.DonateButton.Visible = _cfg.GetCVar(SunriseCCVars.InfoLinksDonate) != ""; // Sunrise-Sponsors
_escapeWindow.ReplaysButton.Visible = _cfg.GetCVar(SunriseCCVars.InfoLinksReplays) != ""; // Sunrise-Replays
CommandBinds.Builder
.Bind(EngineKeyFunctions.EscapeMenu,
InputCmdHandler.FromDelegate(_ => ToggleWindow()))

View file

@ -110,6 +110,16 @@
ToolTip="{Loc 'ui-options-function-open-a-help'}"
MinSize="42 64"
HorizontalExpand="True"
AppendStyleClass="{x:Static style:StyleBase.ButtonSquare}"
/>
<ui:MenuButton
Name="MHelpButton"
Access="Internal"
Icon="{xe:Tex '/Textures/Interface/mentor.svg.192dpi.png'}"
BoundKey = "{x:Static is:ContentKeyFunctions.OpenMentorHelp}"
ToolTip="{Loc 'ui-options-function-open-mentor-help'}"
MinSize="42 64"
HorizontalExpand="True"
AppendStyleClass="{x:Static style:StyleBase.ButtonOpenLeft}"
/>
</widgets:GameTopMenuBar>

View file

@ -0,0 +1,64 @@
using Content.Client._Sunrise.MentorHelp;
using Content.Client.Administration.Systems;
using Content.Client.UserInterface.Systems.Bwoink;
using Content.Shared.Input;
using JetBrains.Annotations;
using Robust.Client.Input;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controllers;
using Robust.Shared.Input.Binding;
namespace Content.Client._Sunrise.HelpChoice;
[UsedImplicitly]
public sealed class HelpChoiceUIController: UIController, IOnSystemChanged<MentorHelpSystem>
{
[Dependency] private readonly IUserInterfaceManager _uiManager = default!;
[Dependency] private readonly IInputManager _input = default!;
private HelpChoiceWindow? _dialog;
public void OnSystemLoaded(MentorHelpSystem system)
{
_input.SetInputCommand(ContentKeyFunctions.OpenHelpChoice,
InputCmdHandler.FromDelegate(_ => ShowHelpChoiceDialog()));
}
public void OnSystemUnloaded(MentorHelpSystem system)
{
_input.SetInputCommand(ContentKeyFunctions.OpenHelpChoice, null);
}
private void ShowHelpChoiceDialog()
{
if (_dialog != null && _dialog.IsOpen)
{
_dialog.Close();
_dialog = null;
return;
}
if (_dialog == null)
{
_dialog = new HelpChoiceWindow();
var local = _dialog;
local.AHelpButton.OnPressed += _ =>
{
local.Close();
_uiManager.GetUIController<AHelpUIController>().Open();
};
local.MentorHelpButton.OnPressed += _ =>
{
local.Close();
_uiManager.GetUIController<MentorHelpUIController>().Open();
};
_dialog.OnClose += () => _dialog = null;
}
_dialog.OpenCentered();
}
}

View file

@ -0,0 +1,14 @@
<DefaultWindow xmlns="https://spacestation14.io"
SetSize="450 200"
Title="{Loc 'help-choice-title'}"
Resizable="False">
<BoxContainer Orientation="Vertical">
<RichTextLabel Text="{Loc 'help-choice-title-label'}" HorizontalAlignment="Center"/>
<BoxContainer Orientation="Horizontal" HorizontalAlignment="Center" SeparationOverride="20">
<Button Name="AHelpButton" Text="{Loc 'help-choice-ahelp-button'}" SetSize="160 45" HorizontalAlignment="Center" Access="Public"/>
<Button Name="MentorHelpButton" Text="{Loc 'help-choice-mhelp-button'}" SetSize="160 45" HorizontalAlignment="Center" Access="Public"/>
</BoxContainer>
<RichTextLabel Name="AHelpDescLabel" Text="{Loc 'help-choice-ahelp-desc-label'}"/>
<RichTextLabel Name="MHelpDescLabel" Text="{Loc 'help-choice-mhelp-desc-label'}"/>
</BoxContainer>
</DefaultWindow>

View file

@ -0,0 +1,16 @@
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML;
namespace Content.Client._Sunrise.HelpChoice
{
[GenerateTypedNameReferences]
public sealed partial class HelpChoiceWindow : DefaultWindow
{
public HelpChoiceWindow()
{
RobustXamlLoader.Load(this);
}
}
}

View file

@ -0,0 +1,102 @@
<Control
xmlns="https://spacestation14.io"
xmlns:controls="clr-namespace:Content.Client.Administration.UI.CustomControls">
<PanelContainer StyleClasses="BackgroundDark">
<BoxContainer Orientation="Vertical" HorizontalExpand="True" VerticalExpand="True">
<BoxContainer Orientation="Horizontal" SetHeight="40" HorizontalExpand="True" Margin="5">
<Button Name="NewTicketButton" Access="Public" Text="{Loc 'mentor-help-new-ticket'}"
StyleClasses="ButtonBig" Margin="0 0 10 0"/>
<Button Name="AdminWhoButton" Access="Public" Text="{Loc 'admin-who-button'}" Margin="0 0 10 0" />
<Button Name="StatisticsButton" Access="Public" Text="{Loc 'mentor-help-statistics'}"
StyleClasses="ButtonBig" Visible="False" Margin="0 0 10 0" />
<Control HorizontalExpand="True" />
<Button Name="BackToListButton" Access="Public" Text="{Loc 'mentor-help-back-to-list'}"
StyleClasses="ButtonBig" HorizontalAlignment="Right" Visible="False" />
</BoxContainer>
<Control Name="DefaultState" VerticalExpand="True" HorizontalExpand="True">
<TabContainer Name="TicketsTabContainer" Access="Public" VerticalExpand="True" HorizontalExpand="True">
<BoxContainer Orientation="Vertical" HorizontalExpand="True" VerticalExpand="True">
<BoxContainer Orientation="Horizontal" HorizontalExpand="True" Margin="5 0 15 0">
<controls:VSeparator/>
<Label Text="{Loc 'mentor-help-column-id'}" SizeFlagsStretchRatio="1" HorizontalExpand="True" HorizontalAlignment="Center"/>
<controls:VSeparator/>
<Label Text="{Loc 'mentor-help-column-player'}" SizeFlagsStretchRatio="3" HorizontalExpand="True" HorizontalAlignment="Center"/>
<controls:VSeparator/>
<Label Text="{Loc 'mentor-help-column-status'}" SizeFlagsStretchRatio="2" HorizontalExpand="True" HorizontalAlignment="Center"/>
<controls:VSeparator/>
<Label Text="{Loc 'mentor-help-column-assigned'}" SizeFlagsStretchRatio="3" HorizontalExpand="True" HorizontalAlignment="Center"/>
<controls:VSeparator/>
<Label Text="{Loc 'mentor-help-column-subject'}" SizeFlagsStretchRatio="5" HorizontalExpand="True" HorizontalAlignment="Center"/>
<controls:VSeparator/>
</BoxContainer>
<controls:HSeparator/>
<ScrollContainer HScrollEnabled="False" HorizontalExpand="True" VerticalExpand="True" MinHeight="150">
<BoxContainer Name="OpenTicketsList" Access="Public" Orientation="Vertical" VerticalExpand="True" HorizontalExpand="True"/>
</ScrollContainer>
</BoxContainer>
<BoxContainer Orientation="Vertical" HorizontalExpand="True" VerticalExpand="True">
<BoxContainer Orientation="Horizontal" HorizontalExpand="True" Margin="5 0 15 0">
<controls:VSeparator/>
<Label Text="{Loc 'mentor-help-column-id'}" SizeFlagsStretchRatio="1" HorizontalExpand="True" HorizontalAlignment="Center"/>
<controls:VSeparator/>
<Label Text="{Loc 'mentor-help-column-player'}" SizeFlagsStretchRatio="3" HorizontalExpand="True" HorizontalAlignment="Center"/>
<controls:VSeparator/>
<Label Text="{Loc 'mentor-help-column-status'}" SizeFlagsStretchRatio="2" HorizontalExpand="True" HorizontalAlignment="Center"/>
<controls:VSeparator/>
<Label Text="{Loc 'mentor-help-column-assigned'}" SizeFlagsStretchRatio="3" HorizontalExpand="True" HorizontalAlignment="Center"/>
<controls:VSeparator/>
<Label Text="{Loc 'mentor-help-column-subject'}" SizeFlagsStretchRatio="5" HorizontalExpand="True" HorizontalAlignment="Center"/>
<controls:VSeparator/>
</BoxContainer>
<controls:HSeparator/>
<ScrollContainer HScrollEnabled="False" HorizontalExpand="True" VerticalExpand="True" MinHeight="150">
<BoxContainer Name="ClosedTicketsList" Access="Public" Orientation="Vertical" VerticalExpand="True" HorizontalExpand="True"/>
</ScrollContainer>
</BoxContainer>
</TabContainer>
</Control>
<Control Name="TicketViewState" Visible="False" VerticalExpand="True" HorizontalExpand="True">
<BoxContainer Orientation="Vertical" HorizontalExpand="True" VerticalExpand="True">
<PanelContainer StyleClasses="PanelColorMedium" HorizontalExpand="True" Margin="5">
<BoxContainer Orientation="Vertical" HorizontalExpand="True" Margin="10">
<BoxContainer Orientation="Horizontal" HorizontalExpand="True">
<Label Text="{Loc 'mentor-help-label-id'}" Margin="0 0 5 0" />
<Label Name="TicketIdLabel" Access="Public" StyleClasses="LabelHeading" Margin="6 0 20 0" />
<Label Text="{Loc 'mentor-help-label-subject'}" Margin="0 0 5 0" />
<Label Name="TicketSubjectLabel" Access="Public" StyleClasses="LabelHeading" Margin="8 0 0 0" />
</BoxContainer>
<BoxContainer Orientation="Horizontal" SeparationOverride="3" HorizontalAlignment="Stretch" HorizontalExpand="True" Margin="0 6 0 0">
<Label Name="TicketStatus" Access="Public" StyleClasses="PdaContentFooterText" HorizontalExpand="True" HorizontalAlignment="Left"/>
<Label Name="TicketAssigned" Access="Public" StyleClasses="PdaContentFooterText" HorizontalExpand="True" HorizontalAlignment="Center"/>
<Label Name="TicketCreated" Access="Public" StyleClasses="PdaContentFooterText" HorizontalExpand="True" HorizontalAlignment="Right" />
</BoxContainer>
</BoxContainer>
</PanelContainer>
<ScrollContainer Name="MessagesScroll" Access="Public" VerticalExpand="True" HorizontalExpand="True" Margin="5">
<BoxContainer Name="MessagesContainer" Access="Public" Orientation="Vertical" />
</ScrollContainer>
<BoxContainer Name="ReplyPanel" Access="Public" Orientation="Vertical" HorizontalExpand="True" Margin="5">
<LineEdit Name="ReplyInput" Access="Public" PlaceHolder="{Loc 'mentor-help-reply-placeholder'}"
HorizontalExpand="True" />
<BoxContainer Orientation="Horizontal" HorizontalExpand="True" Margin="0 5 0 0">
<Button Name="ClaimButton" Access="Public" Text="{Loc 'mentor-help-claim'}"
StyleClasses="ButtonBig" />
<Button Name="UnassignButton" Access="Public" Text="{Loc 'mentor-help-unassign'}"
StyleClasses="ButtonBig" />
<Button Name="CloseTicketButton" Access="Public" Text="{Loc 'mentor-help-close-ticket'}"
StyleClasses="ButtonBig" />
</BoxContainer>
</BoxContainer>
</BoxContainer>
</Control>
</BoxContainer>
</PanelContainer>
</Control>

View file

@ -0,0 +1,434 @@
using System.Linq;
using Content.Client.Administration.Systems;
using Content.Client.Administration.UI.Bwoink;
using Content.Shared._Sunrise.MentorHelp;
using Content.Shared.Administration;
using Content.Shared.Database;
using Robust.Client.AutoGenerated;
using Robust.Client.State;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Network;
using Robust.Shared.Timing;
namespace Content.Client._Sunrise.MentorHelp
{
/// <summary>
/// Main control for mentor help interface
/// </summary>
[GenerateTypedNameReferences]
public sealed partial class MentorHelpControl : Control
{
[Dependency] private readonly IUserInterfaceManager _ui = default!;
[Dependency] private readonly IGameTiming _timing = default!;
private MentorHelpSystem? _mentorHelpSystem;
private NetUserId _ownerUserId;
private bool _hasMentorPermissions;
private List<MentorHelpTicketData> _tickets = new();
private MentorHelpTicketData? _selectedTicket;
private Dictionary<int, List<MentorHelpMessageData>> _ticketMessages = new();
private readonly Dictionary<int, TicketEntryControl> _openTicketControls = new();
private readonly Dictionary<int, TicketEntryControl> _closedTicketControls = new();
private enum ViewState
{
TicketsList,
TicketView
}
private MentorHelpNewTicketDialog? _newTicketDialog;
private int? _pendingOpenTicketId;
public MentorHelpControl()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
// Wire up button events
NewTicketButton.OnPressed += _ => OpenNewTicketDialog();
StatisticsButton.OnPressed += _ => _ui.GetUIController<MentorHelpStatisticsUIController>().ToggleStatistics();
BackToListButton.OnPressed += _ => SwitchState(ViewState.TicketsList);
// Wire up ticket action buttons
ClaimButton.OnPressed += _ => ClaimTicket();
UnassignButton.OnPressed += _ => UnassignTicket();
CloseTicketButton.OnPressed += _ => CloseTicket();
// Handle enter key in reply input
ReplyInput.OnTextEntered += _ => SendReply();
// Setup tab container like in AdminMenuWindow
TicketsTabContainer.SetTabTitle(0, Loc.GetString("mentor-help-tab-open"));
TicketsTabContainer.SetTabTitle(1, Loc.GetString("mentor-help-tab-closed"));
// Handle tab changes to load closed tickets when needed
TicketsTabContainer.OnTabChanged += OnTabChanged;
AdminWhoButton.OnPressed += _ =>
{
var ctrl = _ui.GetUIController<AdminWhoUIController>();
ctrl.Toggle();
};
// Set initial state
SwitchState(ViewState.TicketsList);
}
/// <summary>
/// Попытаться открыть тикет с указанным id. Если тикет ещё не загружен, запросит список тикетов
/// и откроет тикет когда список придёт.
/// </summary>
public void TryOpenTicket(int ticketId)
{
// If we already have the ticket loaded, select it immediately
var ticket = _tickets.FirstOrDefault(t => t.Id == ticketId);
if (ticket != null)
{
OnTicketSelected(ticket);
return;
}
// Otherwise, remember to open when the list arrives and request tickets from server
_pendingOpenTicketId = ticketId;
_mentorHelpSystem?.RequestTickets(onlyMine: !_hasMentorPermissions);
}
public void Initialize(MentorHelpSystem? mentorHelpSystem, NetUserId ownerUserId, bool hasMentorPermissions)
{
_mentorHelpSystem = mentorHelpSystem;
_ownerUserId = ownerUserId;
_hasMentorPermissions = hasMentorPermissions;
// Show/hide buttons based on permissions
StatisticsButton.Visible = _hasMentorPermissions;
UpdateTicketActionButtons();
}
private void OnTabChanged(int tabIndex)
{
// When switching to closed tickets tab, request all tickets including closed ones
if (tabIndex == 1 && _hasMentorPermissions)
{
_mentorHelpSystem?.RequestTickets(onlyMine: false);
}
else if (tabIndex == 1 && !_hasMentorPermissions)
{
_mentorHelpSystem?.RequestTickets(onlyMine: true);
}
}
// State switching like in LobbyGui
private void SwitchState(ViewState state)
{
DefaultState.Visible = false;
TicketViewState.Visible = false;
BackToListButton.Visible = false;
switch (state)
{
case ViewState.TicketsList:
DefaultState.Visible = true;
_selectedTicket = null;
break;
case ViewState.TicketView:
TicketViewState.Visible = true;
BackToListButton.Visible = true;
UpdateTicketHeader();
UpdateTicketActionButtons();
break;
}
}
public void UpdateTicketsList(List<MentorHelpTicketData> tickets)
{
_tickets = tickets;
RefreshTicketsList();
// If we were asked to open a specific ticket once the list arrives, do it now
if (_pendingOpenTicketId.HasValue)
{
var id = _pendingOpenTicketId.Value;
_pendingOpenTicketId = null;
var ticket = _tickets.FirstOrDefault(t => t.Id == id);
if (ticket != null)
{
OnTicketSelected(ticket);
}
}
}
// Refresh tickets like in ContributorsTop
private void RefreshTicketsList()
{
var openTickets = _tickets.Where(t => t.Status != MentorHelpTicketStatus.Closed).ToList();
var closedTickets = _tickets.Where(t => t.Status == MentorHelpTicketStatus.Closed).ToList();
// Filter for players to show only their tickets
if (!_hasMentorPermissions)
{
openTickets = openTickets.Where(t => t.PlayerId == _ownerUserId).ToList();
closedTickets = closedTickets.Where(t => t.PlayerId == _ownerUserId).ToList();
}
// Update open tickets
RefreshTicketTab(openTickets, _openTicketControls, OpenTicketsList);
// Update closed tickets
RefreshTicketTab(closedTickets, _closedTicketControls, ClosedTicketsList);
}
private void RefreshTicketTab(List<MentorHelpTicketData> tickets, Dictionary<int, TicketEntryControl> controls, BoxContainer container)
{
var sortedTickets = tickets.OrderByDescending(t => t.UpdatedAt).ToList();
// Remove tickets that no longer exist
var toRemove = controls.Keys.Where(id => !sortedTickets.Any(t => t.Id == id)).ToList();
foreach (var id in toRemove)
{
if (controls.TryGetValue(id, out var control))
{
container.RemoveChild(control);
controls.Remove(id);
}
}
// Add or update existing tickets
foreach (var ticket in sortedTickets)
{
if (controls.TryGetValue(ticket.Id, out var existingControl))
{
existingControl.UpdateData(ticket);
}
else
{
var control = new TicketEntryControl();
control.UpdateData(ticket);
control.OnTicketSelected += OnTicketSelected;
controls[ticket.Id] = control;
container.AddChild(control);
}
}
}
private void OnTicketSelected(MentorHelpTicketData ticket)
{
_selectedTicket = ticket;
SwitchState(ViewState.TicketView);
// Clear previous messages first
MessagesContainer.RemoveAllChildren();
// Request messages for this ticket
_mentorHelpSystem?.RequestTicketMessages(ticket.Id);
}
public void UpdateTicketMessages(int ticketId, List<MentorHelpMessageData> messages)
{
_ticketMessages[ticketId] = messages;
if (_selectedTicket?.Id == ticketId && TicketViewState.Visible)
{
DisplayTicketMessages(messages);
}
}
public void UpdateTicket(MentorHelpTicketData ticket)
{
// Update ticket in our list
var index = _tickets.FindIndex(t => t.Id == ticket.Id);
if (index >= 0)
{
_tickets[index] = ticket;
}
else
{
_tickets.Add(ticket);
}
// Update selected ticket if it's the one that changed
if (_selectedTicket?.Id == ticket.Id)
{
_selectedTicket = ticket;
if (TicketViewState.Visible)
{
UpdateTicketHeader();
UpdateTicketActionButtons();
}
}
// If admin closed the ticket while player was viewing it, return to list
if (ticket.Status == MentorHelpTicketStatus.Closed &&
_selectedTicket?.Id == ticket.Id &&
!_hasMentorPermissions &&
TicketViewState.Visible)
{
SwitchState(ViewState.TicketsList);
}
// Refresh the list to show updated status
RefreshTicketsList();
}
private void UpdateTicketHeader()
{
if (_selectedTicket == null)
return;
// Отдельные метки для ID и темы (чтобы занимали меньше места и были выровнены)
TicketIdLabel.Text = $"#{_selectedTicket.Id}";
TicketSubjectLabel.Text = _selectedTicket.Subject;
// Компактная строка: статус | назначен | создано
TicketStatus.Text = Loc.GetString("mentor-help-status-label", ("status", GetStatusText(_selectedTicket.Status)));
TicketAssigned.Text = Loc.GetString("mentor-help-assigned-label",
("assigned", _selectedTicket.AssignedToName ?? Loc.GetString("mentor-help-unassigned")));
TicketCreated.Text = Loc.GetString("mentor-help-created-label",
("created", _selectedTicket.CreatedAt.ToString("dd.MM.yyyy HH:mm")));
}
private void UpdateTicketActionButtons()
{
if (_selectedTicket == null)
{
ReplyPanel.Visible = false;
return;
}
var canReply = _selectedTicket.Status != MentorHelpTicketStatus.Closed;
var isAssignedToMe = _selectedTicket.AssignedToUserId == _ownerUserId;
var isOpen = _selectedTicket.Status != MentorHelpTicketStatus.Closed;
// Hide reply panel for closed tickets
ReplyPanel.Visible = canReply;
if (_hasMentorPermissions)
{
ClaimButton.Visible = isOpen && !isAssignedToMe;
UnassignButton.Visible = isOpen && isAssignedToMe;
CloseTicketButton.Visible = isOpen;
}
else
{
ClaimButton.Visible = false;
UnassignButton.Visible = false;
CloseTicketButton.Visible = isOpen; // Players can close their own tickets
}
}
private string GetStatusText(MentorHelpTicketStatus status)
{
return status switch
{
MentorHelpTicketStatus.Open => Loc.GetString("mentor-help-status-open"),
MentorHelpTicketStatus.Assigned => Loc.GetString("mentor-help-status-assigned"),
MentorHelpTicketStatus.AwaitingResponse => Loc.GetString("mentor-help-status-awaiting"),
MentorHelpTicketStatus.Closed => Loc.GetString("mentor-help-status-closed"),
_ => Loc.GetString("mentor-help-status-unknown")
};
}
private void DisplayTicketMessages(List<MentorHelpMessageData> messages)
{
MessagesContainer.RemoveAllChildren();
DateTime? lastDate = null;
foreach (var message in messages.OrderBy(m => m.SentAt))
{
// Ensure we show a date header when the day changes
var sentAt = message.SentAt;
var sentDate = sentAt.Date;
if (lastDate == null || lastDate.Value != sentDate)
{
lastDate = sentDate;
var dateLabel = new RichTextLabel
{
Text = $"[center=\"{sentAt:dd.MM.yyyy}\"]",
HorizontalExpand = true
};
MessagesContainer.AddChild(dateLabel);
}
var messageBox = new PanelContainer
{
StyleClasses = { "PanelColorMedium" },
HorizontalExpand = true,
Margin = new Thickness(0, 2)
};
var vbox = new BoxContainer
{
Orientation = BoxContainer.LayoutOrientation.Vertical,
HorizontalExpand = true
};
// Format: [HH:mm] Author: message
var content = new RichTextLabel
{
Text = $"[bold]{sentAt:HH:mm}[/bold] {message.FormattedSender}: {message.Message}",
HorizontalExpand = true
};
vbox.AddChild(content);
messageBox.AddChild(vbox);
MessagesContainer.AddChild(messageBox);
}
}
private void OpenNewTicketDialog()
{
if (_newTicketDialog != null)
{
_newTicketDialog.Close();
_newTicketDialog = null;
return;
}
_newTicketDialog = new MentorHelpNewTicketDialog();
_newTicketDialog.OnTicketCreated += (subject, message) =>
{
_newTicketDialog?.Close();
_mentorHelpSystem?.CreateTicket(subject, message);
};
_newTicketDialog.OnClose += () => _newTicketDialog = null;
_newTicketDialog.OpenCentered();
}
private void SendReply()
{
if (_selectedTicket == null || string.IsNullOrWhiteSpace(ReplyInput.Text))
return;
_mentorHelpSystem?.ReplyToTicket(_selectedTicket.Id, ReplyInput.Text.Trim());
ReplyInput.Text = string.Empty;
}
private void ClaimTicket()
{
if (_selectedTicket == null)
return;
_mentorHelpSystem?.ClaimTicket(_selectedTicket.Id);
}
private void UnassignTicket()
{
if (_selectedTicket == null)
return;
_mentorHelpSystem?.UnassignTicket(_selectedTicket.Id);
}
private void CloseTicket()
{
if (_selectedTicket == null)
return;
_mentorHelpSystem?.CloseTicket(_selectedTicket.Id);
}
}
}

View file

@ -0,0 +1,19 @@
<DefaultWindow xmlns="https://spacestation14.io"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="{Loc 'mentor-help-new-ticket-title'}"
SetSize="450,400"
Resizable="False">
<BoxContainer Name="VBox" Orientation="Vertical" SeparationOverride="10" HorizontalExpand="True" VerticalExpand="True">
<RichTextLabel Name="Instructions" SetHeight="60" Text="{Loc 'mentor-help-new-ticket-instructions'}" />
<Label Name="SubjectLabel" Text="{Loc 'mentor-help-new-ticket-subject-label'}" />
<LineEdit Name="SubjectInput" PlaceHolder="{Loc 'mentor-help-new-ticket-subject-placeholder'}" SetHeight="30" />
<Label Name="MessageLabel" Text="{Loc 'mentor-help-new-ticket-message-label'}" />
<TextEdit Name="MessageInput" VerticalExpand="True" />
<Label Name="ErrorLabel" Modulate="#FF3333" Visible="False" />
<BoxContainer Name="ButtonContainer" Orientation="Horizontal" SeparationOverride="10" SetHeight="30">
<Button Name="CancelButton" Text="{Loc 'mentor-help-cancel'}" />
<Button Name="CreateButton" Text="{Loc 'mentor-help-new-ticket-create-button'}" />
</BoxContainer>
</BoxContainer>
</DefaultWindow>

View file

@ -0,0 +1,45 @@
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Utility;
namespace Content.Client._Sunrise.MentorHelp
{
/// <summary>
/// Dialog for creating a new mentor help ticket
/// </summary>
[GenerateTypedNameReferences]
public sealed partial class MentorHelpNewTicketDialog : DefaultWindow
{
public event Action<string, string>? OnTicketCreated;
public MentorHelpNewTicketDialog()
{
RobustXamlLoader.Load(this);
CancelButton.OnPressed += _ => Close();
CreateButton.OnPressed += _ =>
{
var subject = SubjectInput.Text.Trim();
var message = Rope.Collapse(MessageInput.TextRope).Trim();
if (string.IsNullOrEmpty(subject))
{
ErrorLabel.Text = Loc.GetString("mentor-help-new-ticket-error-subject");
ErrorLabel.Visible = true;
return;
}
if (string.IsNullOrEmpty(message))
{
ErrorLabel.Text = Loc.GetString("mentor-help-new-ticket-error-message");
ErrorLabel.Visible = true;
return;
}
ErrorLabel.Visible = false;
OnTicketCreated?.Invoke(subject, message);
};
}
}
}

View file

@ -0,0 +1,27 @@
<DefaultWindow xmlns="https://spacestation14.io"
xmlns:controls="clr-namespace:Content.Client.Administration.UI.CustomControls"
SetSize="600 400"
Title="{Loc 'mentor-help-statistics-title'}"
Resizable="False">
<BoxContainer Orientation="Vertical" SeparationOverride="10">
<Label Name="HeaderLabel" Access="Public"
Text="{Loc 'mentor-help-statistics-header'}"
StyleClasses="LabelHeading"
HorizontalAlignment="Center" />
<BoxContainer Orientation="Horizontal" HorizontalExpand="True" Margin="5 0 15 0">
<controls:VSeparator/>
<Label Text="Ментор" SizeFlagsStretchRatio="3" HorizontalExpand="True" HorizontalAlignment="Center"/>
<controls:VSeparator/>
<Label Text="Взятых тикетов" SizeFlagsStretchRatio="2" HorizontalExpand="True" HorizontalAlignment="Center"/>
<controls:VSeparator/>
<Label Text="Сообщений" SizeFlagsStretchRatio="2" HorizontalExpand="True" HorizontalAlignment="Center"/>
<controls:VSeparator/>
</BoxContainer>
<controls:HSeparator/>
<ScrollContainer HScrollEnabled="False" HorizontalExpand="True" VerticalExpand="True">
<BoxContainer Name="StatisticsContainer" Access="Public" Orientation="Vertical" />
</ScrollContainer>
</BoxContainer>
</DefaultWindow>

View file

@ -0,0 +1,72 @@
using Content.Shared._Sunrise.MentorHelp;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML;
namespace Content.Client._Sunrise.MentorHelp
{
[GenerateTypedNameReferences]
public sealed partial class MentorHelpStatisticsDialog : DefaultWindow
{
private MentorHelpSystem? _mentorHelpSystem;
public MentorHelpStatisticsDialog()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
CloseButton.OnPressed += _ => Close();
}
public void Initialize(MentorHelpSystem mentorHelpSystem)
{
if (_mentorHelpSystem != null)
return;
_mentorHelpSystem = mentorHelpSystem;
_mentorHelpSystem.OnStatisticsReceived += OnStatisticsReceived;
_mentorHelpSystem.RequestStatistics();
}
public void Uninitialize()
{
if (_mentorHelpSystem != null)
{
_mentorHelpSystem.OnStatisticsReceived -= OnStatisticsReceived;
_mentorHelpSystem = null;
}
}
private void OnStatisticsReceived(object? sender, MentorHelpStatisticsMessage message)
{
UpdateStatistics(message.Statistics);
}
private void UpdateStatistics(List<MentorHelpStatisticsData> statistics)
{
StatisticsContainer.RemoveAllChildren();
if (statistics.Count == 0)
{
var noDataLabel = new Label
{
Text = "Нет данных о статистике менторов", // а не локализирую, бугагагага
HorizontalAlignment = HAlignment.Center,
StyleClasses = { "LabelSubText" }
};
StatisticsContainer.AddChild(noDataLabel);
return;
}
foreach (var stat in statistics)
{
var entry = new StatisticsEntryControl();
entry.UpdateData(stat);
StatisticsContainer.AddChild(entry);
}
}
}
}

View file

@ -0,0 +1,66 @@
using Content.Client.Administration.Systems;
using JetBrains.Annotations;
using Robust.Client.UserInterface.Controllers;
namespace Content.Client._Sunrise.MentorHelp
{
[UsedImplicitly]
public sealed class MentorHelpStatisticsUIController : UIController, IOnSystemChanged<MentorHelpSystem>
{
private MentorHelpStatisticsDialog? _dialog;
private MentorHelpSystem? _mentorHelpSystem;
protected override string SawmillName => "c.s.go.es.mhelp.stats";
public void OnSystemLoaded(MentorHelpSystem system)
{
_mentorHelpSystem = system;
_dialog = new MentorHelpStatisticsDialog();
_dialog.Initialize(system);
}
public void OnSystemUnloaded(MentorHelpSystem system)
{
if (_dialog != null)
{
_dialog.Close();
_dialog.Uninitialize();
_dialog = null;
}
_mentorHelpSystem = null;
}
public void OpenStatistics()
{
if (_dialog == null)
{
if (_mentorHelpSystem == null)
return;
_dialog = new MentorHelpStatisticsDialog();
_dialog.Initialize(_mentorHelpSystem);
}
_dialog.OpenCentered();
}
public void ToggleStatistics()
{
if (_dialog?.IsOpen == true)
{
CloseStatistics();
}
else
{
OpenStatistics();
}
}
public void CloseStatistics()
{
_dialog?.Close();
}
}
}

View file

@ -0,0 +1,154 @@
using Content.Shared._Sunrise.MentorHelp;
using JetBrains.Annotations;
namespace Content.Client._Sunrise.MentorHelp
{
/// <summary>
/// Client-side mentor help system
/// </summary>
[UsedImplicitly]
public sealed class MentorHelpSystem : SharedMentorHelpSystem
{
public event EventHandler<MentorHelpTicketUpdateMessage>? OnTicketUpdated;
public event EventHandler<MentorHelpTicketsListMessage>? OnTicketsListReceived;
public event EventHandler<MentorHelpTicketMessagesMessage>? OnTicketMessagesReceived;
public event EventHandler<MentorHelpStatisticsMessage>? OnStatisticsReceived;
public event EventHandler<MentorHelpOpenTicketMessage>? OnOpenTicketReceived;
protected override void OnCreateTicketMessage(MentorHelpCreateTicketMessage message, EntitySessionEventArgs eventArgs)
{
// Client doesn't handle this directly
}
protected override void OnClaimTicketMessage(MentorHelpClaimTicketMessage message, EntitySessionEventArgs eventArgs)
{
// Client doesn't handle this directly
}
protected override void OnReplyMessage(MentorHelpReplyMessage message, EntitySessionEventArgs eventArgs)
{
// Client doesn't handle this directly
}
protected override void OnCloseTicketMessage(MentorHelpCloseTicketMessage message, EntitySessionEventArgs eventArgs)
{
// Client doesn't handle this directly
}
protected override void OnRequestTicketsMessage(MentorHelpRequestTicketsMessage message, EntitySessionEventArgs eventArgs)
{
// Client doesn't handle this directly
}
protected override void OnUnassignTicketMessage(MentorHelpUnassignTicketMessage message, EntitySessionEventArgs eventArgs)
{
// Client doesn't handle this directly
}
protected override void OnRequestStatisticsMessage(MentorHelpRequestStatisticsMessage message, EntitySessionEventArgs eventArgs)
{
// Client doesn't handle this directly
}
public override void Initialize()
{
base.Initialize();
SubscribeNetworkEvent<MentorHelpTicketUpdateMessage>(OnTicketUpdate);
SubscribeNetworkEvent<MentorHelpTicketsListMessage>(OnTicketsList);
SubscribeNetworkEvent<MentorHelpTicketMessagesMessage>(OnTicketMessages);
SubscribeNetworkEvent<MentorHelpStatisticsMessage>(OnStatistics);
SubscribeNetworkEvent<MentorHelpOpenTicketMessage>(OnOpenTicket);
}
private void OnOpenTicket(MentorHelpOpenTicketMessage message, EntitySessionEventArgs eventArgs)
{
OnOpenTicketReceived?.Invoke(this, message);
}
private void OnTicketUpdate(MentorHelpTicketUpdateMessage message, EntitySessionEventArgs eventArgs)
{
OnTicketUpdated?.Invoke(this, message);
}
private void OnTicketsList(MentorHelpTicketsListMessage message, EntitySessionEventArgs eventArgs)
{
OnTicketsListReceived?.Invoke(this, message);
}
private void OnTicketMessages(MentorHelpTicketMessagesMessage message, EntitySessionEventArgs eventArgs)
{
OnTicketMessagesReceived?.Invoke(this, message);
}
/// <summary>
/// Create a new mentor help ticket
/// </summary>
private void OnStatistics(MentorHelpStatisticsMessage message, EntitySessionEventArgs eventArgs)
{
OnStatisticsReceived?.Invoke(this, message);
}
public void CreateTicket(string subject, string message)
{
RaiseNetworkEvent(new MentorHelpCreateTicketMessage(subject, message));
}
/// <summary>
/// Claim a mentor help ticket
/// </summary>
public void ClaimTicket(int ticketId)
{
RaiseNetworkEvent(new MentorHelpClaimTicketMessage(ticketId));
}
/// <summary>
/// Unassign a mentor help ticket
/// </summary>
public void UnassignTicket(int ticketId)
{
RaiseNetworkEvent(new MentorHelpUnassignTicketMessage(ticketId));
}
/// <summary>
/// Reply to a mentor help ticket
/// </summary>
public void ReplyToTicket(int ticketId, string message, bool isStaffOnly = false)
{
RaiseNetworkEvent(new MentorHelpReplyMessage(ticketId, message, isStaffOnly));
}
/// <summary>
/// Close a mentor help ticket
/// </summary>
public void CloseTicket(int ticketId)
{
RaiseNetworkEvent(new MentorHelpCloseTicketMessage(ticketId));
}
/// <summary>
/// Request tickets (either all for mentors/admins, or only own for players)
/// </summary>
public void RequestTickets(bool onlyMine = false)
{
RaiseNetworkEvent(new MentorHelpRequestTicketsMessage(onlyMine));
}
/// <summary>
/// Request messages for a specific ticket
/// </summary>
public void RequestTicketMessages(int ticketId)
{
// Send a request to the server to fetch messages for the given ticket
RaiseNetworkEvent(new MentorHelpRequestTicketMessagesMessage(ticketId));
}
/// <summary>
/// Request mentor help statistics
/// </summary>
public void RequestStatistics()
{
RaiseNetworkEvent(new MentorHelpRequestStatisticsMessage());
}
}
}

View file

@ -0,0 +1,446 @@
using Content.Client.Administration.Managers;
using Content.Client.Gameplay;
using Content.Client.Lobby;
using Content.Client.Lobby.UI;
using Content.Client.UserInterface.Controls;
using Content.Client.UserInterface.Systems.MenuBar.Widgets;
using Content.Shared._Sunrise.MentorHelp;
using Content.Shared._Sunrise.SunriseCCVars;
using Content.Shared.Administration;
using Content.Shared.Input;
using JetBrains.Annotations;
using Robust.Client.Audio;
using Robust.Client.Graphics;
using Robust.Client.Player;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controllers;
using Robust.Client.UserInterface.Controls;
using Robust.Shared.Configuration;
using Robust.Shared.Input.Binding;
using Robust.Shared.Network;
using Robust.Shared.Player;
namespace Content.Client._Sunrise.MentorHelp;
/// <summary>
/// UI controller for mentor help system
/// </summary>
[UsedImplicitly]
public sealed class MentorHelpUIController : UIController, IOnSystemChanged<MentorHelpSystem>, IOnStateChanged<GameplayState>, IOnStateChanged<LobbyState>
{
[Dependency] private readonly IClientAdminManager _adminManager = default!;
[Dependency] private readonly IConfigurationManager _config = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IClyde _clyde = default!;
[Dependency] private readonly IUserInterfaceManager _uiManager = default!;
[UISystemDependency] private readonly AudioSystem _audio = default!;
private MentorHelpSystem? _mentorHelpSystem;
public IMentorHelpUIHandler? UIHelper;
private bool _hasMentorPermissions;
private bool _hasUnreadTickets;
private bool _mentorHelpSoundEnabled;
private string? _mentorHelpSound;
private Button? LobbyMHelpButton => (UIManager.ActiveScreen as LobbyGui)?.MHelpButton;
private MenuButton? GameMHelpButton => UIManager.GetActiveUIWidgetOrNull<GameTopMenuBar>()?.MHelpButton;
protected override string SawmillName => "c.s.go.es.mhelp";
public override void Initialize()
{
base.Initialize();
_adminManager.AdminStatusUpdated += OnAdminStatusUpdated;
_config.OnValueChanged(SunriseCCVars.MentorHelpSound, v => _mentorHelpSound = v, true); // Reuse ahelp sound for now
_config.OnValueChanged(SunriseCCVars.MentorHelpSoundEnabled, v => _mentorHelpSoundEnabled = v, true);
}
public void OnSystemLoaded(MentorHelpSystem system)
{
_mentorHelpSystem = system;
_mentorHelpSystem.OnTicketUpdated += OnTicketUpdated;
_mentorHelpSystem.OnTicketsListReceived += OnTicketsListReceived;
_mentorHelpSystem.OnTicketMessagesReceived += OnTicketMessagesReceived;
_mentorHelpSystem.OnOpenTicketReceived += OnOpenTicketReceived;
CommandBinds.Builder
.Bind(ContentKeyFunctions.OpenMentorHelp,
InputCmdHandler.FromDelegate(_ => ToggleWindow()))
.Register<MentorHelpUIController>();
}
private void OnOpenTicketReceived(object? sender, MentorHelpOpenTicketMessage message)
{
EnsureUIHelper();
// Open the window and instruct UI to open the specific ticket
Open();
UIHelper?.OpenTicket(message.TicketId);
}
public void OnSystemUnloaded(MentorHelpSystem system)
{
CommandBinds.Unregister<MentorHelpUIController>();
if (_mentorHelpSystem != null)
{
_mentorHelpSystem.OnTicketUpdated -= OnTicketUpdated;
_mentorHelpSystem.OnTicketsListReceived -= OnTicketsListReceived;
_mentorHelpSystem.OnTicketMessagesReceived -= OnTicketMessagesReceived;
_mentorHelpSystem = null;
}
if (GameMHelpButton != null)
GameMHelpButton.OnPressed -= MHelpButtonPressed;
if (LobbyMHelpButton != null)
LobbyMHelpButton.OnPressed -= MHelpButtonPressed;
}
public void OnStateEntered(GameplayState state)
{
EnsureUIHelper();
SubscribeToButtons();
}
public void OnStateExited(GameplayState state)
{
// Keep UI helper for potential return to game
}
public void OnStateEntered(LobbyState state)
{
EnsureUIHelper();
SubscribeToButtons();
}
public void OnStateExited(LobbyState state)
{
// Keep UI helper for potential return to lobby
}
private void SubscribeToButtons()
{
if (GameMHelpButton != null)
GameMHelpButton.OnPressed += MHelpButtonPressed;
if (LobbyMHelpButton != null)
LobbyMHelpButton.OnPressed += MHelpButtonPressed;
}
private void MHelpButtonPressed(BaseButton.ButtonEventArgs obj)
{
ToggleWindow();
}
private void OnAdminStatusUpdated()
{
_hasMentorPermissions = _adminManager.HasFlag(AdminFlags.Mentor);
if (UIHelper is not { IsOpen: true })
return;
EnsureUIHelper();
}
private void OnTicketUpdated(object? sender, MentorHelpTicketUpdateMessage message)
{
if (_mentorHelpSound != null && _mentorHelpSoundEnabled)
{
_audio.PlayGlobal(_mentorHelpSound, Filter.Local(), false);
_clyde.RequestWindowAttention();
}
EnsureUIHelper();
if (!UIHelper!.IsOpen)
{
UnreadTicketReceived();
}
UIHelper!.TicketUpdated(message.Ticket);
}
private void OnTicketsListReceived(object? sender, MentorHelpTicketsListMessage message)
{
EnsureUIHelper();
UIHelper!.TicketsListReceived(message.Tickets);
}
private void OnTicketMessagesReceived(object? sender, MentorHelpTicketMessagesMessage message)
{
EnsureUIHelper();
UIHelper!.TicketMessagesReceived(message.TicketId, message.Messages);
}
public void EnsureUIHelper()
{
var hasMentorPerms = _adminManager.HasFlag(AdminFlags.Mentor);
if (UIHelper != null && UIHelper.HasMentorPermissions == hasMentorPerms)
return;
UIHelper?.Dispose();
var ownerUserId = _playerManager.LocalUser!.Value;
UIHelper = hasMentorPerms
? new MentorMentorHelpUIHandler(ownerUserId, _mentorHelpSystem)
: new PlayerMentorHelpUIHandler(ownerUserId, _mentorHelpSystem);
UIHelper.OnClose += () => { SetMentorHelpPressed(false); };
}
/// <summary>
/// Open the mentor help window
/// </summary>
public void Open()
{
EnsureUIHelper();
UIHelper!.OpenWindow();
SetMentorHelpPressed(true);
}
/// <summary>
/// Toggle the mentor help window
/// </summary>
public void ToggleWindow()
{
EnsureUIHelper();
if (UIHelper!.IsOpen)
{
UIHelper.CloseWindow();
SetMentorHelpPressed(false);
}
else
{
UIHelper.OpenWindow();
SetMentorHelpPressed(true);
}
}
/// <summary>
/// Close the mentor help window
/// </summary>
public void Close()
{
UIHelper?.CloseWindow();
SetMentorHelpPressed(false);
}
private void SetMentorHelpPressed(bool pressed)
{
UIManager.ClickSound();
UnreadTicketRead();
if (GameMHelpButton != null)
{
GameMHelpButton.Pressed = pressed;
}
if (LobbyMHelpButton != null)
{
LobbyMHelpButton.Pressed = pressed;
}
}
private void UnreadTicketReceived()
{
_hasUnreadTickets = true;
UpdateButtonStyling();
}
private void UnreadTicketRead()
{
_hasUnreadTickets = false;
UpdateButtonStyling();
}
private void UpdateButtonStyling()
{
if (_hasUnreadTickets)
{
GameMHelpButton?.StyleClasses.Add(MenuButton.StyleClassRedTopButton);
LobbyMHelpButton?.StyleClasses.Add("ButtonColorRed");
}
else
{
GameMHelpButton?.StyleClasses.Remove(MenuButton.StyleClassRedTopButton);
LobbyMHelpButton?.StyleClasses.Remove("ButtonColorRed");
}
}
}
/// <summary>
/// Interface for mentor help UI handlers
/// </summary>
public interface IMentorHelpUIHandler : IDisposable
{
bool IsOpen { get; }
bool HasMentorPermissions { get; }
event Action? OnClose;
void OpenWindow();
void CloseWindow();
void OpenTicket(int ticketId);
void TicketUpdated(MentorHelpTicketData ticket);
void TicketsListReceived(List<MentorHelpTicketData> tickets);
void TicketMessagesReceived(int ticketId, List<MentorHelpMessageData> messages);
}
public sealed class PlayerMentorHelpUIHandler : IMentorHelpUIHandler
{
public bool IsOpen { get; private set; }
public bool HasMentorPermissions => false;
public event Action? OnClose;
private readonly NetUserId _ownerUserId;
private readonly MentorHelpSystem? _mentorHelpSystem;
private MentorHelpWindow? _window;
public PlayerMentorHelpUIHandler(NetUserId ownerUserId, MentorHelpSystem? mentorHelpSystem)
{
_ownerUserId = ownerUserId;
_mentorHelpSystem = mentorHelpSystem;
}
public void OpenWindow()
{
if (_window != null)
{
_window.MoveToFront();
IsOpen = true;
return;
}
_window = new MentorHelpWindow();
_window.MentorHelp.Initialize(_mentorHelpSystem, _ownerUserId, false);
_window.OnClose += () =>
{
IsOpen = false;
OnClose?.Invoke();
_window = null;
};
_window.OpenCentered();
IsOpen = true;
_mentorHelpSystem?.RequestTickets(onlyMine: true);
}
public void OpenTicket(int ticketId)
{
// Ensure window is open
OpenWindow();
// Ask control to focus the ticket if possible
_window?.MentorHelp.TryOpenTicket(ticketId);
// Also request messages from server in case they're not loaded yet
_mentorHelpSystem?.RequestTicketMessages(ticketId);
}
public void CloseWindow()
{
_window?.Close();
IsOpen = false;
OnClose?.Invoke();
}
public void TicketUpdated(MentorHelpTicketData ticket)
{
_window?.MentorHelp.UpdateTicket(ticket);
}
public void TicketsListReceived(List<MentorHelpTicketData> tickets)
{
_window?.MentorHelp.UpdateTicketsList(tickets);
}
public void TicketMessagesReceived(int ticketId, List<MentorHelpMessageData> messages)
{
_window?.MentorHelp.UpdateTicketMessages(ticketId, messages);
}
public void Dispose()
{
CloseWindow();
}
}
/// <summary>
/// UI handler for mentors/admins (can see and manage all tickets)
/// </summary>
public sealed class MentorMentorHelpUIHandler : IMentorHelpUIHandler
{
public bool IsOpen { get; private set; }
public bool HasMentorPermissions => true;
public event Action? OnClose;
private readonly NetUserId _ownerUserId;
private readonly MentorHelpSystem? _mentorHelpSystem;
private MentorHelpWindow? _window;
public MentorMentorHelpUIHandler(NetUserId ownerUserId, MentorHelpSystem? mentorHelpSystem)
{
_ownerUserId = ownerUserId;
_mentorHelpSystem = mentorHelpSystem;
}
public void OpenWindow()
{
if (_window != null)
{
_window.MoveToFront();
IsOpen = true;
return;
}
_window = new MentorHelpWindow();
_window.MentorHelp.Initialize(_mentorHelpSystem, _ownerUserId, true);
_window.OnClose += () =>
{
IsOpen = false;
OnClose?.Invoke();
_window = null;
};
_window.OpenCentered();
IsOpen = true;
_mentorHelpSystem?.RequestTickets(onlyMine: false);
}
public void OpenTicket(int ticketId)
{
OpenWindow();
_window?.MentorHelp.TryOpenTicket(ticketId);
_mentorHelpSystem?.RequestTicketMessages(ticketId);
}
public void CloseWindow()
{
_window?.Close();
IsOpen = false;
OnClose?.Invoke();
}
public void TicketUpdated(MentorHelpTicketData ticket)
{
_window?.MentorHelp.UpdateTicket(ticket);
}
public void TicketsListReceived(List<MentorHelpTicketData> tickets)
{
_window?.MentorHelp.UpdateTicketsList(tickets);
}
public void TicketMessagesReceived(int ticketId, List<MentorHelpMessageData> messages)
{
_window?.MentorHelp.UpdateTicketMessages(ticketId, messages);
}
public void Dispose()
{
CloseWindow();
}
}

View file

@ -0,0 +1,8 @@
<DefaultWindow xmlns="https://spacestation14.io"
xmlns:mentorHelp="clr-namespace:Content.Client._Sunrise.MentorHelp"
SetSize="900 700"
HeaderClass="windowHeaderHelp"
TitleClass="windowTitleHelp"
Title="{Loc 'mentor-help-window-title'}" >
<mentorHelp:MentorHelpControl Name="MentorHelp" Access="Public"/>
</DefaultWindow>

View file

@ -0,0 +1,18 @@
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML;
namespace Content.Client._Sunrise.MentorHelp
{
/// <summary>
/// Mentor help window wrapper
/// </summary>
[GenerateTypedNameReferences]
public sealed partial class MentorHelpWindow : DefaultWindow
{
public MentorHelpWindow()
{
RobustXamlLoader.Load(this);
}
}
}

View file

@ -0,0 +1,35 @@
<PanelContainer xmlns="https://spacestation14.io"
xmlns:customControls="clr-namespace:Content.Client.Administration.UI.CustomControls"
Name="BackgroundColorPanel"
MouseFilter="Stop">
<BoxContainer Orientation="Vertical">
<customControls:HSeparator/>
<BoxContainer Orientation="Horizontal"
HorizontalExpand="True"
Margin="5 2">
<customControls:VSeparator/>
<Label Name="MentorNameLabel" Access="Public"
SizeFlagsStretchRatio="3"
HorizontalExpand="True"
StyleClasses="LabelSubText"
ClipText="True" />
<customControls:VSeparator/>
<Label Name="TicketsClaimedLabel" Access="Public"
SizeFlagsStretchRatio="2"
HorizontalExpand="True"
StyleClasses="LabelSubText"
Align="Center"
ClipText="True" />
<customControls:VSeparator/>
<Label Name="MessagesCountLabel" Access="Public"
SizeFlagsStretchRatio="2"
HorizontalExpand="True"
StyleClasses="LabelSubText"
Align="Center"
ClipText="True" />
<customControls:VSeparator/>
</BoxContainer>
<customControls:HSeparator/>
</BoxContainer>
</PanelContainer>

View file

@ -0,0 +1,46 @@
using Content.Shared._Sunrise.MentorHelp;
using Content.Shared.Administration;
using Robust.Client.AutoGenerated;
using Robust.Client.Graphics;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
namespace Content.Client._Sunrise.MentorHelp
{
[GenerateTypedNameReferences]
public sealed partial class StatisticsEntryControl : PanelContainer
{
private static readonly Color NormalColor = Color.FromHex("#202023");
private static readonly Color HoverColor = Color.FromHex("#2F2F33");
public StatisticsEntryControl()
{
RobustXamlLoader.Load(this);
TooltipDelay = 0.5f;
BackgroundColorPanel.PanelOverride = new StyleBoxFlat
{
BackgroundColor = NormalColor
};
BackgroundColorPanel.OnMouseEntered += args =>
{
var panel = (StyleBoxFlat)BackgroundColorPanel.PanelOverride!;
panel.BackgroundColor = HoverColor;
};
BackgroundColorPanel.OnMouseExited += args =>
{
var panel = (StyleBoxFlat)BackgroundColorPanel.PanelOverride!;
panel.BackgroundColor = NormalColor;
};
}
public void UpdateData(MentorHelpStatisticsData data)
{
MentorNameLabel.Text = data.MentorName;
TicketsClaimedLabel.Text = data.TicketsClaimed.ToString();
MessagesCountLabel.Text = data.MessagesCount.ToString();
}
}
}

View file

@ -0,0 +1,46 @@
<Control xmlns="https://spacestation14.io"
xmlns:customControls="clr-namespace:Content.Client.Administration.UI.CustomControls">
<PanelContainer Name="BackgroundColorPanel"
MouseFilter="Stop">
<BoxContainer Orientation="Vertical">
<customControls:HSeparator/>
<BoxContainer Orientation="Horizontal"
HorizontalExpand="True"
Margin="5 2">
<customControls:VSeparator/>
<Label Name="IdLabel" Access="Public"
SizeFlagsStretchRatio="1"
HorizontalExpand="True"
StyleClasses="LabelSubText"
ClipText="True" />
<customControls:VSeparator/>
<Label Name="PlayerLabel" Access="Public"
SizeFlagsStretchRatio="3"
HorizontalExpand="True"
StyleClasses="LabelSubText"
ClipText="True" />
<customControls:VSeparator/>
<Label Name="StatusLabel" Access="Public"
SizeFlagsStretchRatio="2"
HorizontalExpand="True"
StyleClasses="LabelSubText"
Align="Center"
ClipText="True" />
<customControls:VSeparator/>
<Label Name="AssignedLabel" Access="Public"
SizeFlagsStretchRatio="3"
HorizontalExpand="True"
StyleClasses="LabelSubText"
ClipText="True" />
<customControls:VSeparator/>
<Label Name="SubjectLabel" Access="Public"
SizeFlagsStretchRatio="5"
HorizontalExpand="True"
StyleClasses="LabelSubText"
ClipText="True" />
<customControls:VSeparator/>
</BoxContainer>
<customControls:HSeparator/>
</BoxContainer>
</PanelContainer>
</Control>

View file

@ -0,0 +1,83 @@
using Content.Shared._Sunrise.MentorHelp;
using Content.Shared.Database;
using Robust.Client.AutoGenerated;
using Robust.Client.Graphics;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Input;
namespace Content.Client._Sunrise.MentorHelp
{
[GenerateTypedNameReferences]
public sealed partial class TicketEntryControl : Control
{
private static readonly Color NormalColor = Color.FromHex("#202023");
private static readonly Color HoverColor = Color.FromHex("#2F2F33");
private MentorHelpTicketData? _ticketData;
public event Action<MentorHelpTicketData>? OnTicketSelected;
public TicketEntryControl()
{
RobustXamlLoader.Load(this);
TooltipDelay = 0.5f;
BackgroundColorPanel.PanelOverride = new StyleBoxFlat
{
BackgroundColor = NormalColor
};
BackgroundColorPanel.OnMouseEntered += args =>
{
var panel = (StyleBoxFlat)BackgroundColorPanel.PanelOverride!;
panel.BackgroundColor = HoverColor;
};
BackgroundColorPanel.OnMouseExited += args =>
{
var panel = (StyleBoxFlat)BackgroundColorPanel.PanelOverride!;
panel.BackgroundColor = NormalColor;
};
BackgroundColorPanel.OnKeyBindDown += args =>
{
if (args.Function != EngineKeyFunctions.Use)
return;
if (_ticketData != null)
OnTicketSelected?.Invoke(_ticketData);
};
}
public void UpdateData(MentorHelpTicketData ticketData)
{
_ticketData = ticketData;
IdLabel.Text = $"#{ticketData.Id}";
PlayerLabel.Text = ticketData.PlayerName;
var statusText = GetStatusText(ticketData.Status);
StatusLabel.Text = statusText;
AssignedLabel.Text = ticketData.AssignedToName ?? Loc.GetString("mentor-help-unassigned");
var subjectText = ticketData.Subject;
if (ticketData.HasUnreadMessages)
subjectText = "* " + subjectText;
SubjectLabel.Text = subjectText;
}
private string GetStatusText(MentorHelpTicketStatus status)
{
return status switch
{
MentorHelpTicketStatus.Open => Loc.GetString("mentor-help-status-open"),
MentorHelpTicketStatus.Assigned => Loc.GetString("mentor-help-status-assigned"),
MentorHelpTicketStatus.AwaitingResponse => Loc.GetString("mentor-help-status-awaiting"),
MentorHelpTicketStatus.Closed => Loc.GetString("mentor-help-status-closed"),
_ => Loc.GetString("mentor-help-status-unknown")
};
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,96 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Content.Server.Database.Migrations.Postgres
{
/// <inheritdoc />
public partial class MentorHelp : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "mentor_help_tickets",
columns: table => new
{
mentor_help_tickets_id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
player_id = table.Column<Guid>(type: "uuid", nullable: false),
assigned_to_user_id = table.Column<Guid>(type: "uuid", nullable: true),
subject = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
status = table.Column<int>(type: "integer", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
closed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
closed_by_user_id = table.Column<Guid>(type: "uuid", nullable: true),
round_id = table.Column<int>(type: "integer", nullable: true),
server_id = table.Column<int>(type: "integer", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_mentor_help_tickets", x => x.mentor_help_tickets_id);
});
migrationBuilder.CreateTable(
name: "mentor_help_messages",
columns: table => new
{
mentor_help_messages_id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ticket_id = table.Column<int>(type: "integer", nullable: false),
sender_user_id = table.Column<Guid>(type: "uuid", nullable: false),
message = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
sent_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
is_staff_only = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_mentor_help_messages", x => x.mentor_help_messages_id);
table.ForeignKey(
name: "FK_mentor_help_messages_mentor_help_tickets_ticket_id",
column: x => x.ticket_id,
principalTable: "mentor_help_tickets",
principalColumn: "mentor_help_tickets_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_mentor_help_messages_sent_at",
table: "mentor_help_messages",
column: "sent_at");
migrationBuilder.CreateIndex(
name: "IX_mentor_help_messages_ticket_id",
table: "mentor_help_messages",
column: "ticket_id");
migrationBuilder.CreateIndex(
name: "IX_mentor_help_tickets_assigned_to_user_id",
table: "mentor_help_tickets",
column: "assigned_to_user_id");
migrationBuilder.CreateIndex(
name: "IX_mentor_help_tickets_player_id",
table: "mentor_help_tickets",
column: "player_id");
migrationBuilder.CreateIndex(
name: "IX_mentor_help_tickets_status",
table: "mentor_help_tickets",
column: "status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "mentor_help_messages");
migrationBuilder.DropTable(
name: "mentor_help_tickets");
}
}
}

View file

@ -747,6 +747,115 @@ namespace Content.Server.Database.Migrations.Postgres
b.ToTable("job", (string)null);
});
modelBuilder.Entity("Content.Server.Database.MentorHelpMessage", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("mentor_help_messages_id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("IsStaffOnly")
.HasColumnType("boolean")
.HasColumnName("is_staff_only");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("message");
b.Property<Guid>("SenderUserId")
.HasColumnType("uuid")
.HasColumnName("sender_user_id");
b.Property<DateTimeOffset>("SentAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("sent_at");
b.Property<int>("TicketId")
.HasColumnType("integer")
.HasColumnName("ticket_id");
b.HasKey("Id")
.HasName("PK_mentor_help_messages");
b.HasIndex("SentAt")
.HasDatabaseName("IX_mentor_help_messages_sent_at");
b.HasIndex("TicketId")
.HasDatabaseName("IX_mentor_help_messages_ticket_id");
b.ToTable("mentor_help_messages", (string)null);
});
modelBuilder.Entity("Content.Server.Database.MentorHelpTicket", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("mentor_help_tickets_id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Guid?>("AssignedToUserId")
.HasColumnType("uuid")
.HasColumnName("assigned_to_user_id");
b.Property<DateTimeOffset?>("ClosedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("closed_at");
b.Property<Guid?>("ClosedByUserId")
.HasColumnType("uuid")
.HasColumnName("closed_by_user_id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("PlayerId")
.HasColumnType("uuid")
.HasColumnName("player_id");
b.Property<int?>("RoundId")
.HasColumnType("integer")
.HasColumnName("round_id");
b.Property<int?>("ServerId")
.HasColumnType("integer")
.HasColumnName("server_id");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<string>("Subject")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("subject");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("PK_mentor_help_tickets");
b.HasIndex("AssignedToUserId")
.HasDatabaseName("IX_mentor_help_tickets_assigned_to_user_id");
b.HasIndex("PlayerId")
.HasDatabaseName("IX_mentor_help_tickets_player_id");
b.HasIndex("Status")
.HasDatabaseName("IX_mentor_help_tickets_status");
b.ToTable("mentor_help_tickets", (string)null);
});
modelBuilder.Entity("Content.Server.Database.PlayTime", b =>
{
b.Property<int>("Id")
@ -1796,6 +1905,18 @@ namespace Content.Server.Database.Migrations.Postgres
b.Navigation("Profile");
});
modelBuilder.Entity("Content.Server.Database.MentorHelpMessage", b =>
{
b.HasOne("Content.Server.Database.MentorHelpTicket", "Ticket")
.WithMany()
.HasForeignKey("TicketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("FK_mentor_help_messages_mentor_help_tickets_ticket_id");
b.Navigation("Ticket");
});
modelBuilder.Entity("Content.Server.Database.Player", b =>
{
b.OwnsOne("Content.Server.Database.TypedHwid", "LastSeenHWId", b1 =>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,95 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Content.Server.Database.Migrations.Sqlite
{
/// <inheritdoc />
public partial class MentorHelp : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "mentor_help_tickets",
columns: table => new
{
mentor_help_tickets_id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
player_id = table.Column<Guid>(type: "TEXT", nullable: false),
assigned_to_user_id = table.Column<Guid>(type: "TEXT", nullable: true),
subject = table.Column<string>(type: "TEXT", maxLength: 512, nullable: false),
status = table.Column<int>(type: "INTEGER", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
updated_at = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
closed_at = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
closed_by_user_id = table.Column<Guid>(type: "TEXT", nullable: true),
round_id = table.Column<int>(type: "INTEGER", nullable: true),
server_id = table.Column<int>(type: "INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_mentor_help_tickets", x => x.mentor_help_tickets_id);
});
migrationBuilder.CreateTable(
name: "mentor_help_messages",
columns: table => new
{
mentor_help_messages_id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ticket_id = table.Column<int>(type: "INTEGER", nullable: false),
sender_user_id = table.Column<Guid>(type: "TEXT", nullable: false),
message = table.Column<string>(type: "TEXT", maxLength: 4096, nullable: false),
sent_at = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
is_staff_only = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_mentor_help_messages", x => x.mentor_help_messages_id);
table.ForeignKey(
name: "FK_mentor_help_messages_mentor_help_tickets_ticket_id",
column: x => x.ticket_id,
principalTable: "mentor_help_tickets",
principalColumn: "mentor_help_tickets_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_mentor_help_messages_sent_at",
table: "mentor_help_messages",
column: "sent_at");
migrationBuilder.CreateIndex(
name: "IX_mentor_help_messages_ticket_id",
table: "mentor_help_messages",
column: "ticket_id");
migrationBuilder.CreateIndex(
name: "IX_mentor_help_tickets_assigned_to_user_id",
table: "mentor_help_tickets",
column: "assigned_to_user_id");
migrationBuilder.CreateIndex(
name: "IX_mentor_help_tickets_player_id",
table: "mentor_help_tickets",
column: "player_id");
migrationBuilder.CreateIndex(
name: "IX_mentor_help_tickets_status",
table: "mentor_help_tickets",
column: "status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "mentor_help_messages");
migrationBuilder.DropTable(
name: "mentor_help_tickets");
}
}
}

View file

@ -707,6 +707,111 @@ namespace Content.Server.Database.Migrations.Sqlite
b.ToTable("job", (string)null);
});
modelBuilder.Entity("Content.Server.Database.MentorHelpMessage", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("mentor_help_messages_id");
b.Property<bool>("IsStaffOnly")
.HasColumnType("INTEGER")
.HasColumnName("is_staff_only");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT")
.HasColumnName("message");
b.Property<Guid>("SenderUserId")
.HasColumnType("TEXT")
.HasColumnName("sender_user_id");
b.Property<DateTimeOffset>("SentAt")
.HasColumnType("TEXT")
.HasColumnName("sent_at");
b.Property<int>("TicketId")
.HasColumnType("INTEGER")
.HasColumnName("ticket_id");
b.HasKey("Id")
.HasName("PK_mentor_help_messages");
b.HasIndex("SentAt")
.HasDatabaseName("IX_mentor_help_messages_sent_at");
b.HasIndex("TicketId")
.HasDatabaseName("IX_mentor_help_messages_ticket_id");
b.ToTable("mentor_help_messages", (string)null);
});
modelBuilder.Entity("Content.Server.Database.MentorHelpTicket", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("mentor_help_tickets_id");
b.Property<Guid?>("AssignedToUserId")
.HasColumnType("TEXT")
.HasColumnName("assigned_to_user_id");
b.Property<DateTimeOffset?>("ClosedAt")
.HasColumnType("TEXT")
.HasColumnName("closed_at");
b.Property<Guid?>("ClosedByUserId")
.HasColumnType("TEXT")
.HasColumnName("closed_by_user_id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<Guid>("PlayerId")
.HasColumnType("TEXT")
.HasColumnName("player_id");
b.Property<int?>("RoundId")
.HasColumnType("INTEGER")
.HasColumnName("round_id");
b.Property<int?>("ServerId")
.HasColumnType("INTEGER")
.HasColumnName("server_id");
b.Property<int>("Status")
.HasColumnType("INTEGER")
.HasColumnName("status");
b.Property<string>("Subject")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT")
.HasColumnName("subject");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("PK_mentor_help_tickets");
b.HasIndex("AssignedToUserId")
.HasDatabaseName("IX_mentor_help_tickets_assigned_to_user_id");
b.HasIndex("PlayerId")
.HasDatabaseName("IX_mentor_help_tickets_player_id");
b.HasIndex("Status")
.HasDatabaseName("IX_mentor_help_tickets_status");
b.ToTable("mentor_help_tickets", (string)null);
});
modelBuilder.Entity("Content.Server.Database.PlayTime", b =>
{
b.Property<int>("Id")
@ -1717,6 +1822,18 @@ namespace Content.Server.Database.Migrations.Sqlite
b.Navigation("Profile");
});
modelBuilder.Entity("Content.Server.Database.MentorHelpMessage", b =>
{
b.HasOne("Content.Server.Database.MentorHelpTicket", "Ticket")
.WithMany()
.HasForeignKey("TicketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("FK_mentor_help_messages_mentor_help_tickets_ticket_id");
b.Navigation("Ticket");
});
modelBuilder.Entity("Content.Server.Database.Player", b =>
{
b.OwnsOne("Content.Server.Database.TypedHwid", "LastSeenHWId", b1 =>

View file

@ -47,6 +47,8 @@ namespace Content.Server.Database
public DbSet<BanTemplate> BanTemplate { get; set; } = null!;
public DbSet<IPIntelCache> IPIntelCache { get; set; } = null!;
public DbSet<AHelpMessage> AHelpMessages { get; set; } = default!;
public DbSet<MentorHelpTicket> MentorHelpTickets { get; set; } = default!;
public DbSet<MentorHelpMessage> MentorHelpMessages { get; set; } = default!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@ -1365,4 +1367,107 @@ namespace Content.Server.Database
public bool PlaySound { get; set; }
public bool AdminOnly { get; set; }
}
/// <summary>
/// Represents a mentor help ticket
/// </summary>
[Table("mentor_help_tickets"), Index(nameof(PlayerId)), Index(nameof(AssignedToUserId)), Index(nameof(Status))]
public class MentorHelpTicket
{
[Key]
public int Id { get; set; }
/// <summary>
/// The player who created the ticket
/// </summary>
[ForeignKey("Player")]
public Guid PlayerId { get; set; }
/// <summary>
/// The mentor/admin who claimed this ticket (null if unclaimed)
/// </summary>
[ForeignKey("Player")]
public Guid? AssignedToUserId { get; set; }
/// <summary>
/// Subject/title of the ticket
/// </summary>
[Required, MaxLength(256)]
public string Subject { get; set; } = string.Empty;
/// <summary>
/// Current status of the ticket
/// </summary>
public MentorHelpTicketStatus Status { get; set; } = MentorHelpTicketStatus.Open;
/// <summary>
/// When the ticket was created
/// </summary>
public DateTimeOffset CreatedAt { get; set; }
/// <summary>
/// When the ticket was last updated
/// </summary>
public DateTimeOffset UpdatedAt { get; set; }
/// <summary>
/// When the ticket was closed (null if still open)
/// </summary>
public DateTimeOffset? ClosedAt { get; set; }
/// <summary>
/// Who closed the ticket
/// </summary>
[ForeignKey("Player")]
public Guid? ClosedByUserId { get; set; }
/// <summary>
/// Round ID when the ticket was created
/// </summary>
public int? RoundId { get; set; }
/// <summary>
/// Server ID where the ticket was created
/// </summary>
public int? ServerId { get; set; }
}
/// <summary>
/// Represents a message in a mentor help ticket
/// </summary>
[Table("mentor_help_messages"), Index(nameof(TicketId)), Index(nameof(SentAt))]
public class MentorHelpMessage
{
[Key]
public int Id { get; set; }
/// <summary>
/// The ticket this message belongs to
/// </summary>
[ForeignKey("MentorHelpTicket")]
public int TicketId { get; set; }
public MentorHelpTicket Ticket { get; set; } = null!;
/// <summary>
/// Who sent this message
/// </summary>
[ForeignKey("Player")]
public Guid SenderUserId { get; set; }
/// <summary>
/// The message content
/// </summary>
[Required, MaxLength(4096)]
public string Message { get; set; } = string.Empty;
/// <summary>
/// When the message was sent
/// </summary>
public DateTimeOffset SentAt { get; set; }
/// <summary>
/// Whether this message is only visible to mentors/admins
/// </summary>
public bool IsStaffOnly { get; set; } = false;
}
}

View file

@ -0,0 +1,64 @@
using Content.Server.Administration.Managers;
using Content.Server.Afk;
using Content.Shared.Administration;
using Robust.Shared.Utility;
namespace Content.Server.Administration.Systems;
/// <summary>
/// Server system for handling admin who requests
/// </summary>
public sealed class AdminWhoSystem : EntitySystem
{
[Dependency] private readonly IAfkManager _afkManager = default!;
[Dependency] private readonly IAdminManager _adminManager = default!;
public override void Initialize()
{
base.Initialize();
SubscribeNetworkEvent<RequestAdminWhoEvent>(OnRequestAdminWho);
}
private void OnRequestAdminWho(RequestAdminWhoEvent args, EntitySessionEventArgs session)
{
if (session.SenderSession == null)
return;
var seeStealth = false;
var seeAfk = false;
// Check if the requesting player can see stealth admins and AFK status
if (session.SenderSession.AttachedEntity != null)
{
var playerData = _adminManager.GetAdminData(session.SenderSession);
if (playerData != null)
{
seeStealth = playerData.CanStealth();
seeAfk = _adminManager.HasAdminFlag(session.SenderSession, AdminFlags.Admin);
}
}
var adminList = new List<AdminWhoEntry>();
foreach (var admin in _adminManager.ActiveAdmins)
{
var adminData = _adminManager.GetAdminData(admin);
DebugTools.AssertNotNull(adminData);
if (adminData!.Stealth && !seeStealth)
continue;
var isAfk = seeAfk && _afkManager.IsAfk(admin);
adminList.Add(new AdminWhoEntry(
admin.Name,
adminData.Title,
adminData.Stealth,
isAfk
));
}
RaiseNetworkEvent(new AdminWhoResponseEvent(adminList), session.SenderSession);
}
}

View file

@ -408,7 +408,7 @@ namespace Content.Server.Administration.Systems
if (senderAdmin is not null &&
senderAdmin.Value.dat.Flags ==
AdminFlags.Adminhelp) // Mentor. Not full admin. That's why it's colored differently.
AdminFlags.Mentor) // Mentor. Not full admin. That's why it's colored differently.
{
bwoinkText = $"[color=purple]{adminPrefix}{username}[/color]";
}
@ -762,8 +762,12 @@ namespace Content.Server.Administration.Systems
// Based on Starlight Build: https://github.com/ss14Starlight/space-station-14/pull/85
var currentTime = _timing.RealTime;
if (IsOnCooldown(message.UserId, currentTime))
if (IsOnCooldown(message.UserId, currentTime, out var remainingCooldown))
{
// Send cooldown feedback to the client
RaiseNetworkEvent(new BwoinkCooldownMessage(remainingCooldown), senderSession.Channel);
return;
}
if (IsSpam(message.UserId, message.Text))
_banManager.CreateServerBan(senderSession.UserId, senderSession.Name, null, null, null, 180, NoteSeverity.High, Loc.GetString("ahelp-antispam-ban-reason"));
@ -1019,14 +1023,31 @@ namespace Content.Server.Administration.Systems
}
}
private bool IsOnCooldown(NetUserId channelId, TimeSpan currentTime)
private bool IsOnCooldown(NetUserId channelId, TimeSpan currentTime, out TimeSpan remainingCooldown)
{
remainingCooldown = TimeSpan.Zero;
var lastMessage = _recentMessages
.Where(msg => msg.Channel == channelId)
.OrderByDescending(msg => msg.Timestamp)
.FirstOrDefault();
return lastMessage != default && (currentTime - lastMessage.Timestamp) < _messageCooldown;
if (lastMessage == default)
return false;
var timeSinceLastMessage = currentTime - lastMessage.Timestamp;
if (timeSinceLastMessage < _messageCooldown)
{
remainingCooldown = _messageCooldown - timeSinceLastMessage;
return true;
}
return false;
}
private bool IsOnCooldown(NetUserId channelId, TimeSpan currentTime)
{
return IsOnCooldown(channelId, currentTime, out _);
}
private bool IsSpam(NetUserId channelId, string text)

View file

@ -497,7 +497,7 @@ public sealed partial class ChatSystem : SharedChatSystem
// Sunrise-end
// Sunrise-start - Use speaker network for station announcements
if (playTts && (playDefault || announcementSound != null))
if (playTts)
{
if (playDefault && announcementSound == null)
announcementSound = new SoundPathSpecifier(DefaultAnnouncementSound);

View file

@ -80,6 +80,24 @@ namespace Content.Server.Communications
// Sunrise-Start
[DataField("announceVoice", customTypeSerializer:typeof(PrototypeIdSerializer<TTSVoicePrototype>))]
public string AnnounceVoice = "Hanson";
// Sunrise-Start
[ViewVariables]
public bool IsRelaying;
[ViewVariables]
public float RelayTimeRemaining;
[ViewVariables]
public float RelayCooldownRemaining;
[DataField]
public float RelayDuration = 60f;
[DataField]
public float RelayCooldown = 300f;
[DataField]
public float RelayRange = 7f;
// Sunrise-End
}
}

View file

@ -3,11 +3,12 @@ using Content.Server.AlertLevel;
using Content.Server.Chat.Systems;
using Content.Server.DeviceNetwork.Systems;
using Content.Server.Popups;
using Content.Server.Power.EntitySystems;
using Content.Server.RoundEnd;
using Content.Server.Screens.Components;
using Content.Server.Shuttles.Systems;
using Content.Server.Station.Systems;
using Content.Shared._Sunrise.TTS; // Sunrise-edit
using Content.Shared._Sunrise.TTS;
using Content.Shared.Access.Components;
using Content.Shared.Access.Systems;
using Content.Shared.CCVar;
@ -18,6 +19,9 @@ using Content.Shared.DeviceNetwork;
using Content.Shared.DeviceNetwork.Components;
using Content.Shared.IdentityManagement;
using Content.Shared.Popups;
using Content.Shared.Power.EntitySystems;
using Content.Shared.Speech;
using Content.Shared.Speech.Components;
using Robust.Server.GameObjects;
using Robust.Shared.Configuration;
@ -55,6 +59,11 @@ namespace Content.Server.Communications
// On console init, set cooldown
SubscribeLocalEvent<CommunicationsConsoleComponent, MapInitEvent>(OnCommunicationsConsoleMapInit);
// Sunrise-Start
SubscribeLocalEvent<CommunicationsConsoleComponent, CommunicationsConsoleToggleRelayMessage>(OnToggleRelayMessage);
SubscribeLocalEvent<CommunicationsConsoleComponent, ListenEvent>(OnEntitySpokeNearbyRelay);
// Sunrise-End
}
public override void Update(float frameTime)
@ -68,6 +77,23 @@ namespace Content.Server.Communications
comp.AnnouncementCooldownRemaining -= frameTime;
}
// Sunrise-Start
if (comp.RelayCooldownRemaining > 0f)
comp.RelayCooldownRemaining -= frameTime;
if (comp.IsRelaying)
{
if (!this.IsPowered(uid, EntityManager))
StopRelay(uid, comp, announce: true);
else
{
comp.RelayTimeRemaining -= frameTime;
if (comp.RelayTimeRemaining <= 0f)
StopRelay(uid, comp, announce: true);
}
}
// Sunrise-End
comp.UIUpdateAccumulator += frameTime;
if (comp.UIUpdateAccumulator < UIUpdateInterval)
@ -159,13 +185,20 @@ namespace Content.Server.Communications
}
}
var canRelay = comp.RelayCooldownRemaining <= 0f && !comp.IsRelaying && this.IsPowered(uid, EntityManager); // Sunrise-Edit
_uiSystem.SetUiState(uid, CommunicationsConsoleUiKey.Key, new CommunicationsConsoleInterfaceState(
CanAnnounce(comp),
CanCallOrRecall(comp),
levels,
currentLevel,
currentDelay,
_roundEndSystem.ExpectedCountdownEnd
_roundEndSystem.ExpectedCountdownEnd,
// Sunrise-Start
canRelay,
comp.IsRelaying,
MathF.Max(0f, comp.RelayCooldownRemaining),
MathF.Max(0f, comp.RelayTimeRemaining)
// Sunrise-End
));
}
@ -338,6 +371,67 @@ namespace Content.Server.Communications
_roundEndSystem.CancelRoundEndCountdown(uid);
_adminLogger.Add(LogType.Action, LogImpact.High, $"{ToPrettyString(message.Actor):player} has recalled the shuttle.");
}
// Sunrise-Start
private void OnToggleRelayMessage(EntityUid uid, CommunicationsConsoleComponent comp, CommunicationsConsoleToggleRelayMessage message)
{
if (comp.IsRelaying)
{
StopRelay(uid, comp, announce: true);
return;
}
if (comp.RelayCooldownRemaining > 0f)
return;
if (!this.IsPowered(uid, EntityManager))
return;
comp.IsRelaying = true;
comp.RelayTimeRemaining = comp.RelayDuration;
UpdateCommsConsoleInterface(uid, comp);
EnsureComp<ActiveListenerComponent>(uid).Range = comp.RelayRange;
var startText = Loc.GetString("comms-console-relay-started");
var title = Loc.GetString(comp.Title);
_chatSystem.DispatchStationAnnouncement(uid, startText, sender: title, playDefault: true, playTts: true, colorOverride: comp.Color, announceVoice: comp.AnnounceVoice, announcementSound: comp.Sound);
}
private void OnEntitySpokeNearbyRelay(EntityUid uid, CommunicationsConsoleComponent comp, ListenEvent ev)
{
if (!this.IsPowered(uid, EntityManager))
return;
var voice = comp.AnnounceVoice;
if (TryComp<TTSComponent>(ev.Source, out var ttsComponent))
{
voice = ttsComponent.VoicePrototypeId;
}
_chatSystem.DispatchStationAnnouncement(uid, ev.Message, sender: Loc.GetString(comp.Title), playDefault: false, playTts: true, colorOverride: comp.Color, announceVoice: voice);
}
private void StopRelay(EntityUid uid, CommunicationsConsoleComponent comp, bool announce)
{
if (!comp.IsRelaying && comp.RelayCooldownRemaining > 0f)
{
UpdateCommsConsoleInterface(uid, comp);
return;
}
comp.IsRelaying = false;
comp.RelayTimeRemaining = 0f;
comp.RelayCooldownRemaining = comp.RelayCooldown;
UpdateCommsConsoleInterface(uid, comp);
RemCompDeferred<ActiveListenerComponent>(uid);
if (announce)
{
var stopText = Loc.GetString("comms-console-relay-stopped");
var title = Loc.GetString(comp.Title);
_chatSystem.DispatchStationAnnouncement(uid, stopText, sender: title, playDefault: true, playTts: true, colorOverride: comp.Color, announceVoice: comp.AnnounceVoice, announcementSound: comp.Sound);
}
}
// Sunrise-End
}
/// <summary>

View file

@ -9,6 +9,7 @@ using System.Threading.Tasks;
using Content.Server.Administration.Logs;
using Content.Server.Administration.Managers;
using Content.Shared._Sunrise.MarkingEffects;
using Content.Shared._Sunrise.MentorHelp;
using Content.Shared.Administration.Logs;
using Content.Shared.Construction.Prototypes;
using Content.Shared.Database;
@ -1373,8 +1374,8 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id}
MakePlayerRecord(ban.CreatedBy),
ban.BanTime,
MakePlayerRecord(ban.LastEditedBy),
ban.LastEditedAt,
ban.ExpirationTime,
NormalizeDatabaseTime(ban.LastEditedAt),
NormalizeDatabaseTime(ban.ExpirationTime),
ban.Hidden,
MakePlayerRecord(ban.Unban?.UnbanningAdmin == null
? null
@ -1415,8 +1416,8 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id}
MakePlayerRecord(ban.CreatedBy),
ban.BanTime,
MakePlayerRecord(ban.LastEditedBy),
ban.LastEditedAt,
ban.ExpirationTime,
NormalizeDatabaseTime(ban.LastEditedAt),
NormalizeDatabaseTime(ban.ExpirationTime),
ban.Hidden,
new [] { ban.RoleId.Replace(BanManager.JobPrefix, null) },
MakePlayerRecord(unbanningAdmin),
@ -1817,6 +1818,137 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id}
return messages;
}
# endregion
# region MentorHelp
public async Task AddMentorHelpTicketAsync(MentorHelpTicket ticket)
{
await using var db = await GetDb();
db.DbContext.MentorHelpTickets.Add(ticket);
await db.DbContext.SaveChangesAsync();
}
public async Task<MentorHelpTicket?> GetMentorHelpTicketAsync(int ticketId)
{
await using var db = await GetDb();
return await db.DbContext.MentorHelpTickets
.FirstOrDefaultAsync(t => t.Id == ticketId);
}
public async Task<List<MentorHelpStatistics>> GetMentorHelpStatisticsAsync()
{
await using var db = await GetDb();
// Получаем количество тикетов, взятых каждым ментором
var tickets = await db.DbContext.MentorHelpTickets
.Where(t => t.AssignedToUserId != null)
.GroupBy(t => t.AssignedToUserId!.Value)
.Select(g => new { MentorUserId = g.Key, TicketsClaimed = g.Count() })
.ToListAsync();
// Получаем количество сообщений, отправленных каждым ментором
var messages = await db.DbContext.MentorHelpMessages
.GroupBy(m => m.SenderUserId)
.Select(g => new { MentorUserId = g.Key, MessagesCount = g.Count() })
.ToListAsync();
// Объединяем статистику по MentorUserId
var stats = new Dictionary<Guid, MentorHelpStatistics>();
foreach (var t in tickets)
{
stats[t.MentorUserId] = new MentorHelpStatistics
{
MentorUserId = t.MentorUserId,
TicketsClaimed = t.TicketsClaimed,
MessagesCount = 0
};
}
foreach (var m in messages)
{
if (stats.TryGetValue(m.MentorUserId, out var stat))
{
stat.MessagesCount = m.MessagesCount;
stats[m.MentorUserId] = stat;
}
else
{
stats[m.MentorUserId] = new MentorHelpStatistics
{
MentorUserId = m.MentorUserId,
TicketsClaimed = 0,
MessagesCount = m.MessagesCount
};
}
}
return stats.Values.ToList();
}
public async Task UpdateMentorHelpTicketAsync(MentorHelpTicket ticket)
{
await using var db = await GetDb();
db.DbContext.MentorHelpTickets.Update(ticket);
await db.DbContext.SaveChangesAsync();
}
public async Task<List<MentorHelpTicket>> GetMentorHelpTicketsByPlayerAsync(Guid playerId)
{
await using var db = await GetDb();
return (await db.DbContext.MentorHelpTickets
.Where(t => t.PlayerId == playerId)
.ToListAsync())
.OrderByDescending(t => t.CreatedAt)
.ToList();
}
public async Task<List<MentorHelpTicket>> GetOpenMentorHelpTicketsAsync()
{
await using var db = await GetDb();
return (await db.DbContext.MentorHelpTickets
.Where(t => t.Status != MentorHelpTicketStatus.Closed)
.ToListAsync())
.OrderByDescending(t => t.UpdatedAt)
.ToList();
}
public async Task<List<MentorHelpTicket>> GetAssignedMentorHelpTicketsAsync(Guid mentorId)
{
await using var db = await GetDb();
return (await db.DbContext.MentorHelpTickets
.Where(t => t.AssignedToUserId == mentorId && t.Status != MentorHelpTicketStatus.Closed)
.ToListAsync())
.OrderByDescending(t => t.UpdatedAt)
.ToList();
}
public async Task<List<MentorHelpTicket>> GetClosedMentorHelpTicketsAsync()
{
await using var db = await GetDb();
return (await db.DbContext.MentorHelpTickets
.Where(t => t.Status == MentorHelpTicketStatus.Closed)
.ToListAsync())
.OrderByDescending(t => t.UpdatedAt)
.ToList();
}
public async Task AddMentorHelpMessageAsync(MentorHelpMessage message)
{
await using var db = await GetDb();
db.DbContext.MentorHelpMessages.Add(message);
await db.DbContext.SaveChangesAsync();
}
public async Task<List<MentorHelpMessage>> GetMentorHelpMessagesByTicketAsync(int ticketId)
{
await using var db = await GetDb();
return await db.DbContext.MentorHelpMessages
.Where(m => m.TicketId == ticketId)
.ToListAsync();
}
# endregion
// Sunrise-End

View file

@ -5,6 +5,7 @@ using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Content.Server.Administration.Logs;
using Content.Shared._Sunrise.MentorHelp;
using Content.Shared.Administration;
using Content.Shared.Administration.Logs;
using Content.Shared.CCVar;
@ -376,6 +377,21 @@ namespace Content.Server.Database
public Task<List<AHelpMessage>> GetAHelpMessagesByReceiverAsync(Guid receiverUserId);
#endregion
#region MentorHelp
Task<List<MentorHelpStatistics>> GetMentorHelpStatisticsAsync();
Task AddMentorHelpTicketAsync(MentorHelpTicket ticket);
Task<MentorHelpTicket?> GetMentorHelpTicketAsync(int ticketId);
Task UpdateMentorHelpTicketAsync(MentorHelpTicket ticket);
Task<List<MentorHelpTicket>> GetMentorHelpTicketsByPlayerAsync(Guid playerId);
Task<List<MentorHelpTicket>> GetOpenMentorHelpTicketsAsync();
Task<List<MentorHelpTicket>> GetAssignedMentorHelpTicketsAsync(Guid mentorId);
Task AddMentorHelpMessageAsync(MentorHelpMessage message);
Task<List<MentorHelpMessage>> GetMentorHelpMessagesByTicketAsync(int ticketId);
Task<List<MentorHelpTicket>> GetClosedMentorHelpTicketsAsync();
#endregion
// Sunrise-End
}
@ -1093,6 +1109,60 @@ namespace Content.Server.Database
return RunDbCommand(() => _db.GetAHelpMessagesByReceiverAsync(receiverUserId));
}
// MentorHelp implementations
public Task AddMentorHelpTicketAsync(MentorHelpTicket ticket)
{
DbWriteOpsMetric.Inc();
return RunDbCommand(() => _db.AddMentorHelpTicketAsync(ticket));
}
public Task<List<MentorHelpStatistics>> GetMentorHelpStatisticsAsync()
{
return RunDbCommand(() => _db.GetMentorHelpStatisticsAsync());
}
public Task<MentorHelpTicket?> GetMentorHelpTicketAsync(int ticketId)
{
return RunDbCommand(() => _db.GetMentorHelpTicketAsync(ticketId));
}
public Task UpdateMentorHelpTicketAsync(MentorHelpTicket ticket)
{
DbWriteOpsMetric.Inc();
return RunDbCommand(() => _db.UpdateMentorHelpTicketAsync(ticket));
}
public Task<List<MentorHelpTicket>> GetMentorHelpTicketsByPlayerAsync(Guid playerId)
{
return RunDbCommand(() => _db.GetMentorHelpTicketsByPlayerAsync(playerId));
}
public Task<List<MentorHelpTicket>> GetOpenMentorHelpTicketsAsync()
{
return RunDbCommand(() => _db.GetOpenMentorHelpTicketsAsync());
}
public Task<List<MentorHelpTicket>> GetAssignedMentorHelpTicketsAsync(Guid mentorId)
{
return RunDbCommand(() => _db.GetAssignedMentorHelpTicketsAsync(mentorId));
}
public Task AddMentorHelpMessageAsync(MentorHelpMessage message)
{
DbWriteOpsMetric.Inc();
return RunDbCommand(() => _db.AddMentorHelpMessageAsync(message));
}
public Task<List<MentorHelpMessage>> GetMentorHelpMessagesByTicketAsync(int ticketId)
{
return RunDbCommand(() => _db.GetMentorHelpMessagesByTicketAsync(ticketId));
}
public Task<List<MentorHelpTicket>> GetClosedMentorHelpTicketsAsync()
{
return RunDbCommand(() => _db.GetClosedMentorHelpTicketsAsync());
}
public void SubscribeToNotifications(Action<DatabaseNotification> handler)
{
lock (_notificationHandlers)

View file

@ -59,11 +59,10 @@ public sealed partial class EnergyDomeSystem : EntitySystem
SubscribeLocalEvent<EnergyDomeGeneratorComponent, ComponentRemove>(OnComponentRemove);
//Dome events
SubscribeLocalEvent<EnergyDomeComponent, DamageChangedEvent>(OnDomeDamaged);
SubscribeLocalEvent<EnergyDomeProtectedUserComponent, EntParentChangedMessage>(OnProtectedEntityParentChanged);
}
private void OnInit(Entity<EnergyDomeGeneratorComponent> generator, ref MapInitEvent args)
{
if (generator.Comp.CanDeviceNetworkUse)
@ -159,7 +158,6 @@ public sealed partial class EnergyDomeSystem : EntitySystem
if (args.Handled)
return;
// Sunrise-Start
if (!_containerSystem.ContainsEntity(args.Performer, generator.Owner))
return;
@ -168,7 +166,6 @@ public sealed partial class EnergyDomeSystem : EntitySystem
if (!_biocodeSystem.CanUse(args.Performer, biocodedComponent.Factions))
return;
}
// Sunrise-End
AttemptToggle(generator, !generator.Comp.Enabled);
@ -251,6 +248,12 @@ public sealed partial class EnergyDomeSystem : EntitySystem
public bool AttemptToggle(Entity<EnergyDomeGeneratorComponent> generator, bool status)
{
var parent = Transform(generator.Owner).ParentUid;
if (HasComp<ContainerManagerComponent>(Transform(parent).ParentUid))
{
return false;
}
if (TryComp<UseDelayComponent>(generator, out var useDelay) && _useDelay.IsDelayed(new (generator, useDelay)))
{
_audio.PlayPvs(generator.Comp.TurnOffSound, generator);
@ -321,6 +324,9 @@ public sealed partial class EnergyDomeSystem : EntitySystem
domeComp.Generator = generator;
}
var protectedComp = EnsureComp<EnergyDomeProtectedUserComponent>(protectedEntity);
protectedComp.DomeEntity = newDome;
if (TryComp<PowerCellDrawComponent>(generator.Owner, out var powerCellDrawComponent))
{
_powerCell.SetDrawEnabled(generator.Owner, true);
@ -335,6 +341,23 @@ public sealed partial class EnergyDomeSystem : EntitySystem
generator.Comp.Enabled = true;
}
// Sunrise: обработчик смены парента защищаемой сущности
private void OnProtectedEntityParentChanged(Entity<EnergyDomeProtectedUserComponent> ent, ref EntParentChangedMessage args)
{
if (ent.Comp.DomeEntity == null)
return;
if (HasComp<ContainerManagerComponent>(Transform(ent).ParentUid))
{
if (TryComp<EnergyDomeComponent>(ent.Comp.DomeEntity.Value, out var domeComp) && domeComp.Generator != null)
{
if (TryComp<EnergyDomeGeneratorComponent>(domeComp.Generator.Value, out var genComp))
{
TurnOff((domeComp.Generator.Value, genComp), false);
}
}
}
}
private void TurnOff(Entity<EnergyDomeGeneratorComponent> generator, bool startReloading)
{
if (!generator.Comp.Enabled)
@ -343,6 +366,11 @@ public sealed partial class EnergyDomeSystem : EntitySystem
generator.Comp.Enabled = false;
QueueDel(generator.Comp.SpawnedDome);
if (generator.Comp.DomeParentEntity != null && HasComp<EnergyDomeProtectedUserComponent>(generator.Comp.DomeParentEntity.Value))
{
RemCompDeferred<EnergyDomeProtectedUserComponent>(generator.Comp.DomeParentEntity.Value);
}
if (TryComp<PowerCellDrawComponent>(generator.Owner, out var powerCellDrawComponent))
{
_powerCell.SetDrawEnabled(generator.Owner, false);
@ -372,3 +400,4 @@ public sealed partial class EnergyDomeSystem : EntitySystem
: entity;
}
}

View file

@ -5,6 +5,7 @@ using Content.Server.Ghost;
using Content.Server.Roles.Jobs;
using Content.Shared.CCVar;
using Content.Shared.Ghost;
using Content.Shared.Mind;
using Content.Shared.Mind.Components;
using Content.Shared.Voting;
using Robust.Server.Player;
@ -14,6 +15,9 @@ using Robust.Shared.Player;
using Robust.Shared.Timing;
using System.Threading.Tasks;
using Content.Shared.Players.PlayTimeTracking;
using Content.Shared.Roles;
using Content.Shared.Roles.Components;
using Robust.Shared.Prototypes;
namespace Content.Server.Voting;
@ -28,6 +32,8 @@ public sealed class VotingSystem : EntitySystem
[Dependency] private readonly JobSystem _jobs = default!;
[Dependency] private readonly GameTicker _gameTicker = default!;
[Dependency] private readonly ISharedPlaytimeManager _playtimeManager = default!;
[Dependency] private readonly SharedRoleSystem _roles = default!;
[Dependency] private readonly IPrototypeManager _prototypes = default!;
public override void Initialize()
{
@ -76,12 +82,46 @@ public sealed class VotingSystem : EntitySystem
{
TryComp<MindContainerComponent>(attached, out var mind);
var jobName = _jobs.MindTryGetJobName(mind?.Mind);
var jobName = GetNonAntagJobName(mind?.Mind);
var playerInfo = $"{Comp<MetaDataComponent>(attached).EntityName} ({jobName})";
return playerInfo;
}
/// <summary>
/// Gets the job name for a mind, excluding antagonist roles to prevent revealing game mode information in voting.
/// Returns the first non-antagonist job role found, or "Unknown" if none exists.
/// </summary>
private string GetNonAntagJobName(EntityUid? mindId)
{
if (mindId == null)
return Loc.GetString("generic-unknown-title");
if (!TryComp<MindComponent>(mindId.Value, out var mindComponent))
return Loc.GetString("generic-unknown-title");
// Look for a job role that is NOT an antagonist
foreach (var roleEnt in mindComponent.MindRoleContainer.ContainedEntities)
{
if (!TryComp<MindRoleComponent>(roleEnt, out var roleComponent))
continue;
// Skip antagonist roles to prevent revealing game mode
if (roleComponent.Antag || roleComponent.ExclusiveAntag)
continue;
// We found a non-antagonist role with a job
if (roleComponent.JobPrototype != null &&
_prototypes.TryIndex(roleComponent.JobPrototype.Value, out var jobPrototype))
{
return jobPrototype.LocalizedName;
}
}
// Fallback: no non-antagonist job found
return Loc.GetString("generic-unknown-title");
}
/// <summary>
/// Used to check whether the player initiating a votekick is allowed to do so serverside.
/// </summary>

View file

@ -1,17 +1,11 @@
using Content.Server.Power.Components;
using Content.Server.Power.EntitySystems;
using Content.Server.PowerCell;
using Content.Server._Sunrise.EnergyShield;
using Content.Shared.Damage;
using Content.Shared.Item.ItemToggle.Components;
using Content.Shared.Item.ItemToggle;
using Content.Shared.Popups;
using Content.Shared.Examine;
using Content.Shared.Timing;
using Content.Shared.IdentityManagement;
using Content.Shared.PowerCell.Components;
using Robust.Shared.Audio.Systems;
using Robust.Shared.GameStates;
using Robust.Shared.Containers;
namespace Content.Server._Sunrise.EnergyShield;
@ -21,6 +15,7 @@ public sealed class EnergyShieldSystem : EntitySystem
[Dependency] private readonly ItemToggleSystem _itemToggle = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly SharedContainerSystem _container = default!;
public override void Initialize()
{

View file

@ -0,0 +1,653 @@
using System.Linq;
using System.Threading.Tasks;
using Content.Server.Administration.Managers;
using Content.Server.Database;
using Content.Server.GameTicking;
using Content.Server.Players.RateLimiting;
using Content.Shared._Sunrise.MentorHelp;
using Content.Shared._Sunrise.SunriseCCVars;
using Content.Shared.Administration;
using Content.Shared.Database;
using Content.Shared.Players.RateLimiting;
using Content.Sunrise.Interfaces.Shared;
using JetBrains.Annotations;
using Robust.Server.Player;
using Robust.Shared.Configuration;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Timing;
namespace Content.Server._Sunrise.MentorHelp
{
/// <summary>
/// Server-side mentor help system for managing tickets
/// </summary>
[UsedImplicitly]
public sealed class MentorHelpSystem : SharedMentorHelpSystem
{
private const string RateLimitKey = "MentorHelp";
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IAdminManager _adminManager = default!;
[Dependency] private readonly IConfigurationManager _config = default!;
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly GameTicker _gameTicker = default!;
[Dependency] private readonly IServerDbManager _dbManager = default!;
[Dependency] private readonly PlayerRateLimitManager _rateLimit = default!;
private ISharedSponsorsManager? _sponsorsManager; // Sunrise-Sponsors
private ISawmill _sawmill = default!;
private List<MentorHelpStatisticsData>? _mentorStatsCache;
private DateTimeOffset? _mentorStatsCacheTime;
private readonly float _mentorCacheInterval = 10;
public override void Initialize()
{
base.Initialize();
_sawmill = IoCManager.Resolve<ILogManager>().GetSawmill("MHELP");
_rateLimit.Register(
RateLimitKey,
new RateLimitRegistration(SunriseCCVars.MentorHelpRateLimitPeriod, // Reuse ahelp rate limit config
SunriseCCVars.MentorHelpRateLimitCount,
PlayerRateLimitedAction)
);
_playerManager.PlayerStatusChanged += OnPlayerStatusChanged;
IoCManager.Instance!.TryResolveType(out _sponsorsManager); // Sunrise-Sponsors
}
private void PlayerRateLimitedAction(ICommonSession session)
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) was rate limited for mentor help");
}
private void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e)
{
// Could notify mentors about player connection status for active tickets
// For now, keep it simple
}
protected override async void OnCreateTicketMessage(MentorHelpCreateTicketMessage message, EntitySessionEventArgs eventArgs)
{
var session = eventArgs.SenderSession;
// Rate limiting
if (_rateLimit.CountAction(session, RateLimitKey) != RateLimitStatus.Allowed)
return;
// Validate input
if (string.IsNullOrWhiteSpace(message.Subject) || string.IsNullOrWhiteSpace(message.Message))
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to create mentor help ticket with empty subject or message");
return;
}
if (message.Subject.Length > 256 || message.Message.Length > 4096)
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to create mentor help ticket with too long subject or message");
return;
}
try
{
var now = DateTimeOffset.UtcNow;
var ticket = new MentorHelpTicket
{
PlayerId = session.UserId.UserId,
Subject = message.Subject.Trim(),
Status = MentorHelpTicketStatus.Open,
CreatedAt = now,
UpdatedAt = now,
RoundId = _gameTicker.RoundId,
ServerId = await GetServerIdAsync()
};
await _dbManager.AddMentorHelpTicketAsync(ticket);
var ticketMessage = new MentorHelpMessage
{
TicketId = ticket.Id,
SenderUserId = session.UserId.UserId,
Message = message.Message.Trim(),
SentAt = now,
IsStaffOnly = false
};
await _dbManager.AddMentorHelpMessageAsync(ticketMessage);
_sawmill.Info($"Player {session.Name} ({session.UserId}) created mentor help ticket #{ticket.Id}: {ticket.Subject}");
// Notify player
var ticketData = await ConvertToTicketDataAsync(ticket);
RaiseNetworkEvent(new MentorHelpTicketUpdateMessage(ticketData), session.Channel);
// Instruct the player's client to open the newly created ticket
RaiseNetworkEvent(new MentorHelpOpenTicketMessage(ticket.Id), session.Channel);
// Notify mentors/admins
await NotifyMentorsOfNewTicket(ticketData);
}
catch (Exception ex)
{
_sawmill.Error($"Error creating mentor help ticket for {session.Name} ({session.UserId}): {ex}");
}
}
protected override async void OnClaimTicketMessage(MentorHelpClaimTicketMessage message, EntitySessionEventArgs eventArgs)
{
var session = eventArgs.SenderSession;
// Check permissions
if (!HasMentorPermissions(session))
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to claim mentor help ticket without permissions");
return;
}
try
{
var ticket = await _dbManager.GetMentorHelpTicketAsync(message.TicketId);
if (ticket == null)
{
_sawmill.Warning($"Mentor {session.Name} ({session.UserId}) tried to claim non-existent ticket #{message.TicketId}");
return;
}
if (ticket.Status == MentorHelpTicketStatus.Closed)
{
_sawmill.Warning($"Mentor {session.Name} ({session.UserId}) tried to claim closed ticket #{message.TicketId}");
return;
}
// Claim the ticket
ticket.AssignedToUserId = session.UserId.UserId;
ticket.Status = MentorHelpTicketStatus.Assigned;
ticket.UpdatedAt = DateTimeOffset.UtcNow;
await _dbManager.UpdateMentorHelpTicketAsync(ticket);
_sawmill.Info($"Mentor {session.Name} ({session.UserId}) claimed ticket #{ticket.Id}");
// Notify all relevant parties
var ticketData = await ConvertToTicketDataAsync(ticket);
await NotifyTicketUpdate(ticketData);
}
catch (Exception ex)
{
_sawmill.Error($"Error claiming mentor help ticket #{message.TicketId} by {session.Name} ({session.UserId}): {ex}");
}
}
protected override async void OnReplyMessage(MentorHelpReplyMessage message, EntitySessionEventArgs eventArgs)
{
var session = eventArgs.SenderSession;
// Rate limiting
if (_rateLimit.CountAction(session, RateLimitKey) != RateLimitStatus.Allowed)
return;
try
{
var ticket = await _dbManager.GetMentorHelpTicketAsync(message.TicketId);
if (ticket == null)
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to reply to non-existent ticket #{message.TicketId}");
return;
}
if (ticket.Status == MentorHelpTicketStatus.Closed)
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to reply to closed ticket #{message.TicketId}");
return;
}
// Check permissions - player can reply to their own ticket, mentors/admins can reply to any
var isTicketOwner = ticket.PlayerId == session.UserId.UserId;
var hasMentorPerms = HasMentorPermissions(session);
if (!isTicketOwner && !hasMentorPerms)
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to reply to ticket #{message.TicketId} without permissions");
return;
}
// Staff-only messages can only be sent by mentors/admins
if (message.IsStaffOnly && !hasMentorPerms)
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to send staff-only message without permissions");
return;
}
// Validate message
if (string.IsNullOrWhiteSpace(message.Message) || message.Message.Length > 4096)
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to send invalid message to ticket #{message.TicketId}");
return;
}
// Create the message
var ticketMessage = new MentorHelpMessage
{
TicketId = message.TicketId,
SenderUserId = session.UserId.UserId,
Message = message.Message.Trim(),
SentAt = DateTimeOffset.UtcNow,
IsStaffOnly = message.IsStaffOnly
};
await _dbManager.AddMentorHelpMessageAsync(ticketMessage);
// Update ticket status
if (hasMentorPerms && ticket.Status == MentorHelpTicketStatus.Open)
{
// Mentor replied to open ticket, mark as assigned
ticket.AssignedToUserId = session.UserId.UserId;
ticket.Status = MentorHelpTicketStatus.Assigned;
}
else if (hasMentorPerms)
{
// Mentor replied, awaiting player response
ticket.Status = MentorHelpTicketStatus.AwaitingResponse;
}
else if (isTicketOwner && ticket.Status == MentorHelpTicketStatus.AwaitingResponse)
{
// Player replied, mark as assigned again
ticket.Status = MentorHelpTicketStatus.Assigned;
}
ticket.UpdatedAt = DateTimeOffset.UtcNow;
await _dbManager.UpdateMentorHelpTicketAsync(ticket);
_sawmill.Info($"Player {session.Name} ({session.UserId}) replied to ticket #{message.TicketId}");
// Notify relevant parties
var ticketData = await ConvertToTicketDataAsync(ticket);
var messageData = await ConvertToMessageDataAsync(ticketMessage);
await NotifyTicketMessage(ticketData, messageData);
}
catch (Exception ex)
{
_sawmill.Error($"Error adding reply to mentor help ticket #{message.TicketId} by {session.Name} ({session.UserId}): {ex}");
}
}
protected override async void OnCloseTicketMessage(MentorHelpCloseTicketMessage message, EntitySessionEventArgs eventArgs)
{
var session = eventArgs.SenderSession;
try
{
var ticket = await _dbManager.GetMentorHelpTicketAsync(message.TicketId);
if (ticket == null)
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to close non-existent ticket #{message.TicketId}");
return;
}
if (ticket.Status == MentorHelpTicketStatus.Closed)
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to close already closed ticket #{message.TicketId}");
return;
}
// Check permissions - player can close their own ticket, mentors/admins can close any
var isTicketOwner = ticket.PlayerId == session.UserId.UserId;
var hasMentorPerms = HasMentorPermissions(session);
if (!isTicketOwner && !hasMentorPerms)
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to close ticket #{message.TicketId} without permissions");
return;
}
// Close the ticket
ticket.Status = MentorHelpTicketStatus.Closed;
ticket.ClosedAt = DateTimeOffset.UtcNow;
ticket.ClosedByUserId = session.UserId.UserId;
ticket.UpdatedAt = DateTimeOffset.UtcNow;
await _dbManager.UpdateMentorHelpTicketAsync(ticket);
_sawmill.Info($"Player {session.Name} ({session.UserId}) closed ticket #{ticket.Id}");
// Notify relevant parties
var ticketData = await ConvertToTicketDataAsync(ticket);
await NotifyTicketUpdate(ticketData);
}
catch (Exception ex)
{
_sawmill.Error($"Error closing mentor help ticket #{message.TicketId} by {session.Name} ({session.UserId}): {ex}");
}
}
protected override async void OnRequestTicketsMessage(MentorHelpRequestTicketsMessage message, EntitySessionEventArgs eventArgs)
{
var session = eventArgs.SenderSession;
try
{
List<MentorHelpTicket> tickets;
if (message.OnlyMine)
{
// Player requesting their own tickets (both open and closed)
tickets = await _dbManager.GetMentorHelpTicketsByPlayerAsync(session.UserId.UserId);
}
else
{
// Mentor/admin requesting all tickets (both open and closed)
if (!HasMentorPermissions(session))
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to request all mentor help tickets without permissions");
return;
}
// Get both open and closed tickets for mentors
var openTickets = await _dbManager.GetOpenMentorHelpTicketsAsync();
var closedTickets = await _dbManager.GetClosedMentorHelpTicketsAsync();
tickets = openTickets.Concat(closedTickets).ToList();
}
var ticketDataList = new List<MentorHelpTicketData>();
foreach (var ticket in tickets)
{
ticketDataList.Add(await ConvertToTicketDataAsync(ticket));
}
RaiseNetworkEvent(new MentorHelpTicketsListMessage(ticketDataList), session.Channel);
}
catch (Exception ex)
{
_sawmill.Error($"Error requesting mentor help tickets for {session.Name} ({session.UserId}): {ex}");
}
}
protected override async void OnUnassignTicketMessage(MentorHelpUnassignTicketMessage message, EntitySessionEventArgs eventArgs)
{
var session = eventArgs.SenderSession;
if (!HasMentorPermissions(session))
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to unassign mentor help ticket without permissions");
return;
}
try
{
var ticket = await _dbManager.GetMentorHelpTicketAsync(message.TicketId);
if (ticket == null)
{
_sawmill.Warning($"Mentor {session.Name} ({session.UserId}) tried to unassign non-existent ticket #{message.TicketId}");
return;
}
if (ticket.Status == MentorHelpTicketStatus.Closed)
{
_sawmill.Warning($"Mentor {session.Name} ({session.UserId}) tried to unassign closed ticket #{message.TicketId}");
return;
}
ticket.AssignedToUserId = null;
ticket.Status = MentorHelpTicketStatus.Open;
ticket.UpdatedAt = DateTimeOffset.UtcNow;
await _dbManager.UpdateMentorHelpTicketAsync(ticket);
_sawmill.Info($"Mentor {session.Name} ({session.UserId}) unassigned ticket #{ticket.Id}");
var ticketData = await ConvertToTicketDataAsync(ticket);
await NotifyTicketUpdate(ticketData);
}
catch (Exception ex)
{
_sawmill.Error($"Error unassigning mentor help ticket #{message.TicketId} by {session.Name} ({session.UserId}): {ex}");
}
}
private bool HasMentorPermissions(ICommonSession session)
{
var adminData = _adminManager.GetAdminData(session);
return adminData?.HasFlag(AdminFlags.Mentor) ?? false;
}
private async Task<int?> GetServerIdAsync()
{
// Implementation would depend on how server ID is tracked
// For now, return null
return null;
}
protected override async void OnRequestStatisticsMessage(MentorHelpRequestStatisticsMessage message, EntitySessionEventArgs eventArgs)
{
var session = eventArgs.SenderSession;
if (!HasMentorPermissions(session))
{
_sawmill.Warning($"Player {session.Name} ({session.UserId}) tried to request mentor help statistics without permissions");
return;
}
try
{
var now = DateTimeOffset.UtcNow;
var cacheValid = _mentorStatsCache != null && _mentorStatsCacheTime != null && (now - _mentorStatsCacheTime.Value).TotalMinutes < _mentorCacheInterval;
List<MentorHelpStatisticsData> statisticsData;
if (cacheValid)
{
statisticsData = _mentorStatsCache!;
}
else
{
var statistics = await _dbManager.GetMentorHelpStatisticsAsync();
statisticsData = new List<MentorHelpStatisticsData>();
foreach (var stat in statistics)
{
var adminData = await _adminManager.LoadAdminData(new NetUserId(stat.MentorUserId));
if (adminData == null)
continue;
if (!adminData.Value.dat.Flags.HasFlag(AdminFlags.Mentor))
continue;
var mentorName = await GetPlayerNameAsync(stat.MentorUserId);
statisticsData.Add(new MentorHelpStatisticsData
{
MentorName = mentorName,
TicketsClaimed = stat.TicketsClaimed,
MessagesCount = stat.MessagesCount
});
}
statisticsData = statisticsData.OrderByDescending((s) => s.TicketsClaimed).ToList();
_mentorStatsCache = statisticsData;
_mentorStatsCacheTime = now;
}
RaiseNetworkEvent(new MentorHelpStatisticsMessage(statisticsData), session.Channel);
}
catch (Exception ex)
{
_sawmill.Error($"Error requesting mentor help statistics for {session.Name} ({session.UserId}): {ex}");
}
}
protected override async void OnRequestTicketMessagesMessage(MentorHelpRequestTicketMessagesMessage message, EntitySessionEventArgs eventArgs)
{
var session = eventArgs.SenderSession;
_sawmill.Info("Received RequestTicketMessages for ticket #{0} from {1} ({2})", message.TicketId, session.Name, session.UserId);
try
{
var allMessages = await _dbManager.GetMentorHelpMessagesByTicketAsync(message.TicketId);
var messageDatas = new List<MentorHelpMessageData>();
foreach (var msg in allMessages.OrderBy(m => m.SentAt))
{
messageDatas.Add(await ConvertToMessageDataAsync(msg));
}
RaiseNetworkEvent(new MentorHelpTicketMessagesMessage(message.TicketId, messageDatas), session.Channel);
_sawmill.Info("Sent {0} messages for ticket #{1} to {2} ({3})", messageDatas.Count, message.TicketId, session.Name, session.UserId);
}
catch (Exception ex)
{
_sawmill.Error($"Error requesting mentor help messages for ticket #{message.TicketId} by {session.Name} ({session.UserId}): {ex}");
}
}
private async Task<MentorHelpTicketData> ConvertToTicketDataAsync(MentorHelpTicket ticket)
{
var playerName = await GetPlayerNameAsync(ticket.PlayerId);
var assignedToName = ticket.AssignedToUserId.HasValue ? await GetPlayerNameAsync(ticket.AssignedToUserId.Value) : null;
var closedByName = ticket.ClosedByUserId.HasValue ? await GetPlayerNameAsync(ticket.ClosedByUserId.Value) : null;
return new MentorHelpTicketData
{
Id = ticket.Id,
PlayerId = new NetUserId(ticket.PlayerId),
PlayerName = playerName,
AssignedToUserId = ticket.AssignedToUserId.HasValue ? new NetUserId(ticket.AssignedToUserId.Value) : null,
AssignedToName = assignedToName,
Subject = ticket.Subject,
Status = ticket.Status,
CreatedAt = ticket.CreatedAt.DateTime,
UpdatedAt = ticket.UpdatedAt.DateTime,
ClosedAt = ticket.ClosedAt?.DateTime,
ClosedByUserId = ticket.ClosedByUserId.HasValue ? new NetUserId(ticket.ClosedByUserId.Value) : null,
ClosedByName = closedByName,
RoundId = ticket.RoundId,
HasUnreadMessages = false // Would need to implement read tracking
};
}
private async Task<MentorHelpMessageData> ConvertToMessageDataAsync(MentorHelpMessage message)
{
var senderUserId = new NetUserId(message.SenderUserId);
var senderAdminData = await _adminManager.LoadAdminData(senderUserId);
var senderData = await _dbManager.GetPlayerRecordByUserId(senderUserId);
var username = "";
if (senderData != null)
{
username = senderData.LastSeenUserName;
}
string formatterSender;
var adminPrefix = "";
if (_config.GetCVar(SunriseCCVars.MentorHelpAdminPrefix) && senderAdminData is not null && senderAdminData.Value.dat.Title is not null)
{
adminPrefix = $"[bold]\\[{senderAdminData.Value.dat.Title}\\][/bold] ";
}
if (senderAdminData is not null &&
senderAdminData.Value.dat.Flags ==
AdminFlags.Mentor)
{
formatterSender = $"[color=purple]{adminPrefix}{username}[/color]";
}
else if (senderAdminData is not null && senderAdminData.Value.dat.Flags.HasFlag(AdminFlags.Mentor))
{
formatterSender = $"[color=red]{adminPrefix}{username}[/color]";
}
else if (_sponsorsManager != null)
{
_sponsorsManager.TryGetOocColor(senderUserId, out var oocColor);
_sponsorsManager.TryGetOocTitle(senderUserId, out var oocTitle);
var sponsorTitle = oocTitle is null ? "" : $"\\[{oocTitle}\\]";
if (oocColor != null)
{
formatterSender = $"[color={oocColor.Value.ToHex()}]{sponsorTitle} {username}[/color]";
}
else
{
formatterSender = $"{sponsorTitle} {username}";
}
}
else
{
formatterSender = $"{username}";
}
return new MentorHelpMessageData
{
Id = message.Id,
TicketId = message.TicketId,
SenderUserId = senderUserId,
SenderName = username,
FormattedSender = formatterSender,
Message = message.Message,
SentAt = message.SentAt.DateTime,
IsStaffOnly = message.IsStaffOnly
};
}
private async Task<string> GetPlayerNameAsync(Guid userId)
{
var playerData = await _dbManager.GetPlayerRecordByUserId(new NetUserId(userId));
var name = playerData?.LastSeenUserName;
if (string.IsNullOrWhiteSpace(name))
{
Logger.Warning($"GetPlayerNameAsync: No name found for userId {userId}, returning 'Unknown'.");
return "Unknown";
}
return name;
}
private async Task NotifyMentorsOfNewTicket(MentorHelpTicketData ticketData)
{
var mentors = GetTargetMentors();
foreach (var mentor in mentors)
{
RaiseNetworkEvent(new MentorHelpTicketUpdateMessage(ticketData), mentor);
}
}
private async Task NotifyTicketUpdate(MentorHelpTicketData ticketData)
{
// Notify the player
if (_playerManager.TryGetSessionById(ticketData.PlayerId, out var playerSession))
{
RaiseNetworkEvent(new MentorHelpTicketUpdateMessage(ticketData), playerSession.Channel);
}
// Notify mentors
var mentors = GetTargetMentors();
foreach (var mentor in mentors)
{
RaiseNetworkEvent(new MentorHelpTicketUpdateMessage(ticketData), mentor);
}
}
private async Task NotifyTicketMessage(MentorHelpTicketData ticketData, MentorHelpMessageData messageData)
{
var allMessages = await _dbManager.GetMentorHelpMessagesByTicketAsync(ticketData.Id);
var messageDatas = new List<MentorHelpMessageData>();
foreach (var msg in allMessages.OrderBy(m => m.SentAt)) // сортировка теперь точно по объектам
{
messageDatas.Add(await ConvertToMessageDataAsync(msg));
}
// Notify the player (if not staff-only)
if (!messageData.IsStaffOnly && _playerManager.TryGetSessionById(ticketData.PlayerId, out var playerSession))
{
RaiseNetworkEvent(new MentorHelpTicketMessagesMessage(ticketData.Id, messageDatas), playerSession.Channel);
}
// Notify mentors
var mentors = GetTargetMentors();
foreach (var mentor in mentors)
{
RaiseNetworkEvent(new MentorHelpTicketMessagesMessage(ticketData.Id, messageDatas), mentor);
}
}
private IList<INetChannel> GetTargetMentors()
{
return _adminManager.ActiveAdmins
.Where(p => _adminManager.GetAdminData(p)?.HasFlag(AdminFlags.Mentor) ?? false)
.Select(p => p.Channel)
.ToList();
}
}
}

View file

@ -0,0 +1,28 @@
namespace Content.Shared.Database
{
/// <summary>
/// Status values for mentor help tickets
/// </summary>
public enum MentorHelpTicketStatus
{
/// <summary>
/// Ticket is open and awaiting assignment or response
/// </summary>
Open = 0,
/// <summary>
/// Ticket has been claimed by a mentor/admin
/// </summary>
Assigned = 1,
/// <summary>
/// Ticket has been responded to, awaiting player response
/// </summary>
AwaitingResponse = 2,
/// <summary>
/// Ticket has been resolved/closed
/// </summary>
Closed = 3
}
}

View file

@ -124,6 +124,11 @@
/// </summary>
NameColor = 1 << 21,
/// <summary>
/// Lets you use the mentor help system.
/// </summary>
Mentor = 1 << 22,
/// <summary>
/// Dangerous host permissions like scsi.
/// </summary>

View file

@ -0,0 +1,45 @@
using Robust.Shared.Serialization;
namespace Content.Shared.Administration;
/// <summary>
/// Request event to get the list of online administrators
/// </summary>
[Serializable, NetSerializable]
public sealed class RequestAdminWhoEvent : EntityEventArgs
{
}
/// <summary>
/// Response event containing the list of online administrators
/// </summary>
[Serializable, NetSerializable]
public sealed class AdminWhoResponseEvent : EntityEventArgs
{
public readonly List<AdminWhoEntry> Admins;
public AdminWhoResponseEvent(List<AdminWhoEntry> admins)
{
Admins = admins;
}
}
/// <summary>
/// Information about a single administrator
/// </summary>
[Serializable, NetSerializable]
public sealed class AdminWhoEntry
{
public readonly string Name;
public readonly string? Title;
public readonly bool IsStealth;
public readonly bool IsAfk;
public AdminWhoEntry(string name, string? title, bool isStealth, bool isAfk)
{
Name = name;
Title = title;
IsStealth = isStealth;
IsAfk = isAfk;
}
}

View file

@ -137,4 +137,18 @@ namespace Content.Shared.Administration
Typing = typing;
}
}
/// <summary>
/// Sent by server to notify a client when their message was blocked due to cooldown.
/// </summary>
[Serializable, NetSerializable]
public sealed class BwoinkCooldownMessage : EntityEventArgs
{
public TimeSpan RemainingCooldown { get; }
public BwoinkCooldownMessage(TimeSpan remainingCooldown)
{
RemainingCooldown = remainingCooldown;
}
}
}

View file

@ -18,8 +18,14 @@ namespace Content.Shared.Communications
public List<string>? AlertLevels;
public string CurrentAlert;
public float CurrentAlertDelay;
// Sunrise-Start
public readonly bool CanRelay;
public readonly bool IsRelaying;
public readonly float RelayCooldownRemaining;
public readonly float RelayTimeRemaining;
// Sunrise-End
public CommunicationsConsoleInterfaceState(bool canAnnounce, bool canCall, List<string>? alertLevels, string currentAlert, float currentAlertDelay, TimeSpan? expectedCountdownEnd = null)
public CommunicationsConsoleInterfaceState(bool canAnnounce, bool canCall, List<string>? alertLevels, string currentAlert, float currentAlertDelay, TimeSpan? expectedCountdownEnd = null, bool canRelay = false, bool isRelaying = false, float relayCooldownRemaining = 0f, float relayTimeRemaining = 0f) // Sunrise-Edit
{
CanAnnounce = canAnnounce;
CanCall = canCall;
@ -28,6 +34,12 @@ namespace Content.Shared.Communications
AlertLevels = alertLevels;
CurrentAlert = currentAlert;
CurrentAlertDelay = currentAlertDelay;
// Sunrise-Start
CanRelay = canRelay;
IsRelaying = isRelaying;
RelayCooldownRemaining = relayCooldownRemaining;
RelayTimeRemaining = relayTimeRemaining;
// Sunrise-End
}
}
@ -63,6 +75,13 @@ namespace Content.Shared.Communications
}
}
// Sunrise-Start
[Serializable, NetSerializable]
public sealed class CommunicationsConsoleToggleRelayMessage : BoundUserInterfaceMessage
{
}
// Sunrise-End
[Serializable, NetSerializable]
public sealed class CommunicationsConsoleCallEmergencyShuttleMessage : BoundUserInterfaceMessage
{

View file

@ -0,0 +1,12 @@
namespace Content.Shared.EnergyDome;
/// <summary>
/// marker component that allows linking the dome generator with the dome itself
/// </summary>
[RegisterComponent]
public sealed partial class EnergyDomeProtectedUserComponent : Component
{
[DataField]
public EntityUid? DomeEntity;
}

View file

@ -71,6 +71,8 @@ namespace Content.Shared.Input
public static readonly BoundKeyFunction Reloading = "Reloading";
public static readonly BoundKeyFunction Interact = "Interact";
public static readonly BoundKeyFunction LookUp = "LookUp";
public static readonly BoundKeyFunction OpenMentorHelp = "OpenMentorHelp";
public static readonly BoundKeyFunction OpenHelpChoice = "OpenHelpChoice";
// Sunrise-End
public static readonly BoundKeyFunction ArcadeUp = "ArcadeUp";

View file

@ -0,0 +1,276 @@
using Content.Shared.Database;
using Robust.Shared.Network;
using Robust.Shared.Serialization;
namespace Content.Shared._Sunrise.MentorHelp
{
/// <summary>
/// Shared base class for mentor help system
/// </summary>
public abstract class SharedMentorHelpSystem : EntitySystem
{
public override void Initialize()
{
base.Initialize();
SubscribeNetworkEvent<MentorHelpCreateTicketMessage>(OnCreateTicketMessage);
SubscribeNetworkEvent<MentorHelpClaimTicketMessage>(OnClaimTicketMessage);
SubscribeNetworkEvent<MentorHelpReplyMessage>(OnReplyMessage);
SubscribeNetworkEvent<MentorHelpCloseTicketMessage>(OnCloseTicketMessage);
SubscribeNetworkEvent<MentorHelpRequestTicketsMessage>(OnRequestTicketsMessage);
SubscribeNetworkEvent<MentorHelpUnassignTicketMessage>(OnUnassignTicketMessage);
SubscribeNetworkEvent<MentorHelpRequestStatisticsMessage>(OnRequestStatisticsMessage);
SubscribeNetworkEvent<MentorHelpRequestTicketMessagesMessage>(OnRequestTicketMessagesMessage);
}
protected virtual void OnCreateTicketMessage(MentorHelpCreateTicketMessage message, EntitySessionEventArgs eventArgs) { }
protected virtual void OnClaimTicketMessage(MentorHelpClaimTicketMessage message, EntitySessionEventArgs eventArgs) { }
protected virtual void OnReplyMessage(MentorHelpReplyMessage message, EntitySessionEventArgs eventArgs) { }
protected virtual void OnCloseTicketMessage(MentorHelpCloseTicketMessage message, EntitySessionEventArgs eventArgs) { }
protected virtual void OnRequestTicketsMessage(MentorHelpRequestTicketsMessage message, EntitySessionEventArgs eventArgs) { }
protected virtual void OnUnassignTicketMessage(MentorHelpUnassignTicketMessage message, EntitySessionEventArgs eventArgs) { }
protected virtual void OnRequestStatisticsMessage(MentorHelpRequestStatisticsMessage message, EntitySessionEventArgs eventArgs) { }
protected virtual void OnRequestTicketMessagesMessage(MentorHelpRequestTicketMessagesMessage message, EntitySessionEventArgs eventArgs) { }
}
public struct MentorHelpStatistics
{
public Guid MentorUserId { get; set; }
public int TicketsClaimed { get; set; }
public int MessagesCount { get; set; }
}
/// <summary>
/// Message to create a new mentor help ticket
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpCreateTicketMessage : EntityEventArgs
{
public string Subject { get; }
public string Message { get; }
public MentorHelpCreateTicketMessage(string subject, string message)
{
Subject = subject;
Message = message;
}
}
/// <summary>
/// Message to claim a mentor help ticket
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpClaimTicketMessage : EntityEventArgs
{
public int TicketId { get; }
public MentorHelpClaimTicketMessage(int ticketId)
{
TicketId = ticketId;
}
}
/// <summary>
/// Message to reply to a mentor help ticket
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpReplyMessage : EntityEventArgs
{
public int TicketId { get; }
public string Message { get; }
public bool IsStaffOnly { get; }
public MentorHelpReplyMessage(int ticketId, string message, bool isStaffOnly = false)
{
TicketId = ticketId;
Message = message;
IsStaffOnly = isStaffOnly;
}
}
/// <summary>
/// Message to unassign a mentor help ticket
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpUnassignTicketMessage : EntityEventArgs
{
public int TicketId { get; }
public MentorHelpUnassignTicketMessage(int ticketId)
{
TicketId = ticketId;
}
}
/// <summary>
/// Message to close a mentor help ticket
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpCloseTicketMessage : EntityEventArgs
{
public int TicketId { get; }
public MentorHelpCloseTicketMessage(int ticketId)
{
TicketId = ticketId;
}
}
/// <summary>
/// Message to request tickets (from client)
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpRequestTicketsMessage : EntityEventArgs
{
public bool OnlyMine { get; }
public MentorHelpRequestTicketsMessage(bool onlyMine = false)
{
OnlyMine = onlyMine;
}
}
/// <summary>
/// Message with ticket update (to client)
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpTicketUpdateMessage : EntityEventArgs
{
public MentorHelpTicketData Ticket { get; }
public MentorHelpTicketUpdateMessage(MentorHelpTicketData ticket)
{
Ticket = ticket;
}
}
/// <summary>
/// Message with tickets list (to client)
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpTicketsListMessage : EntityEventArgs
{
public List<MentorHelpTicketData> Tickets { get; }
public MentorHelpTicketsListMessage(List<MentorHelpTicketData> tickets)
{
Tickets = tickets;
}
}
/// <summary>
/// Message with ticket messages (to client)
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpTicketMessagesMessage : EntityEventArgs
{
public int TicketId { get; }
public List<MentorHelpMessageData> Messages { get; }
public MentorHelpTicketMessagesMessage(int ticketId, List<MentorHelpMessageData> messages)
{
TicketId = ticketId;
Messages = messages;
}
}
/// <summary>
/// Serializable ticket data for networking
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpTicketData
{
public int Id { get; set; }
public NetUserId PlayerId { get; set; }
public string PlayerName { get; set; } = string.Empty;
public NetUserId? AssignedToUserId { get; set; }
public string? AssignedToName { get; set; }
public string Subject { get; set; } = string.Empty;
public MentorHelpTicketStatus Status { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime? ClosedAt { get; set; }
public NetUserId? ClosedByUserId { get; set; }
public string? ClosedByName { get; set; }
public int? RoundId { get; set; }
public bool HasUnreadMessages { get; set; }
}
/// <summary>
/// Serializable message data for networking
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpMessageData
{
public int Id { get; set; }
public int TicketId { get; set; }
public NetUserId SenderUserId { get; set; }
public string SenderName { get; set; } = string.Empty;
public string FormattedSender { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public DateTime SentAt { get; set; }
public bool IsStaffOnly { get; set; }
}
/// <summary>
/// DTO для передачи статистики по менторам
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpStatisticsData
{
public string MentorName { get; set; } = string.Empty;
public int TicketsClaimed { get; set; }
public int MessagesCount { get; set; }
}
/// <summary>
/// Сообщение-запрос статистики по менторам
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpRequestStatisticsMessage : EntityEventArgs
{
// Пустой класс-запрос
}
/// <summary>
/// Сообщение с результатами статистики по менторам
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpStatisticsMessage : EntityEventArgs
{
public List<MentorHelpStatisticsData> Statistics { get; }
public MentorHelpStatisticsMessage(List<MentorHelpStatisticsData> statistics)
{
Statistics = statistics;
}
}
/// <summary>
/// Message to request messages for a specific ticket (from client)
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpRequestTicketMessagesMessage : EntityEventArgs
{
public int TicketId { get; }
public MentorHelpRequestTicketMessagesMessage(int ticketId)
{
TicketId = ticketId;
}
}
/// <summary>
/// Message sent from server to client instructing it to open (focus) a specific ticket in the UI.
/// This is used immediately after creating a ticket so the creating player sees their new ticket.
/// </summary>
[Serializable, NetSerializable]
public sealed class MentorHelpOpenTicketMessage : EntityEventArgs
{
public int TicketId { get; }
public MentorHelpOpenTicketMessage(int ticketId)
{
TicketId = ticketId;
}
}
}

View file

@ -191,6 +191,12 @@ public sealed partial class SunriseCCVars : CVars
public static readonly CVarDef<string> InfoLinksDonate =
CVarDef.Create("infolinks.donate", "", CVar.SERVER | CVar.REPLICATED);
/// <summary>
/// Link to replays to show in menus.
/// </summary>
public static readonly CVarDef<string> InfoLinksReplays =
CVarDef.Create("infolinks.replays", "https://t.me/ss14_replays", CVar.SERVER | CVar.REPLICATED);
/**
* Lobby
*/
@ -559,4 +565,19 @@ public sealed partial class SunriseCCVars : CVars
/// </summary>
public static readonly CVarDef<string> DocumentTemplatePool =
CVarDef.Create("doc.template_pool", "Sunrise", CVar.SERVER | CVar.ARCHIVE);
public static readonly CVarDef<bool> MentorHelpAdminPrefix =
CVarDef.Create("mentor_help.admin_prefix", true, CVar.SERVERONLY);
public static readonly CVarDef<float> MentorHelpRateLimitPeriod =
CVarDef.Create("mentor_help.rate_limit_period", 2f, CVar.SERVERONLY);
public static readonly CVarDef<int> MentorHelpRateLimitCount =
CVarDef.Create("mentor_help.rate_limit_count", 10, CVar.SERVERONLY);
public static readonly CVarDef<string> MentorHelpSound =
CVarDef.Create("mentor_help.mentor_help_sound", "/Audio/_Sunrise/Effects/adminticketopen.ogg", CVar.ARCHIVE | CVar.CLIENTONLY);
public static readonly CVarDef<bool> MentorHelpSoundEnabled =
CVarDef.Create("mentor_help.mentor_help_sound_enabled", true, CVar.ARCHIVE | CVar.CLIENTONLY);
}

Binary file not shown.

View file

@ -21972,3 +21972,132 @@
id: 1435
time: '2025-09-04T23:34:23.0000000+00:00'
url: https://github.com/space-sunrise/sunrise-station/pull/3137
- author: ReWAFFlution
changes:
- message: "\u0414\u043E\u0431\u0430\u0432\u043B\u0435\u043D\u0430 \u0432\u043E\u0437\
\u043C\u043E\u0436\u043D\u043E\u0441\u0442\u044C \u0447\u0438\u043D\u0438\u0442\
\u044C \u043D\u0435\u043A\u043E\u0442\u043E\u0440\u044B\u0435 \u0449\u0438\u0442\
\u044B \u0441\u0432\u0430\u0440\u043A\u043E\u0439."
type: Add
- message: "\u0414\u043E\u0431\u0430\u0432\u043B\u0435\u043D\u0430 \u0432\u043E\u0437\
\u043C\u043E\u0436\u043D\u043E\u0441\u0442\u044C \u043A \u043E\u0441\u043C\u043E\
\u0442\u0440\u0443 \u043F\u043E\u0432\u0440\u0435\u0436\u0434\u0435\u043D\u0438\
\u0439 \u0449\u0438\u0442\u043E\u0432."
type: Add
id: 1436
time: '2025-09-07T09:06:53.0000000+00:00'
url: https://github.com/space-sunrise/sunrise-station/pull/3082
- author: Copilot AI
changes:
- message: "\u0414\u043E\u0431\u0430\u0432\u043B\u0435\u043D\u0430 \u043A\u043D\u043E\
\u043F\u043A\u0430 \"\u0420\u0435\u043F\u043B\u0435\u0438\" \u0432 \u043C\u0435\
\u043D\u044E ESC \u0438 \u043B\u043E\u0431\u0431\u0438 \u0434\u043B\u044F \u0434\
\u043E\u0441\u0442\u0443\u043F\u0430 \u043A \u0441\u0438\u0441\u0442\u0435\u043C\
\u0435 \u0440\u0435\u043F\u043B\u0435\u0435\u0432."
type: Add
id: 1437
time: '2025-09-08T17:26:10.0000000+00:00'
url: https://github.com/space-sunrise/sunrise-station/pull/3173
- author: Hero_010
changes:
- message: "\u0418\u0441\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0430 \u043B\u043E\
\u043A\u0430\u043B\u0438\u0437\u0430\u0446\u0438\u044F \u0445\u0438\u0440\u0443\
\u0440\u0433\u0438\u0438 \u0443 \u0410\u0440\u043A\u0430\u043D."
type: Fix
id: 1438
time: '2025-09-08T17:26:50.0000000+00:00'
url: https://github.com/space-sunrise/sunrise-station/pull/3115
- author: KaiserMaus
changes:
- message: "\u0418\u0441\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u044B ERROR\u043A\
\u0438 \u043F\u0440\u0438 \u043F\u0438\u0441\u044C\u043C\u0435 \u0443 \u0434\
\u0438\u043E\u043D."
type: Fix
id: 1439
time: '2025-09-09T13:19:34.0000000+00:00'
url: https://github.com/space-sunrise/sunrise-station/pull/3185
- author: VigersRay, Copilot AI
changes:
- message: "\u0414\u043E\u0431\u0430\u0432\u043B\u0435\u043D \u043E\u0442\u0434\u0435\
\u043B\u044C\u043D\u044B\u0439 \u0438\u043D\u0442\u0435\u0440\u0444\u0435\u0439\
\u0441 \u0434\u043B\u044F \u043C\u0435\u043D\u0442\u043E\u0440\u043E\u0432 \u0440\
\u0430\u0431\u043E\u0442\u0430\u044E\u0449\u0438\u0439 \u043F\u043E \u043F\u0440\
\u0438\u043D\u0446\u0438\u043F\u0443 \u0442\u0438\u043A\u0435\u0442\u043E\u0432\
."
type: Add
- message: "\u0414\u043E\u0431\u0430\u0432\u043B\u0435\u043D\u0430 \u0441\u0442\u0430\
\u0442\u0438\u0441\u0442\u0438\u043A\u0430 \u0430\u043A\u0442\u0438\u0432\u043D\
\u043E\u0441\u0442\u0438 \u0434\u043B\u044F \u043C\u0435\u043D\u0442\u043E\u0440\
\u043E\u0432."
type: Add
- message: "F1 \u0442\u0435\u043F\u0435\u0440\u044C \u043E\u0442\u043A\u0440\u044B\
\u0432\u0430\u0435\u0442 \u0432\u044B\u0431\u043E\u0440 \u043C\u0435\u0436\u0434\
\u0443 \u0430\u0445\u0435\u043B\u043F\u043E\u043C \u0438 \u043C\u0435\u043D\u0442\
\u043E\u0440 \u0445\u0435\u043B\u043F\u043E\u043C."
type: Tweak
id: 1440
time: '2025-09-09T17:54:52.0000000+00:00'
url: https://github.com/space-sunrise/sunrise-station/pull/2915
- author: Copilot AI, VigersRay
changes:
- message: "\u0414\u043E\u0431\u0430\u0432\u043B\u0435\u043D\u0430 \u043A\u043D\u043E\
\u043F\u043A\u0430 \"\u0410\u0434\u043C\u0438\u043D\u044B \u043E\u043D\u043B\
\u0430\u0439\u043D\" \u0432 \u0438\u043D\u0442\u0435\u0440\u0444\u0435\u0439\
\u0441 AHelp \u0438 Mhelp \u0434\u043B\u044F \u0443\u0434\u043E\u0431\u043D\u043E\
\u0433\u043E \u043F\u0440\u043E\u0441\u043C\u043E\u0442\u0440\u0430 \u0441\u043F\
\u0438\u0441\u043A\u0430 \u043E\u043D\u043B\u0430\u0439\u043D \u0430\u0434\u043C\
\u0438\u043D\u0438\u0441\u0442\u0440\u0430\u0442\u043E\u0440\u043E\u0432 \u0438\
\ \u043C\u0435\u043D\u0442\u043E\u0440\u043E\u0432."
type: Add
id: 1441
time: '2025-09-09T19:06:57.0000000+00:00'
url: https://github.com/space-sunrise/sunrise-station/pull/2918
- author: VigersRay
changes:
- message: "\u0414\u043E\u0431\u0430\u0432\u043B\u0435\u043D\u0430 \u0432\u043E\u0437\
\u043C\u043E\u0436\u043D\u043E\u0441\u0442\u044C \u0432\u043A\u043B\u044E\u0447\
\u0438\u0442\u044C \u043D\u0430 60 \u0441\u0435\u043A\u0443\u043D\u0434 \u0442\
\u0440\u0430\u043D\u0441\u043B\u044F\u0446\u0438\u044E \u0440\u0435\u0447\u0438\
\ \u0447\u0435\u0440\u0435\u0437 \u043A\u043E\u043D\u0441\u043E\u043B\u044C\
\ \u0441\u0432\u044F\u0437\u0438. \u041F\u0435\u0440\u0435\u0437\u0430\u0440\
\u044F\u0434\u043A\u0430 5 \u043C\u0438\u043D\u0443\u0442."
type: Add
- message: "\u0423\u043B\u0443\u0447\u0448\u0435\u043D \u0438\u043D\u0442\u0435\u0440\
\u0444\u0435\u0439\u0441 \u043A\u043E\u043D\u0441\u043E\u043B\u0438 \u0441\u0432\
\u044F\u0437\u0438."
type: Tweak
id: 1442
time: '2025-09-09T19:15:38.0000000+00:00'
url: https://github.com/space-sunrise/sunrise-station/pull/3070
- author: Copilot AI
changes:
- message: "\u0412 \u0433\u043E\u043B\u043E\u0441\u043E\u0432\u0430\u043D\u0438\u0438\
\ \u0437\u0430 \u043A\u0438\u043A \u0430\u043D\u0442\u0430\u0433\u043E\u043D\
\u0438\u0441\u0442\u044B \u0442\u0435\u043F\u0435\u0440\u044C \u0431\u0443\u0434\
\u0443\u0442 \u0438\u043C\u0435\u0442\u044C \u0434\u0440\u0443\u0433\u0438\u0435\
\ \u0438\u043C\u0435\u043D\u0430 \u0434\u043B\u044F \u043F\u0440\u0435\u0434\
\u043E\u0442\u0432\u0440\u0430\u0449\u0435\u043D\u0438\u044F \u043F\u043E\u043B\
\u0443\u0447\u0435\u043D\u0438\u044F \u043C\u0435\u0442\u0430 \u0438\u043D\u0444\
\u043E\u0440\u043C\u0430\u0446\u0438\u0438."
type: Fix
id: 1443
time: '2025-09-09T19:18:06.0000000+00:00'
url: https://github.com/space-sunrise/sunrise-station/pull/3012
- author: Copilot AI
changes:
- message: "\u0417\u0430\u043F\u0440\u0435\u0449\u0435\u043D\u0430 \u0430\u043A\u0442\
\u0438\u0432\u0430\u0446\u0438\u044F \u0433\u0435\u043D\u0435\u0440\u0430\u0442\
\u043E\u0440\u0430 \u0449\u0438\u0442\u0430 \u0432\u043D\u0443\u0442\u0440\u0438\
\ \u043C\u0435\u0445\u043E\u0432 \u0438 \u043A\u043E\u043D\u0442\u0435\u0439\
\u043D\u0435\u0440\u043E\u0432."
type: Fix
- message: "\u0413\u0435\u043D\u0435\u0440\u0430\u0442\u043E\u0440\u044B \u0449\u0438\
\u0442\u0430 \u0442\u0435\u043F\u0435\u0440\u044C \u0430\u0432\u0442\u043E\u043C\
\u0430\u0442\u0438\u0447\u0435\u0441\u043A\u0438 \u043E\u0442\u043A\u043B\u044E\
\u0447\u0430\u044E\u0442\u0441\u044F \u043F\u0440\u0438 \u0432\u0445\u043E\u0434\
\u0435 \u0432 \u043C\u0435\u0445\u0438 \u0438\u043B\u0438 \u043A\u043E\u043D\
\u0442\u0435\u0439\u043D\u0435\u0440\u044B."
type: Fix
id: 1444
time: '2025-09-09T23:46:20.0000000+00:00'
url: https://github.com/space-sunrise/sunrise-station/pull/2997

View file

@ -0,0 +1,7 @@
admin-who-button = Admins Online
admin-who-title = Online Administrators
admin-who-refresh = Refresh
admin-who-close = Close
admin-who-no-admins = No administrators are currently online.
admin-who-loading = Loading administrators...
admin-who-info = This feature displays which administrators are currently online and available to help with any issues you may have.

View file

@ -1,2 +1,3 @@
ui-escape-donate = Донат
ui-escape-forum = Форум
ui-escape-replays = Реплеи

View file

@ -0,0 +1,24 @@
health-examinable-shield-none = There is no obvious damage to be seen.
health-examinable-shield-Blunt-8 = [color=orange]Shield has minor dents.[/color]
health-examinable-shield-Blunt-15 = [color=red]Shield has several visible dents.[/color]
health-examinable-shield-Blunt-30 = [color=crimson]Shield is badly deformed from impacts![/color]
health-examinable-shield-Blunt-50 = [color=crimson]Shield is nearly shattered![/color]
health-examinable-shield-Slash-8 = [color=orange]Shield has light scratches.[/color]
health-examinable-shield-Slash-15 = [color=red]Shield is visibly cut in several places.[/color]
health-examinable-shield-Slash-30 = [color=crimson]Shield is deeply gashed and cracked![/color]
health-examinable-shield-Slash-50 = [color=crimson]Shield is nearly shattered![/color]
health-examinable-shield-Piercing-8 = [color=orange]Shield has small puncture marks.[/color]
health-examinable-shield-Piercing-15 = [color=red]Shield is riddled with holes.[/color]
health-examinable-shield-Piercing-30 = [color=crimson]Shield has large, dangerous breaches![/color]
health-examinable-shield-Piercing-50 = [color=crimson]Shield is nearly shattered![/color]
health-examinable-shield-Heat-8 = [color=orange]Shield surface is slightly scorched.[/color]
health-examinable-shield-Heat-15 = [color=red]Shield is charred and blackened.[/color]
health-examinable-shield-Heat-30 = [color=crimson]Shield is melting under extreme heat![/color]
health-examinable-shield-Heat-50 = [color=crimson]Shield almost burns in your hand![/color]
health-examinable-shield-Mangleness-15 = [color=orange]Shield looks fused.[/color]
health-examinable-shield-Mangleness-35 = [color=red]Shield looks like scrap metal![/color]

View file

@ -26,6 +26,8 @@ admin-bwoink-play-sound = Bwoink?
bwoink-title-none-selected = None selected
bwoink-system-rate-limited = System: you are sending messages too quickly.
bwoink-cooldown-message = Too fast! Wait {$seconds}s before sending another message.
bwoink-input-placeholder = Type your message here...
bwoink-system-player-disconnecting = has disconnected.
bwoink-system-player-reconnecting = has reconnected.
bwoink-system-player-banned = has been banned for: {$banReason}

View file

@ -2,6 +2,7 @@ ui-lobby-title = Lobby: {$serverName}
ui-lobby-ahelp-button = AHelp
ui-lobby-options-button = Options
ui-lobby-leave-button = Leave
ui-lobby-replays-button = Replays
ui-lobby-observe-button = Observe
ui-lobby-ready-up-button = Ready Up
ui-lobby-online-players-block = Online Players

View file

@ -8,15 +8,15 @@ ent-LeftArmDemon = левая рука арканы
.desc = { ent-PartDemon.desc }
ent-RightArmDemon = правая рука арканы
.desc = { ent-PartDemon.desc }
ent-LeftHandDemon = левая рука арканы
ent-LeftHandDemon = левая кисть арканы
.desc = { ent-PartDemon.desc }
ent-RightHandDemon = правая рука арканы
ent-RightHandDemon = правая кисть арканы
.desc = { ent-PartDemon.desc }
ent-LeftLegDemon = левая нога арканы
.desc = { ent-PartDemon.desc }
ent-RightLegDemon = правая нога арканы
.desc = { ent-PartDemon.desc }
ent-LeftFootDemon = левая нога арканы
ent-LeftFootDemon = левая стопа арканы
.desc = { ent-PartDemon.desc }
ent-RightFootDemon = правая нога арканы
ent-RightFootDemon = правая стопа арканы
.desc = { ent-PartDemon.desc }

View file

@ -1,22 +1,22 @@
ent-PartSwine = свинячья часть тела
ent-PartSwine = часть тела свиньи
.desc = { ent-BasePart.desc }
ent-TorsoSwine = свинячий торс
ent-TorsoSwine = торс свиньи
.desc = { ent-BaseTorso.desc }
ent-HeadSwine = свинячья голова
ent-HeadSwine = голова свиньи
.desc = { ent-BaseHead.desc }
ent-LeftArmSwine = левая свинячья рука
ent-LeftArmSwine = левая рука свиньи
.desc = { ent-BaseLeftArm.desc }
ent-RightArmSwine = правая свинячья рука
ent-RightArmSwine = правая рука свиньи
.desc = { ent-BaseRightArm.desc }
ent-LeftHandSwine = левая свинячья рука (кисть)
ent-LeftHandSwine = левая кисть свиньи
.desc = { ent-BaseLeftHand.desc }
ent-RightHandSwine = правая свинячья рука (кисть)
ent-RightHandSwine = правая кисть свиньи
.desc = { ent-BaseRightHand.desc }
ent-LeftLegSwine = левая свинячья нога
ent-LeftLegSwine = левая нога свиньи
.desc = { ent-BaseLeftLeg.desc }
ent-RightLegSwine = правая свинячья нога
ent-RightLegSwine = правая нога свиньи
.desc = { ent-BaseRightLeg.desc }
ent-LeftFootSwine = левая свинячья стопа
ent-LeftFootSwine = левая стопа свиньи
.desc = { ent-BaseLeftFoot.desc }
ent-RightFootSwine = правая свинячья стопа
ent-RightFootSwine = правая стопа свиньи
.desc = { ent-BaseRightFoot.desc }

View file

@ -0,0 +1,7 @@
admin-who-button = Админы онлайн
admin-who-title = Администраторы онлайн
admin-who-refresh = Обновить
admin-who-close = Закрыть
admin-who-no-admins = В данный момент нет администраторов онлайн.
admin-who-loading = Загрузка администраторов...
admin-who-info = Эта функция показывает, какие администраторы в данный момент онлайн и доступны для помощи с любыми вопросами.

View file

@ -1 +1,12 @@
comms-console-announcement-title-prison = Космическая тюрьма
comms-console-menu-relay-button = Включить
comms-console-menu-relay-button-tooltip = Включить или выключить вещание голоса на динамики.
comms-console-menu-relay-stop = Выключить
comms-console-menu-relay-cooldown = Перезарядка: { $time }.
comms-console-menu-announcement-header = Оповещения
comms-console-menu-relay-header = Вещание
comms-console-menu-relay-time-left = Осталось: { $time }
comms-console-menu-emergency-header = Эвакуация
comms-console-menu-alert-level-header = Код угрозы
comms-console-relay-started = Начато вещание через консоль связи.
comms-console-relay-stopped = Вещание через консоль связи остановлено.

View file

@ -1,2 +1,3 @@
ui-escape-donate = Донат
ui-escape-forum = Форум
ui-escape-replays = Реплеи

View file

@ -0,0 +1,24 @@
health-examinable-shield-none = Видимые повреждения щита отсутствуют.
health-examinable-shield-Blunt-8 = [color=orange]Щит имеет небольшие вмятины.[/color]
health-examinable-shield-Blunt-15 = [color=red]На щите несколько заметных вмятин.[/color]
health-examinable-shield-Blunt-30 = [color=crimson]Щит сильно деформирован от ударов![/color]
health-examinable-shield-Blunt-50 = [color=crimson]Щит почти раскрошен от сокрушительной силы![/color]
health-examinable-shield-Slash-8 = [color=orange]Щит имеет лёгкие царапины.[/color]
health-examinable-shield-Slash-15 = [color=red]На щите видны глубокие порезы.[/color]
health-examinable-shield-Slash-30 = [color=crimson]Щит изрезан и потрескался![/color]
health-examinable-shield-Slash-50 = [color=crimson]Щит едва держится, разодранный порезами![/color]
health-examinable-shield-Piercing-8 = [color=orange]Щит имеет маленькие пробоины.[/color]
health-examinable-shield-Piercing-15 = [color=red]Щит изрешечён дырами.[/color]
health-examinable-shield-Piercing-30 = [color=crimson]В щите зияют крупные опасные пробоины![/color]
health-examinable-shield-Piercing-50 = [color=crimson]Щит разорван на части от колющего урона![/color]
health-examinable-shield-Heat-8 = [color=orange]Поверхность щита слегка оплавлена.[/color]
health-examinable-shield-Heat-15 = [color=red]Щит почернел и обуглился.[/color]
health-examinable-shield-Heat-30 = [color=crimson]Щит плавится от экстремального жара![/color]
health-examinable-shield-Heat-50 = [color=crimson]Щит почти рассыпался в пламени![/color]
health-examinable-shield-Mangleness-15 = [color=orange]Щит выглядит сплавленным.[/color]
health-examinable-shield-Mangleness-35 = [color=red]Щит выглядит как металлолом![/color]

View file

@ -0,0 +1,72 @@
ui-lobby-mhelp-button = MHelp
ui-options-function-open-mentor-help = Открыть ментор помощь
ui-options-function-open-help-choice = Открыть выбор помощи
# Mentor Help Window
mentor-help-window-title = Ментор помощь
# Tabs
mentor-help-tab-open = Открытые
mentor-help-tab-closed = Закрытые
# Buttons
mentor-help-statistics = Статистика
mentor-help-new-ticket = Новый тикет
mentor-help-back-to-list = Назад к списку
mentor-help-send-reply = Отправить
mentor-help-claim = Взять
mentor-help-unassign = Освободить
mentor-help-close-ticket = Закрыть
mentor-help-cancel = Отмена
mentor-help-close = Закрыть
# Table columns
mentor-help-column-id = ID
mentor-help-column-player = Игрок
mentor-help-column-status = Статус
mentor-help-column-assigned = Назначен
mentor-help-column-subject = Тема
# Status
mentor-help-status-open = Открыт
mentor-help-status-assigned = Назначен
mentor-help-status-awaiting = Ожидает ответа
mentor-help-status-closed = Закрыт
mentor-help-status-unknown = Неизвестно
mentor-help-unassigned = Не назначен
# Ticket info
mentor-help-ticket-info = Статус: {$status} | Назначен: {$assigned} | Создан: {$created}
# Reply input
mentor-help-reply-placeholder = Введите ваш ответ...
# New ticket dialog
mentor-help-new-ticket-title = Новый тикет ментор помощи
mentor-help-new-ticket-instructions = Опишите вашу проблему или вопрос. Менторы помогут вам с игровыми механиками и правилами.
mentor-help-new-ticket-subject-label = Тема:
mentor-help-new-ticket-subject-placeholder = Кратко опишите вашу проблему
mentor-help-new-ticket-message-label = Сообщение:
mentor-help-new-ticket-create-button = Создать тикет
mentor-help-new-ticket-error-subject = Пожалуйста, укажите тему тикета
mentor-help-new-ticket-error-message = Пожалуйста, опишите вашу проблему
# Statistics dialog
mentor-help-statistics-title = Статистика ментор помощи
mentor-help-statistics-header = Статистика работы менторов
mentor-help-statistics-content = Здесь будет отображаться статистика по количеству взятых тикетов каждым ментором.
mentor-help-status-label = Статус: {$status}
mentor-help-assigned-label = Назначен: {$assigned}
mentor-help-created-label = Создан: {$created}
mentor-help-label-id = ID:
mentor-help-label-subject = Тема:
help-choice-title = Выберите тип помощи
help-choice-title-label = [color=white][font size=16]Какой тип помощи вам нужен?[/font][/color]
help-choice-ahelp-button = Админ-помощь
help-choice-mhelp-button = Ментор-помощь
help-choice-ahelp-desc-label = [color=#CCCCCC][font size=12]• Админ-помощь - для жалоб на игроков, сообщений о багах и нарушениях правил[/font][/color]
help-choice-mhelp-desc-label = [color=#CCCCCC][font size=12]• Ментор-помощь - для вопросов о механиках игры и помощи новичкам[/font][/color]

View file

@ -18,6 +18,8 @@ admin-ahelp-admin-only-tooltip =
admin-bwoink-play-sound = Бвоинк?
bwoink-title-none-selected = Ничего не выбрано
bwoink-system-rate-limited = Система: вы отправляете сообщения слишком быстро.
bwoink-cooldown-message = Слишком быстро! Подождите {$seconds}с перед отправкой следующего сообщения.
bwoink-input-placeholder = Введите ваше сообщение здесь...
bwoink-system-player-disconnecting = отключился.
bwoink-system-player-reconnecting = переподключился.
bwoink-system-player-banned = был забанен за: { $banReason }

View file

@ -3,6 +3,7 @@ ui-lobby-title = Лобби: { $serverName }
ui-lobby-ahelp-button = AHelp
ui-lobby-options-button = Настройки
ui-lobby-leave-button = Выйти
ui-lobby-replays-button = Реплеи
ui-lobby-observe-button = Наблюдать
ui-lobby-ready-up-button = Готовность
ui-lobby-online-players-block = Текущие игроки

View file

@ -59,12 +59,23 @@
max: 2
- type: StaticPrice
price: 100
- type: HealthExaminable #Sunrise-Start
examinableTypes:
- Blunt
- Slash
- Piercing
- Heat
- Mangleness
locPrefix: shield
- type: ClothingSpeedModifier
sprintModifier: 0.95
- type: HeldSpeedModifier #Sunrise-End
#Security Shields
- type: entity
name: riot shield
parent: [ BaseShield, BaseSecurityContraband ]
parent: [ RepairableShield, BaseSecurityContraband, BaseShield ] # Sunrise-Edit
id: RiotShield
description: A large tower shield. Good for controlling crowds.
components:
@ -97,7 +108,7 @@
- type: entity
name: riot strobe shield
parent: [ BaseShield, BaseSecurityContraband, MachineComponentSounds ]
parent: [ RepairableShield, BaseSecurityContraband, MachineComponentSounds, BaseShield ] # Sunrise-Edit
id: RiotShieldStrobe
description: A large tower shield with atached Flash. Good for controlling crowds.
components:
@ -191,7 +202,7 @@
- type: entity
name: laser shield
parent: [ BaseShield, BaseSecurityContraband ]
parent: [ RepairableShield, BaseSecurityContraband, BaseShield ] # Sunrise-Edit
id: RiotLaserShield
description: A shield built for withstanding lasers, but not much else.
components:
@ -199,6 +210,7 @@
state: riot_laser-icon
- type: Item
heldPrefix: riot_laser
size: Ginormous # Sunrise-Edit Я не ебу почему он стал маленьким
- type: Blocking
passiveBlockModifier:
coefficients:
@ -217,13 +229,14 @@
- type: entity
name: ballistic shield
parent: [ BaseShield, BaseSecurityContraband ]
parent: [ RepairableShield, BaseSecurityContraband, BaseShield ] # Sunrise-Edit
id: RiotBulletShield
description: A shield built for protecting against ballistics, but not much else.
components:
- type: Sprite
state: riot_bullet-icon
- type: Item
size: Ginormous # Sunrise-Edit Я не ебу почему он стал маленьким
heldPrefix: riot_bullet
- type: Blocking
passiveBlockModifier:
@ -346,8 +359,8 @@
- type: entity
name: makeshift shield
parent: [ BaseShield, RepairableShield ] # Sunrise-Edit
id: MakeshiftShield
parent: BaseShield
description: A rundown looking shield, not good for much.
components:
- type: Sprite
@ -626,6 +639,9 @@
startingCharge: 1500
- type: PowerCellDraw
useRate: 2.5
- type: ClothingSpeedModifier
sprintModifier: 1
- type: HeldSpeedModifier
# Sunrise-End
- type: entity
@ -644,7 +660,7 @@
- type: entity
name: telescopic shield
parent: BaseShield
parent: [ RepairableShield, BaseShield, BaseSecurityContraband ] # Sunrise-Edit
id: TelescopicShield
description: An advanced riot shield made of lightweight materials that collapses for easy storage.
components:
@ -711,3 +727,23 @@
max: 2
- type: StaticPrice
price: 150
- type: entity # Sunrise-Start
name: repairable shield
parent: BaseItem
id: RepairableShield
description: A shield!
abstract: true
components:
- type: Item
size: Ginormous
- type: Repairable
doAfterDelay: 4
damage:
types:
Blunt: -25
Slash: -25
Piercing: -25
Heat: -25
Structural: -25
Mangleness: 7.5 # Sunrise-End

View file

@ -1,6 +1,6 @@
- type: entity
name: paladin shield
parent: [ BaseItem, BaseMajorContraband ] # Easier to remake it than inheret it, due to wiz keeping all shields in one folder and mine doing the opposite, due to the suitslot sprites.
parent: [ RepairableShield, BaseMajorContraband ] # Sunrise-Edit
id: PaladinShield
description: A shield for a true paladin! Good at deflecting hits with swords and absorbing the impact from bats, but not much else.
components:
@ -67,6 +67,17 @@
SheetSteel:
min: 5
max: 13
- type: HealthExaminable #Sunrise-Start
examinableTypes:
- Blunt
- Slash
- Piercing
- Heat
- Mangleness
locPrefix: shield
- type: ClothingSpeedModifier
sprintModifier: 0.95
- type: HeldSpeedModifier #Sunrise-End
- type: entity
name: paladin greatshield

View file

@ -29,3 +29,16 @@
Greenshift: [0, 200]
Secret: [50, 200]
Extra: [50, 100]
- type: gamePresetPool
id: AllPresetPool
presets:
Nukeops: [30, 200]
Traitor: [5, 200]
Zombie: [10, 200]
Survival: [5, 200]
Revolutionary: [10, 200]
Wizard: [30, 200]
BloodCult: [30, 200]
AssaultOps: [10, 200]
FleshCult: [30, 200]

View file

@ -88,6 +88,18 @@
{
"name": "diona2"
},
{
"name": "diona3",
"delays": [
[
0.2,
0.3,
0.3,
0.5,
0.5
]
]
},
{
"name": "gingerbread0",
"delays": [

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -241,9 +241,10 @@ binds:
- function: OpenGuidebook
type: State
key: NumpadNum0
- function: OpenAHelp
type: State
key: F1
# Sunrise-Edit
#- function: OpenAHelp
# type: State
# key: F1
- function: OpenInventoryMenu
type: State
key: I
@ -647,4 +648,10 @@ binds:
- function: MeleeGunAttack
type: State
key: MouseMiddle
- function: OpenMentorHelp
type: State
key: O
- function: OpenHelpChoice
type: State
key: F1
# Sunrise-End