diff --git a/Content.Client/GameTicking/Managers/ClientGameTicker.cs b/Content.Client/GameTicking/Managers/ClientGameTicker.cs index cf49b92a78..0d523327d1 100644 --- a/Content.Client/GameTicking/Managers/ClientGameTicker.cs +++ b/Content.Client/GameTicking/Managers/ClientGameTicker.cs @@ -39,6 +39,8 @@ namespace Content.Client.GameTicking.Managers [ViewVariables] public TimeSpan StartTime { get; private set; } [ViewVariables] public new bool Paused { get; private set; } + public override IReadOnlyList<(TimeSpan, string)> AllPreviousGameRules => new List<(TimeSpan, string)>(); + [ViewVariables] public IReadOnlyDictionary, int?>> JobsAvailable => _jobsAvailable; [ViewVariables] public IReadOnlyDictionary StationNames => _stationNames; diff --git a/Content.Client/UserInterface/Systems/BugReport/BugReportUIController.cs b/Content.Client/UserInterface/Systems/BugReport/BugReportUIController.cs new file mode 100644 index 0000000000..7d45829bd2 --- /dev/null +++ b/Content.Client/UserInterface/Systems/BugReport/BugReportUIController.cs @@ -0,0 +1,113 @@ +using Content.Client.Gameplay; +using Content.Client.Resources; +using Content.Client.UserInterface.Controls; +using Content.Client.UserInterface.Systems.BugReport.Windows; +using Content.Client.UserInterface.Systems.MenuBar.Widgets; +using Content.Shared.BugReport; +using Content.Shared.CCVar; +using JetBrains.Annotations; +using Robust.Client.ResourceManagement; +using Robust.Client.UserInterface.Controllers; +using Robust.Client.UserInterface.Controls; +using Robust.Shared.Configuration; +using Robust.Shared.Network; +using Robust.Shared.Utility; + +namespace Content.Client.UserInterface.Systems.BugReport; + +[UsedImplicitly] +public sealed class BugReportUIController : UIController, IOnStateEntered, IOnStateExited +{ + [Dependency] private readonly IClientNetManager _net = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly IResourceCache _resource = default!; + + // This is the link to the hotbar button + private MenuButton? BugReportButton => UIManager.GetActiveUIWidgetOrNull()?.ReportBugButton; + + // Don't clear this window. It needs to be saved so the input doesn't get erased when it's closed! + private BugReportWindow _bugReportWindow = default!; + + private ResPath Bug = new("/Textures/Interface/bug.svg.192dpi.png"); + private ResPath Splat = new("/Textures/Interface/splat.svg.192dpi.png"); + + public void OnStateEntered(GameplayState state) + { + SetupWindow(); + } + + public void OnStateExited(GameplayState state) + { + CleanupWindow(); + } + + public void LoadButton() + { + if (BugReportButton != null) + BugReportButton.OnPressed += ButtonToggleWindow; + } + + public void UnloadButton() + { + if (BugReportButton != null) + BugReportButton.OnPressed -= ButtonToggleWindow; + } + + private void SetupWindow() + { + if (BugReportButton == null) + return; + + _bugReportWindow = UIManager.CreateWindow(); + // This is to make sure the hotbar button gets checked and unchecked when the window is opened / closed. + _bugReportWindow.OnClose += () => + { + BugReportButton.Pressed = false; + BugReportButton.Icon = _resource.GetTexture(Bug); + }; + _bugReportWindow.OnOpen += () => + { + BugReportButton.Pressed = true; + BugReportButton.Icon = _resource.GetTexture(Splat); + }; + + _bugReportWindow.OnBugReportSubmitted += OnBugReportSubmitted; + + _cfg.OnValueChanged(CCVars.EnablePlayerBugReports, UpdateButtonVisibility, true); + } + + private void CleanupWindow() + { + _bugReportWindow.CleanupCCvars(); + + _cfg.UnsubValueChanged(CCVars.EnablePlayerBugReports, UpdateButtonVisibility); + } + + private void ToggleWindow() + { + if (_bugReportWindow.IsOpen) + _bugReportWindow.Close(); + else + _bugReportWindow.OpenCentered(); + } + + private void OnBugReportSubmitted(PlayerBugReportInformation report) + { + var message = new BugReportMessage { ReportInformation = report }; + _net.ClientSendMessage(message); + _bugReportWindow.Close(); + } + + private void ButtonToggleWindow(BaseButton.ButtonEventArgs obj) + { + ToggleWindow(); + } + + private void UpdateButtonVisibility(bool val) + { + if (BugReportButton == null) + return; + + BugReportButton.Visible = val; + } +} diff --git a/Content.Client/UserInterface/Systems/BugReport/Windows/BugReportWindow.xaml b/Content.Client/UserInterface/Systems/BugReport/Windows/BugReportWindow.xaml new file mode 100644 index 0000000000..d3d6570a41 --- /dev/null +++ b/Content.Client/UserInterface/Systems/BugReport/Windows/BugReportWindow.xaml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Content.Client/UserInterface/Systems/BugReport/Windows/BugReportWindow.xaml.cs b/Content.Client/UserInterface/Systems/BugReport/Windows/BugReportWindow.xaml.cs new file mode 100644 index 0000000000..a43ba15d68 --- /dev/null +++ b/Content.Client/UserInterface/Systems/BugReport/Windows/BugReportWindow.xaml.cs @@ -0,0 +1,181 @@ +using System.Diagnostics.CodeAnalysis; +using Content.Client.Players.PlayTimeTracking; +using Content.Shared.BugReport; +using Content.Shared.CCVar; +using Robust.Client.AutoGenerated; +using Robust.Client.UserInterface.CustomControls; +using Robust.Client.UserInterface.XAML; +using Robust.Shared.Configuration; +using Robust.Shared.Timing; +using Robust.Shared.Utility; + +namespace Content.Client.UserInterface.Systems.BugReport.Windows; + +[GenerateTypedNameReferences] +public sealed partial class BugReportWindow : DefaultWindow +{ + [Dependency] private readonly IConfigurationManager _cfg = default!; + // TODO: Use SharedPlaytimeManager when its refactored out of job requirements + [Dependency] private readonly JobRequirementsManager _job = default!; + + // This action gets invoked when the user submits a bug report. + public event Action? OnBugReportSubmitted; + + private DateTime _lastIsEnabledUpdated; + private readonly TimeSpan _isEnabledUpdateInterval = TimeSpan.FromSeconds(1); + + // These are NOT always up to date. If someone disconnects and reconnects, the values will be reset. + // The only other way of getting updated values would be a message from client -> server then from server -> client. + // I don't think that is worth the added complexity. + private DateTime _lastBugReportSubmittedTime = DateTime.MinValue; + private int _amountOfBugReportsSubmitted; + + private readonly ConfigurationMultiSubscriptionBuilder _configSub; + + #region ccvar + + private bool _enablePlayerBugReports; + private int _minimumPlaytimeBugReports; + private int _minimumTimeBetweenBugReports; + private int _maximumBugReportsPerRound; + + private int _maximumBugReportTitleLength; + private int _minimumBugReportTitleLength; + private int _maximumBugReportDescriptionLength; + private int _minimumBugReportDescriptionLength; + + #endregion + + public BugReportWindow() + { + RobustXamlLoader.Load(this); + IoCManager.InjectDependencies(this); + + _configSub = _cfg.SubscribeMultiple() + .OnValueChanged(CCVars.EnablePlayerBugReports, x => _enablePlayerBugReports = x, true) + .OnValueChanged(CCVars.MinimumPlaytimeInMinutesToEnableBugReports, x => _minimumPlaytimeBugReports = x, true) + .OnValueChanged(CCVars.MinimumSecondsBetweenBugReports, x => _minimumTimeBetweenBugReports = x, true) + .OnValueChanged(CCVars.MaximumBugReportsPerRound, x => _maximumBugReportsPerRound = x, true) + .OnValueChanged(CCVars.MaximumBugReportTitleLength, x => _maximumBugReportTitleLength = x, true) + .OnValueChanged(CCVars.MinimumBugReportTitleLength, x => _minimumBugReportTitleLength = x, true) + .OnValueChanged(CCVars.MaximumBugReportDescriptionLength, x => _maximumBugReportDescriptionLength = x, true) + .OnValueChanged(CCVars.MinimumBugReportDescriptionLength, x => _minimumBugReportDescriptionLength = x, true); + + // Hook up the events + SubmitButton.OnPressed += _ => OnSubmitButtonPressed(); + BugReportTitle.OnTextChanged += _ => HandleInputChange(); + BugReportDescription.OnTextChanged += _ => HandleInputChange(); + OnOpen += UpdateEnabled; + + HandleInputChange(); + UpdateEnabled(); + } + + private void OnSubmitButtonPressed() + { + var report = new PlayerBugReportInformation + { + BugReportTitle = BugReportTitle.Text, + BugReportDescription = Rope.Collapse(BugReportDescription.TextRope), + }; + OnBugReportSubmitted?.Invoke(report); + + _lastBugReportSubmittedTime = DateTime.UtcNow; + _amountOfBugReportsSubmitted++; + + BugReportTitle.Text = string.Empty; + BugReportDescription.TextRope = Rope.Leaf.Empty; + + HandleInputChange(); + UpdateEnabled(); + } + + /// + /// Deals with the user changing their input. Ensures that things that depend on what the user has inputted get updated + /// (E.g. the amount of characters they have typed) + /// + private void HandleInputChange() + { + var titleLen = BugReportTitle.Text.Length; + var descriptionLen = BugReportDescription.TextLength; + + var invalidTitleLen = titleLen < _minimumBugReportTitleLength || titleLen > _maximumBugReportTitleLength; + var invalidDescriptionLen = descriptionLen < _minimumBugReportDescriptionLength || descriptionLen > _maximumBugReportDescriptionLength; + + TitleCharacterCounter.Text = Loc.GetString("bug-report-window-submit-char-split", ("typed", titleLen), ("total", _maximumBugReportTitleLength)); + TitleCharacterCounter.FontColorOverride = invalidTitleLen ? Color.Red : Color.Green; + + DescriptionCharacterCounter.Text = Loc.GetString("bug-report-window-submit-char-split", ("typed", descriptionLen), ("total", _maximumBugReportDescriptionLength)); + + DescriptionCharacterCounter.FontColorOverride = invalidDescriptionLen ? Color.Red : Color.Green; + + SubmitButton.Disabled = invalidTitleLen || invalidDescriptionLen; + + PlaceholderCenter.Visible = descriptionLen == 0; + } + + /// + /// Checks if the bug report window should be enabled for this client. + /// + private bool IsEnabled([NotNullWhen(false)] out string? errorMessage) + { + errorMessage = null; + + if (!_enablePlayerBugReports) + { + errorMessage = Loc.GetString("bug-report-window-disabled-not-enabled"); + return false; + } + + if (TimeSpan.FromMinutes(_minimumPlaytimeBugReports) > _job.FetchOverallPlaytime()) + { + errorMessage = Loc.GetString("bug-report-window-disabled-playtime"); + return false; + } + + if (_amountOfBugReportsSubmitted >= _maximumBugReportsPerRound) + { + errorMessage = Loc.GetString("bug-report-window-disabled-submissions", ("num", _maximumBugReportsPerRound)); + return false; + } + + var timeSinceLastReport = DateTime.UtcNow - _lastBugReportSubmittedTime; + var timeBetweenBugReports = TimeSpan.FromSeconds(_minimumTimeBetweenBugReports); + + if (timeSinceLastReport <= timeBetweenBugReports) + { + var time = timeBetweenBugReports - timeSinceLastReport; + errorMessage = Loc.GetString("bug-report-window-disabled-cooldown", ("time", time.ToString(@"d\.hh\:mm\:ss"))); + return false; + } + + return true; + } + + // Update the state of the window to display either the bug report window or an error explaining why you can't submit a report. + private void UpdateEnabled() + { + var isEnabled = IsEnabled(out var errorMessage); + DisabledLabel.Text = errorMessage; + + DisabledLabel.Visible = !isEnabled; + BugReportContainer.Visible = isEnabled; + _lastIsEnabledUpdated = DateTime.UtcNow; + } + + protected override void FrameUpdate(FrameEventArgs args) + { + base.FrameUpdate(args); + + if (!Visible) // Don't bother updating if no one can see the window anyway. + return; + + if(DateTime.UtcNow - _lastIsEnabledUpdated > _isEnabledUpdateInterval) + UpdateEnabled(); + } + + public void CleanupCCvars() + { + _configSub.Dispose(); + } +} diff --git a/Content.Client/UserInterface/Systems/MenuBar/GameTopMenuBarUIController.cs b/Content.Client/UserInterface/Systems/MenuBar/GameTopMenuBarUIController.cs index a10ff0f3c1..fb7c7f9d25 100644 --- a/Content.Client/UserInterface/Systems/MenuBar/GameTopMenuBarUIController.cs +++ b/Content.Client/UserInterface/Systems/MenuBar/GameTopMenuBarUIController.cs @@ -1,5 +1,6 @@ using Content.Client.UserInterface.Systems.Actions; using Content.Client.UserInterface.Systems.Admin; +using Content.Client.UserInterface.Systems.BugReport; using Content.Client.UserInterface.Systems.Bwoink; using Content.Client.UserInterface.Systems.Character; using Content.Client.UserInterface.Systems.Crafting; @@ -24,6 +25,7 @@ public sealed class GameTopMenuBarUIController : UIController [Dependency] private readonly SandboxUIController _sandbox = default!; [Dependency] private readonly GuidebookUIController _guidebook = default!; [Dependency] private readonly EmotesUIController _emotes = default!; + [Dependency] private readonly BugReportUIController _bug = default!; private GameTopMenuBar? GameTopMenuBar => UIManager.GetActiveUIWidgetOrNull(); @@ -46,7 +48,8 @@ public sealed class GameTopMenuBarUIController : UIController _ahelp.UnloadButton(); _action.UnloadButton(); _sandbox.UnloadButton(); - //_emotes.UnloadButton(); + _emotes.UnloadButton(); + _bug.UnloadButton(); } public void LoadButtons() @@ -59,6 +62,7 @@ public sealed class GameTopMenuBarUIController : UIController _ahelp.LoadButton(); _action.LoadButton(); _sandbox.LoadButton(); - //_emotes.LoadButton(); + _emotes.LoadButton(); + _bug.LoadButton(); } } diff --git a/Content.Client/UserInterface/Systems/MenuBar/Widgets/GameTopMenuBar.xaml b/Content.Client/UserInterface/Systems/MenuBar/Widgets/GameTopMenuBar.xaml index dc8972970a..2c09666fdf 100644 --- a/Content.Client/UserInterface/Systems/MenuBar/Widgets/GameTopMenuBar.xaml +++ b/Content.Client/UserInterface/Systems/MenuBar/Widgets/GameTopMenuBar.xaml @@ -93,6 +93,15 @@ HorizontalExpand="True" AppendStyleClass="{x:Static style:StyleBase.ButtonSquare}" /> + (); + + await pair.Server.WaitAssertion(() => + { + Assert.Multiple(() => + { + foreach (var (proto, comp) in pair.GetPrototypesWithComponent()) + { + Assert.That(proto.TryGetComponent(out _, componentFactory), $"Found MobPriceComponent on {proto.ID}, but no BodyComponent!"); + Assert.That(proto.TryGetComponent(out _, componentFactory), $"Found MobPriceComponent on {proto.ID}, but no MobStateComponent!"); + } + }); + }); + + await pair.CleanReturnAsync(); + } } diff --git a/Content.Server/BugReports/BugReportManager.cs b/Content.Server/BugReports/BugReportManager.cs new file mode 100644 index 0000000000..79c8424230 --- /dev/null +++ b/Content.Server/BugReports/BugReportManager.cs @@ -0,0 +1,221 @@ +using System.Linq; +using Content.Server.Administration.Logs; +using Content.Server.GameTicking; +using Content.Server.Github; +using Content.Server.Maps; +using Content.Server.Players.PlayTimeTracking; +using Content.Shared.BugReport; +using Content.Shared.CCVar; +using Content.Shared.Database; +using Robust.Server.Player; +using Robust.Shared; +using Robust.Shared.Configuration; +using Robust.Shared.Network; +using Robust.Shared.Timing; + +namespace Content.Server.BugReports; + +/// +public sealed class BugReportManager : IBugReportManager, IPostInjectInit +{ + [Dependency] private readonly IServerNetManager _net = default!; + [Dependency] private readonly IEntityManager _entity = default!; + [Dependency] private readonly PlayTimeTrackingManager _playTime = default!; + [Dependency] private readonly IPlayerManager _player = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly IAdminLogManager _admin = default!; + [Dependency] private readonly IGameMapManager _map = default!; + [Dependency] private readonly GithubApiManager _githubApiManager = default!; + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly ILogManager _log = default!; + + private ISawmill _sawmill = default!; + + /// + /// List of player NetIds and the number of bug reports they have submitted this round. + /// UserId -> (bug reports this round, last submitted bug report) + /// + private readonly Dictionary _bugReportsPerPlayerThisRound = new(); + + private BugReportLimits _limits = default!; + + private List _tags = []; + + private ConfigurationMultiSubscriptionBuilder _configSub = default!; + + public void Initialize() + { + _net.RegisterNetMessage(ReceivedPlayerBugReport); + + _limits = new BugReportLimits(); + + _configSub = _cfg.SubscribeMultiple() + .OnValueChanged(CCVars.MaximumBugReportTitleLength, x => _limits.TitleMaxLength = x, true) + .OnValueChanged(CCVars.MinimumBugReportTitleLength, x => _limits.TitleMinLength = x, true) + .OnValueChanged(CCVars.MaximumBugReportDescriptionLength, x => _limits.DescriptionMaxLength = x, true) + .OnValueChanged(CCVars.MinimumBugReportDescriptionLength, x => _limits.DescriptionMinLength = x, true) + .OnValueChanged(CCVars.MinimumPlaytimeInMinutesToEnableBugReports, x => _limits.MinimumPlaytimeToEnableBugReports = TimeSpan.FromMinutes(x), true) + .OnValueChanged(CCVars.MaximumBugReportsPerRound, x => _limits.MaximumBugReportsForPlayerPerRound = x, true) + .OnValueChanged(CCVars.MinimumSecondsBetweenBugReports, x => _limits.MinimumTimeBetweenBugReports = TimeSpan.FromSeconds(x), true) + .OnValueChanged(CCVars.BugReportTags, x => _tags = x.Split(",").ToList(), true); + } + + public void Restart() + { + // When the round restarts, clear the dictionary. + _bugReportsPerPlayerThisRound.Clear(); + } + + public void Shutdown() + { + _configSub.Dispose(); + } + + private void ReceivedPlayerBugReport(BugReportMessage message) + { + if (!_cfg.GetCVar(CCVars.EnablePlayerBugReports)) + return; + + var netId = message.MsgChannel.UserId; + var userName = message.MsgChannel.UserName; + var report = message.ReportInformation; + if (!IsBugReportValid(report, (NetId: netId, UserName: userName)) || !CanPlayerSendReport(netId, userName)) + return; + + var playerBugReportingStats = _bugReportsPerPlayerThisRound.GetValueOrDefault(netId); + _bugReportsPerPlayerThisRound[netId] = (playerBugReportingStats.ReportsCount + 1, DateTime.UtcNow); + + var title = report.BugReportTitle; + var description = report.BugReportDescription; + + _admin.Add(LogType.BugReport, LogImpact.High, $"{message.MsgChannel.UserName}, {netId}: submitted a bug report. Title: {title}, Description: {description}"); + + var bugReport = CreateBugReport(message); + + _githubApiManager.TryCreateIssue(bugReport); + } + + /// + /// Checks that the given report is valid (E.g. not too long etc...). + /// Logs problems if report is invalid. + /// + /// True if the report is valid, false there is an issue with the report. + private bool IsBugReportValid(PlayerBugReportInformation report, (NetUserId NetId, string UserName) userData) + { + var descriptionLen = report.BugReportDescription.Length; + var titleLen = report.BugReportTitle.Length; + + // These should only happen if there is a hacked client or a glitch! + if (titleLen < _limits.TitleMinLength || titleLen > _limits.TitleMaxLength) + { + _sawmill.Warning( + $"{userData.UserName}, {userData.NetId}: has tried to submit a bug report " + + $"with a title of {titleLen} characters, min/max: {_limits.TitleMinLength}/{_limits.TitleMaxLength}." + ); + return false; + } + + if (descriptionLen < _limits.DescriptionMinLength || descriptionLen > _limits.DescriptionMaxLength) + { + _sawmill.Warning( + $"{userData.UserName}, {userData.NetId}: has tried to submit a bug report " + + $"with a description of {descriptionLen} characters, min/max: {_limits.DescriptionMinLength}/{_limits.DescriptionMaxLength}." + ); + return false; + } + + return true; + } + + /// + /// Checks that the player sending the report is allowed to (E.g. not spamming etc...). + /// Logs problems if report is invalid. + /// + /// True if the player can submit a report, false if they can't. + private bool CanPlayerSendReport(NetUserId netId, string userName) + { + var session = _player.GetSessionById(netId); + var playtime = _playTime.GetOverallPlaytime(session); + if (_limits.MinimumPlaytimeToEnableBugReports > playtime) + return false; + + var playerBugReportingStats = _bugReportsPerPlayerThisRound.GetValueOrDefault(netId); + var maximumBugReportsForPlayerPerRound = _limits.MaximumBugReportsForPlayerPerRound; + if (playerBugReportingStats.ReportsCount >= maximumBugReportsForPlayerPerRound) + { + _admin.Add(LogType.BugReport, + LogImpact.High, + $"{userName}, {netId}: has tried to submit more than {maximumBugReportsForPlayerPerRound} bug reports this round."); + return false; + } + + var timeSinceLastReport = DateTime.UtcNow - playerBugReportingStats.ReportedDateTime; + var timeBetweenBugReports = _limits.MinimumTimeBetweenBugReports; + if (timeSinceLastReport <= timeBetweenBugReports) + { + _admin.Add(LogType.BugReport, + LogImpact.High, + $"{userName}, {netId}: has tried to submit a bug report. " + + $"Last bug report was {timeSinceLastReport:g} ago. The limit is {timeBetweenBugReports:g} minutes." + ); + return false; + } + + return true; + } + + /// + /// Create a bug report out of the given message. Add will extra metadata that could be useful, along with + /// the original text report from the user. + /// + /// The message from user. + /// A based of the user report. + private ValidPlayerBugReportReceivedEvent CreateBugReport(BugReportMessage message) + { + // todo: dont request entity system out of sim, check if you are in-sim before doing so. Bug report should work out of sim too. + var ticker = _entity.System(); + var metadata = new BugReportMetaData + { + Username = message.MsgChannel.UserName, + PlayerGUID = message.MsgChannel.UserData.UserId, + ServerName = _cfg.GetCVar(CCVars.AdminLogsServerName), + NumberOfPlayers = _player.PlayerCount, + SubmittedTime = DateTime.UtcNow, + BuildVersion = _cfg.GetCVar(CVars.BuildVersion), + EngineVersion = _cfg.GetCVar(CVars.BuildEngineVersion), + }; + + // Only add these if your in round. + if (ticker.Preset != null) + { + metadata.RoundTime = _timing.CurTime.Subtract(ticker.RoundStartTimeSpan); + metadata.RoundNumber = ticker.RoundId; + metadata.RoundType = Loc.GetString(ticker.CurrentPreset?.ModeTitle ?? "bug-report-report-unknown"); + metadata.Map = _map.GetSelectedMap()?.MapName ?? Loc.GetString("bug-report-report-unknown"); + } + + return new ValidPlayerBugReportReceivedEvent( + message.ReportInformation.BugReportTitle.Trim(), + message.ReportInformation.BugReportDescription.Trim(), + metadata, + _tags + ); + } + + void IPostInjectInit.PostInject() + { + _sawmill = _log.GetSawmill("BugReport"); + } + + private sealed class BugReportLimits + { + public int TitleMaxLength; + public int TitleMinLength; + public int DescriptionMaxLength; + public int DescriptionMinLength; + + public TimeSpan MinimumPlaytimeToEnableBugReports; + public int MaximumBugReportsForPlayerPerRound; + public TimeSpan MinimumTimeBetweenBugReports; + } +} diff --git a/Content.Server/BugReports/IBugReportEvents.cs b/Content.Server/BugReports/IBugReportEvents.cs new file mode 100644 index 0000000000..3e79ff542e --- /dev/null +++ b/Content.Server/BugReports/IBugReportEvents.cs @@ -0,0 +1,94 @@ +using Robust.Shared.Network; + +namespace Content.Server.BugReports; + +/// +/// This event stores information related to a player submitted bug report. +/// +public sealed class ValidPlayerBugReportReceivedEvent(string title, string description, BugReportMetaData metaData, List tags) : EventArgs +{ + /// + /// Title for the bug report. This is player controlled! + /// + public string Title = title; + + /// + /// Description for the bug report. This is player controlled! + /// + public string Description = description; + + /// + /// Metadata for bug report, containing data collected by server. + /// + public BugReportMetaData MetaData = metaData; + + public List Tags = tags; +} + +/// +/// Metadata for a bug report. Holds relevant data for bug reports that aren't directly player controlled. +/// +public sealed class BugReportMetaData +{ + /// + /// Bug reporter SS14 username. + /// + /// piggylongsnout + public required string Username; + + /// + /// The GUID of the player who reported the bug. + /// + public required NetUserId PlayerGUID; + + /// + /// Name of the server from which bug report was issued. + /// + /// DeltaV> + public required string ServerName; + + /// + /// Date and time on which player submitted report (NOT round time). + /// The time is UTC and based off the servers clock. + /// + public required DateTime SubmittedTime; + + /// + /// Time that has elapsed in the round. Can be null if bug was not reported during a round. + /// + public TimeSpan? RoundTime; + + /// + /// Round number during which bug report was issued. Can be null if bug was reported not during round. + /// + /// 1311 + public int? RoundNumber; + + /// + /// Type preset title (type of round that is being played). Can be null if bug was reported not during round. + /// + /// Sandbox + public string? RoundType; + + /// + /// The map being played. + /// + /// "Dev"> + public string? Map; + + /// + /// Number of players currently on server. + /// + public int NumberOfPlayers; + + /// + /// Build version of the game. + /// + public required string BuildVersion; + + /// + /// Engine version of the game. + /// + /// 253.0.0 + public required string EngineVersion; +} diff --git a/Content.Server/BugReports/IBugReportManager.cs b/Content.Server/BugReports/IBugReportManager.cs new file mode 100644 index 0000000000..28264bc8c0 --- /dev/null +++ b/Content.Server/BugReports/IBugReportManager.cs @@ -0,0 +1,22 @@ +namespace Content.Server.BugReports; + +/// +/// Manager for validating client bug reports, issued in-game, and relaying creation of issue in tracker to dedicated api client. +/// +public interface IBugReportManager +{ + /// Will get called when the manager is first initialized. + public void Initialize(); + + /// + /// Will get called whenever the round is restarted. + /// Should be used to clean up anything that needs reset after each round. + /// + public void Restart(); + + /// + /// Will get called whenever the round is restarted. + /// Should be used to clean up anything that needs reset after each round. + /// + public void Shutdown(); +} diff --git a/Content.Server/Entry/EntryPoint.cs b/Content.Server/Entry/EntryPoint.cs index c50e26c118..a046a0d53f 100644 --- a/Content.Server/Entry/EntryPoint.cs +++ b/Content.Server/Entry/EntryPoint.cs @@ -9,6 +9,7 @@ using Content.Server.Administration; using Content.Server.Administration.Logs; using Content.Server.Administration.Managers; using Content.Server.Afk; +using Content.Server.BugReports; using Content.Server.Chat.Managers; using Content.Server.Connection; using Content.Server.Database; @@ -16,13 +17,12 @@ using Content.Server.Discord.DiscordLink; using Content.Server.EUI; using Content.Server.GameTicking; using Content.Server.GhostKick; +using Content.Server.Github; using Content.Server.GuideGenerator; using Content.Server.Info; using Content.Server.IoC; using Content.Server.Maps; using Content.Server.NodeContainer.NodeGroups; -using Content.Server.Objectives; -using Content.Server.Players; using Content.Server.Players.JobWhitelist; using Content.Server.Players.PlayTimeTracking; using Content.Server.Players.RateLimiting; @@ -124,6 +124,10 @@ namespace Content.Server.Entry IoCManager.Resolve().Initialize(); IoCManager.Resolve().Initialize(); IoCManager.Resolve().Initialize(); + IoCManager.Resolve().Initialize(); + IoCManager.Resolve().Initialize(); + IoCManager.Resolve().Initialize(); + IoCManager.Resolve().Initialize(); IoCManager.Resolve().Initialize(); // Sunrise-Edit IoCManager.Resolve().Initialize(); // Sunrise-Edit @@ -232,6 +236,8 @@ namespace Content.Server.Entry IoCManager.Resolve().Shutdown(); IoCManager.Resolve().Shutdown(); + + IoCManager.Resolve().Shutdown(); } private static void LoadConfigPresets(IConfigurationManager cfg, IResourceManager res, ISawmill sawmill) diff --git a/Content.Server/GameTicking/Commands/DynamicRuleCommand.cs b/Content.Server/GameTicking/Commands/DynamicRuleCommand.cs new file mode 100644 index 0000000000..798e7d0d3a --- /dev/null +++ b/Content.Server/GameTicking/Commands/DynamicRuleCommand.cs @@ -0,0 +1,103 @@ +using System.Linq; +using Content.Server.Administration; +using Content.Server.GameTicking.Rules; +using Content.Shared.Administration; +using Robust.Shared.Prototypes; +using Robust.Shared.Toolshed; + +namespace Content.Server.GameTicking.Commands; + +[ToolshedCommand, AdminCommand(AdminFlags.Round)] +public sealed class DynamicRuleCommand : ToolshedCommand +{ + private DynamicRuleSystem? _dynamicRuleSystem; + + [CommandImplementation("list")] + public IEnumerable List() + { + _dynamicRuleSystem ??= GetSys(); + + return _dynamicRuleSystem.GetDynamicRules(); + } + + [CommandImplementation("get")] + public EntityUid Get() + { + _dynamicRuleSystem ??= GetSys(); + + return _dynamicRuleSystem.GetDynamicRules().FirstOrDefault(); + } + + [CommandImplementation("budget")] + public IEnumerable Budget([PipedArgument] IEnumerable input) + => input.Select(Budget); + + [CommandImplementation("budget")] + public float? Budget([PipedArgument] EntityUid input) + { + _dynamicRuleSystem ??= GetSys(); + + return _dynamicRuleSystem.GetRuleBudget(input); + } + + [CommandImplementation("adjust")] + public IEnumerable Adjust([PipedArgument] IEnumerable input, float value) + => input.Select(i => Adjust(i,value)); + + [CommandImplementation("adjust")] + public float? Adjust([PipedArgument] EntityUid input, float value) + { + _dynamicRuleSystem ??= GetSys(); + + return _dynamicRuleSystem.AdjustBudget(input, value); + } + + [CommandImplementation("set")] + public IEnumerable Set([PipedArgument] IEnumerable input, float value) + => input.Select(i => Set(i,value)); + + [CommandImplementation("set")] + public float? Set([PipedArgument] EntityUid input, float value) + { + _dynamicRuleSystem ??= GetSys(); + + return _dynamicRuleSystem.SetBudget(input, value); + } + + [CommandImplementation("dryrun")] + public IEnumerable> DryRun([PipedArgument] IEnumerable input) + => input.Select(DryRun); + + [CommandImplementation("dryrun")] + public IEnumerable DryRun([PipedArgument] EntityUid input) + { + _dynamicRuleSystem ??= GetSys(); + + return _dynamicRuleSystem.DryRun(input); + } + + [CommandImplementation("executenow")] + public IEnumerable> ExecuteNow([PipedArgument] IEnumerable input) + => input.Select(ExecuteNow); + + [CommandImplementation("executenow")] + public IEnumerable ExecuteNow([PipedArgument] EntityUid input) + { + _dynamicRuleSystem ??= GetSys(); + + return _dynamicRuleSystem.ExecuteNow(input); + } + + [CommandImplementation("rules")] + public IEnumerable> Rules([PipedArgument] IEnumerable input) + => input.Select(Rules); + + [CommandImplementation("rules")] + public IEnumerable Rules([PipedArgument] EntityUid input) + { + _dynamicRuleSystem ??= GetSys(); + + return _dynamicRuleSystem.Rules(input); + } +} + diff --git a/Content.Server/GameTicking/GameTicker.GameRule.cs b/Content.Server/GameTicking/GameTicker.GameRule.cs index cf0b0eceb1..1750d3c27a 100644 --- a/Content.Server/GameTicking/GameTicker.GameRule.cs +++ b/Content.Server/GameTicking/GameTicker.GameRule.cs @@ -21,7 +21,7 @@ public sealed partial class GameTicker /// A list storing the start times of all game rules that have been started this round. /// Game rules can be started and stopped at any time, including midround. /// - public IReadOnlyList<(TimeSpan, string)> AllPreviousGameRules => _allPreviousGameRules; + public override IReadOnlyList<(TimeSpan, string)> AllPreviousGameRules => _allPreviousGameRules; private void InitializeGameRules() { diff --git a/Content.Server/GameTicking/GameTicker.RoundFlow.cs b/Content.Server/GameTicking/GameTicker.RoundFlow.cs index 3e57e62093..4d858ca335 100644 --- a/Content.Server/GameTicking/GameTicker.RoundFlow.cs +++ b/Content.Server/GameTicking/GameTicker.RoundFlow.cs @@ -766,6 +766,8 @@ namespace Content.Server.GameTicking _banManager.Restart(); + _bugManager.Restart(); + _gameMapManager.ClearSelectedMap(); // Clear up any game rules. diff --git a/Content.Server/GameTicking/GameTicker.cs b/Content.Server/GameTicking/GameTicker.cs index 55bf51db02..290d363047 100644 --- a/Content.Server/GameTicking/GameTicker.cs +++ b/Content.Server/GameTicking/GameTicker.cs @@ -1,5 +1,6 @@ using Content.Server.Administration.Logs; using Content.Server.Administration.Managers; +using Content.Server.BugReports; using Content.Server.Chat.Managers; using Content.Server.Chat.Systems; using Content.Server.Database; @@ -65,6 +66,7 @@ namespace Content.Server.GameTicking [Dependency] private readonly MetaDataSystem _metaData = default!; [Dependency] private readonly SharedRoleSystem _roles = default!; [Dependency] private readonly ServerDbEntryManager _dbEntryManager = default!; + [Dependency] private readonly IBugReportManager _bugManager = default!; [ViewVariables] private bool _initialized; [ViewVariables] private bool _postInitialized; diff --git a/Content.Server/GameTicking/Rules/DynamicRuleSystem.cs b/Content.Server/GameTicking/Rules/DynamicRuleSystem.cs new file mode 100644 index 0000000000..b23e9d40f2 --- /dev/null +++ b/Content.Server/GameTicking/Rules/DynamicRuleSystem.cs @@ -0,0 +1,195 @@ +using System.Diagnostics; +using Content.Server.Administration.Logs; +using Content.Server.RoundEnd; +using Content.Shared.Database; +using Content.Shared.EntityTable; +using Content.Shared.EntityTable.Conditions; +using Content.Shared.GameTicking.Components; +using Content.Shared.GameTicking.Rules; +using Robust.Shared.Prototypes; +using Robust.Shared.Random; + +namespace Content.Server.GameTicking.Rules; + +public sealed class DynamicRuleSystem : GameRuleSystem +{ + [Dependency] private readonly IAdminLogManager _adminLog = default!; + [Dependency] private readonly EntityTableSystem _entityTable = default!; + [Dependency] private readonly RoundEndSystem _roundEnd = default!; + [Dependency] private readonly IRobustRandom _random = default!; + + protected override void Added(EntityUid uid, DynamicRuleComponent component, GameRuleComponent gameRule, GameRuleAddedEvent args) + { + base.Added(uid, component, gameRule, args); + + component.Budget = _random.Next(component.StartingBudgetMin, component.StartingBudgetMax);; + component.NextRuleTime = Timing.CurTime + _random.Next(component.MinRuleInterval, component.MaxRuleInterval); + } + + protected override void Started(EntityUid uid, DynamicRuleComponent component, GameRuleComponent gameRule, GameRuleStartedEvent args) + { + base.Started(uid, component, gameRule, args); + + // Since we don't know how long until this rule is activated, we need to + // set the last budget update to now so it doesn't immediately give the component a bunch of points. + component.LastBudgetUpdate = Timing.CurTime; + Execute((uid, component)); + } + + protected override void Ended(EntityUid uid, DynamicRuleComponent component, GameRuleComponent gameRule, GameRuleEndedEvent args) + { + base.Ended(uid, component, gameRule, args); + + foreach (var rule in component.Rules) + { + GameTicker.EndGameRule(rule); + } + } + + protected override void ActiveTick(EntityUid uid, DynamicRuleComponent component, GameRuleComponent gameRule, float frameTime) + { + base.ActiveTick(uid, component, gameRule, frameTime); + + if (Timing.CurTime < component.NextRuleTime) + return; + + // don't spawn antags during evac + if (_roundEnd.IsRoundEndRequested()) + return; + + Execute((uid, component)); + } + + /// + /// Generates and returns a list of randomly selected, + /// valid rules to spawn based on . + /// + private IEnumerable GetRuleSpawns(Entity entity) + { + UpdateBudget((entity.Owner, entity.Comp)); + var ctx = new EntityTableContext(new Dictionary + { + { HasBudgetCondition.BudgetContextKey, entity.Comp.Budget }, + }); + + return _entityTable.GetSpawns(entity.Comp.Table, ctx: ctx); + } + + /// + /// Updates the budget of the provided dynamic rule component based on the amount of time since the last update + /// multiplied by the value. + /// + private void UpdateBudget(Entity entity) + { + var duration = (float) (Timing.CurTime - entity.Comp.LastBudgetUpdate).TotalSeconds; + + entity.Comp.Budget += duration * entity.Comp.BudgetPerSecond; + entity.Comp.LastBudgetUpdate = Timing.CurTime; + } + + /// + /// Executes this rule, generating new dynamic rules and starting them. + /// + /// + /// Returns a list of the rules that were executed. + /// + private List Execute(Entity entity) + { + entity.Comp.NextRuleTime = + Timing.CurTime + _random.Next(entity.Comp.MinRuleInterval, entity.Comp.MaxRuleInterval); + + var executedRules = new List(); + + foreach (var rule in GetRuleSpawns(entity)) + { + var res = GameTicker.StartGameRule(rule, out var ruleUid); + Debug.Assert(res); + + executedRules.Add(ruleUid); + + if (TryComp(ruleUid, out var cost)) + { + entity.Comp.Budget -= cost.Cost; + _adminLog.Add(LogType.EventRan, LogImpact.High, $"{ToPrettyString(entity)} ran rule {ToPrettyString(ruleUid)} with cost {cost.Cost} on budget {entity.Comp.Budget}."); + } + else + { + _adminLog.Add(LogType.EventRan, LogImpact.High, $"{ToPrettyString(entity)} ran rule {ToPrettyString(ruleUid)} which had no cost."); + } + } + + entity.Comp.Rules.AddRange(executedRules); + return executedRules; + } + + #region Command Methods + + public List GetDynamicRules() + { + var rules = new List(); + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var uid, out _, out var comp)) + { + if (!GameTicker.IsGameRuleActive(uid, comp)) + continue; + rules.Add(uid); + } + + return rules; + } + + public float? GetRuleBudget(Entity entity) + { + if (!Resolve(entity, ref entity.Comp)) + return null; + + UpdateBudget((entity.Owner, entity.Comp)); + return entity.Comp.Budget; + } + + public float? AdjustBudget(Entity entity, float amount) + { + if (!Resolve(entity, ref entity.Comp)) + return null; + + UpdateBudget((entity.Owner, entity.Comp)); + entity.Comp.Budget += amount; + return entity.Comp.Budget; + } + + public float? SetBudget(Entity entity, float amount) + { + if (!Resolve(entity, ref entity.Comp)) + return null; + + entity.Comp.LastBudgetUpdate = Timing.CurTime; + entity.Comp.Budget = amount; + return entity.Comp.Budget; + } + + public IEnumerable DryRun(Entity entity) + { + if (!Resolve(entity, ref entity.Comp)) + return new List(); + + return GetRuleSpawns((entity.Owner, entity.Comp)); + } + + public IEnumerable ExecuteNow(Entity entity) + { + if (!Resolve(entity, ref entity.Comp)) + return new List(); + + return Execute((entity.Owner, entity.Comp)); + } + + public IEnumerable Rules(Entity entity) + { + if (!Resolve(entity, ref entity.Comp)) + return new List(); + + return entity.Comp.Rules; + } + + #endregion +} diff --git a/Content.Server/Github/Commands/TestGithubApiCommand.cs b/Content.Server/Github/Commands/TestGithubApiCommand.cs new file mode 100644 index 0000000000..85ac798057 --- /dev/null +++ b/Content.Server/Github/Commands/TestGithubApiCommand.cs @@ -0,0 +1,71 @@ +using Content.Server.Administration; +using Content.Server.Github.Requests; +using Content.Shared.Administration; +using Content.Shared.CCVar; +using Robust.Shared.Configuration; +using Robust.Shared.Console; + +namespace Content.Server.Github.Commands; + +/// +/// Simple command for testing if the GitHub api is set up correctly! It ensures that all necessary ccvars are set, +/// and will also create one new issue on the targeted repository. +/// +[AdminCommand(AdminFlags.Server)] +public sealed class TestGithubApiCommand : LocalizedCommands +{ + [Dependency] private readonly GithubApiManager _git = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + + public override string Command => Loc.GetString("github-command-test-name"); + + public override async void Execute(IConsoleShell shell, string argStr, string[] args) + { + var enabled = _cfg.GetCVar(CCVars.GithubEnabled); + var path = _cfg.GetCVar(CCVars.GithubAppPrivateKeyPath); + var appId = _cfg.GetCVar(CCVars.GithubAppId); + var repoName = _cfg.GetCVar(CCVars.GithubRepositoryName); + var owner = _cfg.GetCVar(CCVars.GithubRepositoryOwner); + + if (!enabled) + { + shell.WriteError(Loc.GetString("github-command-not-enabled")); + return; + } + + if (string.IsNullOrWhiteSpace(path)) + { + shell.WriteError(Loc.GetString("github-command-no-path")); + return; + } + + if (string.IsNullOrWhiteSpace(appId)) + { + shell.WriteError(Loc.GetString("github-command-no-app-id")); + return; + } + + if (string.IsNullOrWhiteSpace(repoName)) + { + shell.WriteError(Loc.GetString("github-command-no-repo-name")); + return; + } + + if (string.IsNullOrWhiteSpace(owner)) + { + shell.WriteError(Loc.GetString("github-command-no-owner")); + return; + } + + // Create two issues and send them to the api. + var request = new CreateIssueRequest + { + Title = Loc.GetString("github-command-issue-title-one"), + Body = Loc.GetString("github-command-issue-description-one"), + }; + + _git.TryMakeRequest(request); + + shell.WriteLine(Loc.GetString("github-command-finish")); + } +} diff --git a/Content.Server/Github/GithubApiManager.cs b/Content.Server/Github/GithubApiManager.cs new file mode 100644 index 0000000000..44f164fdf0 --- /dev/null +++ b/Content.Server/Github/GithubApiManager.cs @@ -0,0 +1,53 @@ +using Content.Server.Github.Requests; +using System.Threading.Tasks; +using Content.Server.BugReports; + +namespace Content.Server.Github; + +public sealed class GithubApiManager +{ + [Dependency] private readonly GithubBackgroundWorker _githubWorker = default!; + + public void Initialize() + { + Task.Run(() => _githubWorker.HandleQueue()); + } + + public bool TryCreateIssue(ValidPlayerBugReportReceivedEvent bugReport) + { + var createIssueRequest = ConvertToCreateIssue(bugReport); + return TryMakeRequest(createIssueRequest); + } + + public bool TryMakeRequest(IGithubRequest request) + { + return _githubWorker.Writer.TryWrite(request); + } + + private CreateIssueRequest ConvertToCreateIssue(ValidPlayerBugReportReceivedEvent bugReport) + { + var request = new CreateIssueRequest + { + Title = bugReport.Title, + Labels = bugReport.Tags, + }; + + var metadata = bugReport.MetaData; + + request.Body = Loc.GetString("github-issue-format", + ("description", bugReport.Description), + ("buildVersion", metadata.BuildVersion), + ("engineVersion", metadata.EngineVersion), + ("serverName", metadata.ServerName), + ("submittedTime", metadata.SubmittedTime), + ("roundNumber", metadata.RoundNumber.ToString() ?? ""), + ("roundTime", metadata.RoundTime.ToString() ?? ""), + ("roundType", metadata.RoundType ?? ""), + ("map", metadata.Map ?? ""), + ("numberOfPlayers", metadata.NumberOfPlayers), + ("username", metadata.Username), + ("playerGUID", metadata.PlayerGUID)); + + return request; + } +} diff --git a/Content.Server/Github/GithubBackgroundWorker.cs b/Content.Server/Github/GithubBackgroundWorker.cs new file mode 100644 index 0000000000..06d85dd001 --- /dev/null +++ b/Content.Server/Github/GithubBackgroundWorker.cs @@ -0,0 +1,74 @@ +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Content.Server.Github.Requests; +using Content.Shared.CCVar; +using Robust.Shared.Configuration; + +namespace Content.Server.Github; + +public sealed class GithubBackgroundWorker +{ + [Dependency] private readonly GithubClient _client = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly ILogManager _log = default!; + + private ISawmill _sawmill = default!; + + private bool _enabled; + private readonly Channel _channel = Channel.CreateUnbounded(); + private readonly CancellationTokenSource _cts = new CancellationTokenSource(); + + public ChannelWriter Writer => _channel.Writer; + + public void Initialize() + { + _sawmill = _log.GetSawmill("github-ratelimit"); + _cfg.OnValueChanged(CCVars.GithubEnabled, val => Interlocked.Exchange(ref _enabled, val), true); + } + + public async Task HandleQueue() + { + var token = _cts.Token; + var reader = _channel.Reader; + while (!token.IsCancellationRequested) + { + await reader.WaitToReadAsync(token); + if (!reader.TryRead(out var request)) + continue; + + await SendRequest(request, token); + } + } + + // this should be called in BaseServer.Cleanup! + public void Shutdown() + { + _cts.Cancel(); + } + + /// + /// Directly send a request to the API. This does not have any rate limits checks so be careful! + /// Only use this if you have a very good reason to! + /// + /// The request to make. + /// Request cancellation token. + /// The direct HTTP response from the API. If null the request could not be made. + private async Task SendRequest(T request, CancellationToken ct) where T : IGithubRequest + { + if (!_enabled) + { + _sawmill.Info("Tried to make a github api request but the api was not enabled."); + return; + } + + try + { + await _client.TryMakeRequestSafe(request, ct); + } + catch (Exception e) + { + _sawmill.Error("Github API exception: {error}", e.ToString()); + } + } +} diff --git a/Content.Server/Github/GithubClient.cs b/Content.Server/Github/GithubClient.cs new file mode 100644 index 0000000000..ed7563dd3f --- /dev/null +++ b/Content.Server/Github/GithubClient.cs @@ -0,0 +1,417 @@ +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Content.Server.Github.Requests; +using Content.Server.Github.Responses; +using Content.Shared.CCVar; +using JetBrains.Annotations; +using Robust.Shared.Configuration; + +namespace Content.Server.Github; + +/// +/// Basic implementation of the GitHub api. This was mainly created for making issues from users bug reports - it is not +/// a full implementation! I tried to follow the spec very closely and the docs are really well done. I highly recommend +/// taking a look at them! +///
+///
Some useful information about the api: +///
Api home page +///
Best practices +///
Rate limit information +///
Troubleshooting +///
+/// As it uses async, it should be called from background worker when possible, like . +public sealed class GithubClient +{ + [Dependency] private readonly ILogManager _log = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + private HttpClient _httpClient = default!; + + private ISawmill _sawmill = default!; + + // Token data for the GitHub app (This is used to authenticate stuff like new issue creation) + private (DateTime? Expiery, string Token) _tokenData; + + // Json web token for the GitHub app (This is used to authenticate stuff like seeing where the app is installed) + // The token is created locally. + private (DateTime? Expiery, string JWT) _jwtData; + + private const int ErrorResponseMaxLogSize = 200; + + private readonly JsonSerializerOptions _jsonSerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + // Docs say 10 should be the maximum. + private readonly TimeSpan _jwtExpiration = TimeSpan.FromMinutes(10); + private readonly TimeSpan _jwtBackDate = TimeSpan.FromMinutes(1); + + // Buffers because requests can take a while. We don't want the tokens to expire in the middle of doing requests! + private readonly TimeSpan _jwtBuffer = TimeSpan.FromMinutes(2); + private readonly TimeSpan _tokenBuffer = TimeSpan.FromMinutes(2); + + private string _privateKey = ""; + + #region Header constants + + private const string ProductName = "SpaceStation14GithubApi"; + private const string ProductVersion = "1"; + + private const string AcceptHeader = "Accept"; + private const string AcceptHeaderType = "application/vnd.github+json"; + + private const string AuthHeader = "Authorization"; + private const string AuthHeaderBearer = "Bearer "; + + private const string VersionHeader = "X-GitHub-Api-Version"; + private const string VersionNumber = "2022-11-28"; + + #endregion + + private readonly Uri _baseUri = new("https://api.github.com/"); + + #region CCvar values + + private string _appId = ""; + private string _repository = ""; + private string _owner = ""; + private int _maxRetries; + + #endregion + + public void Initialize() + { + _sawmill = _log.GetSawmill("github"); + _tokenData = (null, ""); + _jwtData = (null, ""); + + _cfg.OnValueChanged(CCVars.GithubAppPrivateKeyPath, OnPrivateKeyPathChanged, true); + _cfg.OnValueChanged(CCVars.GithubAppId, val => Interlocked.Exchange(ref _appId, val), true); + _cfg.OnValueChanged(CCVars.GithubRepositoryName, val => Interlocked.Exchange(ref _repository, val), true); + _cfg.OnValueChanged(CCVars.GithubRepositoryOwner, val => Interlocked.Exchange(ref _owner, val), true); + _cfg.OnValueChanged(CCVars.GithubMaxRetries, val => SetValueAndInitHttpClient(ref _maxRetries, val), true); + } + + private void OnPrivateKeyPathChanged(string path) + { + if (string.IsNullOrEmpty(path)) + return; + + if (!File.Exists(path)) + { + _sawmill.Error($"\"{path}\" does not exist."); + return; + } + + string fileText; + try + { + fileText = File.ReadAllText(path); + } + catch (Exception e) + { + _sawmill.Error($"\"{path}\" could not be read!\n{e}"); + return; + } + + var rsa = RSA.Create(); + try + { + rsa.ImportFromPem(fileText); + } + catch + { + _sawmill.Error($"\"{path}\" does not contain a valid private key!"); + return; + } + + _privateKey = fileText; + } + + private void SetValueAndInitHttpClient(ref T toSet, T value) + { + Interlocked.Exchange(ref toSet, value); + + var httpMessageHandler = new RetryHandler(new HttpClientHandler(), _maxRetries, _sawmill); + var newClient = new HttpClient(httpMessageHandler) + { + BaseAddress = _baseUri, + DefaultRequestHeaders = + { + { AcceptHeader, AcceptHeaderType }, + { VersionHeader, VersionNumber }, + }, + Timeout = TimeSpan.FromSeconds(15), + }; + + newClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(ProductName, ProductVersion)); + + Interlocked.Exchange(ref _httpClient, newClient); + } + + #region Public functions + + /// + /// The standard way to make requests to the GitHub api. This will ensure that the request respects the rate limit + /// and will also retry the request if it fails. Awaiting this to finish could take a very long time depending + /// on what exactly is going on! Only await for it if you're willing to wait a long time. + /// + /// The request you want to make. + /// Token for operation cancellation. + /// The direct HTTP response from the API. If null the request could not be made. + public async Task TryMakeRequestSafe(IGithubRequest request, CancellationToken ct) + { + if (!HaveFullApiData()) + { + _sawmill.Info("Tried to make a github api request but the api was not enabled."); + return null; + } + + if (request.AuthenticationMethod == GithubAuthMethod.Token && !await TryEnsureTokenNotExpired(ct)) + return null; + + return await MakeRequest(request, ct); + } + + private async Task MakeRequest(IGithubRequest request, CancellationToken ct) + { + var httpRequestMessage = BuildRequest(request); + + var response = await _httpClient.SendAsync(httpRequestMessage, ct); + + var message = $"Made a github api request to: '{httpRequestMessage.RequestUri}', status is {response.StatusCode}"; + if (response.IsSuccessStatusCode) + { + _sawmill.Info(message); + return response; + } + + _sawmill.Error(message); + var responseText = await response.Content.ReadAsStringAsync(ct); + + if (responseText.Length > ErrorResponseMaxLogSize) + responseText = responseText.Substring(0, ErrorResponseMaxLogSize); + + _sawmill.Error(message + "\r\n" + responseText); + + return null; + } + + /// + /// A simple helper function that just tries to parse a header value that is expected to be a long int. + /// In general, there are just a lot of single value headers that are longs so this removes a lot of duplicate code. + /// + /// The headers that you want to search. + /// The header you want to get the long value for. + /// Value of header, if found, null otherwise. + /// The headers value if it exists, null otherwise. + public static bool TryGetHeaderAsLong(HttpResponseHeaders? headers, string header, [NotNullWhen(true)] out long? value) + { + value = null; + if (headers == null) + return false; + + if (!headers.TryGetValues(header, out var headerValues)) + return false; + + if (!long.TryParse(headerValues.First(), out var result)) + return false; + + value = result; + return true; + } + + # endregion + + #region Helper functions + + private HttpRequestMessage BuildRequest(IGithubRequest request) + { + var json = JsonSerializer.Serialize(request, _jsonSerializerOptions); + var payload = new StringContent(json, Encoding.UTF8, "application/json"); + + var builder = new UriBuilder(_baseUri) + { + Port = -1, + Path = request.GetLocation(_owner, _repository), + }; + + var httpRequest = new HttpRequestMessage + { + Method = request.RequestMethod, + RequestUri = builder.Uri, + Content = payload, + }; + + httpRequest.Headers.Add(AuthHeader, CreateAuthenticationHeader(request)); + + return httpRequest; + } + + private bool HaveFullApiData() + { + return !string.IsNullOrWhiteSpace(_privateKey) && + !string.IsNullOrWhiteSpace(_repository) && + !string.IsNullOrWhiteSpace(_owner); + } + + private string CreateAuthenticationHeader(IGithubRequest request) + { + return request.AuthenticationMethod switch + { + GithubAuthMethod.Token => AuthHeaderBearer + _tokenData.Token, + GithubAuthMethod.JWT => AuthHeaderBearer + GetValidJwt(), + _ => throw new Exception("Unknown auth method!"), + }; + } + + // TODO: Maybe ensure that perms are only read metadata / write issues so people don't give full access + /// + /// Try to get a valid verification token from the GitHub api + /// + /// True if the token is valid and successfully found, false if there was an error. + private async Task TryEnsureTokenNotExpired(CancellationToken ct) + { + if (_tokenData.Expiery != null && _tokenData.Expiery - _tokenBuffer > DateTime.UtcNow) + return true; + + _sawmill.Info("Token expired - requesting new token!"); + + var installationRequest = new InstallationsRequest(); + var installationHttpResponse = await MakeRequest(installationRequest, ct); + if (installationHttpResponse == null) + { + _sawmill.Error("Could not make http installation request when creating token."); + return false; + } + + var installationResponse = await installationHttpResponse.Content.ReadFromJsonAsync>(_jsonSerializerOptions, ct); + if (installationResponse == null) + { + _sawmill.Error("Could not parse installation response."); + return false; + } + + if (installationResponse.Count == 0) + { + _sawmill.Error("App not installed anywhere."); + return false; + } + + int? installationId = null; + foreach (var installation in installationResponse) + { + if (installation.Account.Login != _owner) + continue; + + installationId = installation.Id; + break; + } + + if (installationId == null) + { + _sawmill.Error("App not installed in given repository."); + return false; + } + + var tokenRequest = new TokenRequest + { + InstallationId = installationId.Value, + }; + + var tokenHttpResponse = await MakeRequest(tokenRequest, ct); + if (tokenHttpResponse == null) + { + _sawmill.Error("Could not make http token request when creating token.."); + return false; + } + + var tokenResponse = await tokenHttpResponse.Content.ReadFromJsonAsync(_jsonSerializerOptions, ct); + if (tokenResponse == null) + { + _sawmill.Error("Could not parse token response."); + return false; + } + + _tokenData = (tokenResponse.ExpiresAt, tokenResponse.Token); + return true; + } + + // See: https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app + private string GetValidJwt() + { + if (_jwtData.Expiery != null && _jwtData.Expiery - _jwtBuffer > DateTime.UtcNow) + return _jwtData.JWT; + + var githubClientId = _appId; + var apiPrivateKey = _privateKey; + + var time = DateTime.UtcNow; + var expTime = time + _jwtExpiration; + var iatTime = time - _jwtBackDate; + + var iat = ((DateTimeOffset) iatTime).ToUnixTimeSeconds(); + var exp = ((DateTimeOffset) expTime).ToUnixTimeSeconds(); + + const string headerJson = """ + { + "typ":"JWT", + "alg":"RS256" + } + """; + + var headerEncoded = Base64EncodeUrlSafe(headerJson); + + var payloadJson = $$""" + { + "iat":{{iat}}, + "exp":{{exp}}, + "iss":"{{githubClientId}}" + } + """; + + var payloadJsonEncoded = Base64EncodeUrlSafe(payloadJson); + + var headPayload = $"{headerEncoded}.{payloadJsonEncoded}"; + + var rsa = System.Security.Cryptography.RSA.Create(); + rsa.ImportFromPem(apiPrivateKey); + + var bytesPlainTextData = Encoding.UTF8.GetBytes(headPayload); + + var signedData = rsa.SignData(bytesPlainTextData, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var signBase64 = Base64EncodeUrlSafe(signedData); + + var jwt = $"{headPayload}.{signBase64}"; + + _jwtData = (expTime, jwt); + + _sawmill.Info("Generated new JWT."); + + return jwt; + } + + private string Base64EncodeUrlSafe(string plainText) + { + return Base64EncodeUrlSafe(Encoding.UTF8.GetBytes(plainText)); + } + + private string Base64EncodeUrlSafe(byte[] plainText) + { + return Convert.ToBase64String(plainText) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + #endregion +} diff --git a/Content.Server/Github/Requests/CreateIssueRequest.cs b/Content.Server/Github/Requests/CreateIssueRequest.cs new file mode 100644 index 0000000000..92cee609fa --- /dev/null +++ b/Content.Server/Github/Requests/CreateIssueRequest.cs @@ -0,0 +1,38 @@ +using System.Net.Http; +using System.Text.Json.Serialization; + +namespace Content.Server.Github.Requests; + +/// +/// > +/// +public sealed class CreateIssueRequest : IGithubRequest +{ + [JsonIgnore] + public HttpMethod RequestMethod => HttpMethod.Post; + + [JsonIgnore] + public GithubAuthMethod AuthenticationMethod => GithubAuthMethod.Token; + + #region JSON fields + + [JsonInclude, JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public required string Title; + [JsonInclude, JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Body; + [JsonInclude, JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Assignee; + [JsonInclude, JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Milestone; + [JsonInclude, JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List Labels = []; + [JsonInclude, JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List Assignees = []; + + #endregion + + public string GetLocation(string owner, string repository) + { + return $"repos/{owner}/{repository}/issues"; + } +} diff --git a/Content.Server/Github/Requests/IGithubRequest.cs b/Content.Server/Github/Requests/IGithubRequest.cs new file mode 100644 index 0000000000..afc421722d --- /dev/null +++ b/Content.Server/Github/Requests/IGithubRequest.cs @@ -0,0 +1,44 @@ +using System.Net.Http; +using System.Text.Json.Serialization; + +namespace Content.Server.Github.Requests; + +/// +/// Interface for all github api requests. +/// +/// +/// WARNING: You must add this JsonDerivedType for all requests that have json otherwise they will not parse properly! +/// +[JsonPolymorphic(UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)] +[JsonDerivedType(typeof(CreateIssueRequest))] +[JsonDerivedType(typeof(InstallationsRequest))] +[JsonDerivedType(typeof(TokenRequest))] +public interface IGithubRequest +{ + /// + /// The kind of request method for the request. + /// + [JsonIgnore] + public HttpMethod RequestMethod { get; } + + /// + /// There are different types of authentication methods depending on which endpoint you are working with. + /// E.g. the app api endpoint mostly uses JWTs, while stuff like issue creation uses Tokens + /// + [JsonIgnore] + public GithubAuthMethod AuthenticationMethod { get; } + + /// + /// Location of the api endpoint for this request. + /// + /// Owner of the repository. + /// The repository to make the request. + /// The api location for this request. + public string GetLocation(string owner, string repository); +} + +public enum GithubAuthMethod +{ + JWT, + Token, +} diff --git a/Content.Server/Github/Requests/InstallationsRequest.cs b/Content.Server/Github/Requests/InstallationsRequest.cs new file mode 100644 index 0000000000..4e75bbdc38 --- /dev/null +++ b/Content.Server/Github/Requests/InstallationsRequest.cs @@ -0,0 +1,18 @@ +using System.Net.Http; + +namespace Content.Server.Github.Requests; + +/// +/// > +/// +public sealed class InstallationsRequest : IGithubRequest +{ + public HttpMethod RequestMethod => HttpMethod.Get; + + public GithubAuthMethod AuthenticationMethod => GithubAuthMethod.JWT; + + public string GetLocation(string owner, string repository) + { + return "app/installations"; + } +} diff --git a/Content.Server/Github/Requests/TokenRequest.cs b/Content.Server/Github/Requests/TokenRequest.cs new file mode 100644 index 0000000000..f07764cdf0 --- /dev/null +++ b/Content.Server/Github/Requests/TokenRequest.cs @@ -0,0 +1,22 @@ +using System.Net.Http; +using System.Text.Json.Serialization; + +namespace Content.Server.Github.Requests; + +/// +/// > +/// +public sealed class TokenRequest : IGithubRequest +{ + public HttpMethod RequestMethod => HttpMethod.Post; + + public GithubAuthMethod AuthenticationMethod => GithubAuthMethod.JWT; + + [JsonPropertyName("id")] + public required int InstallationId; + + public string GetLocation(string owner, string repository) + { + return $"/app/installations/{InstallationId}/access_tokens"; + } +} diff --git a/Content.Server/Github/Responses/InstallationResponse.cs b/Content.Server/Github/Responses/InstallationResponse.cs new file mode 100644 index 0000000000..ffc84a6f0c --- /dev/null +++ b/Content.Server/Github/Responses/InstallationResponse.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Content.Server.Github.Responses; + +/// +/// Not all fields are filled out - only the necessary ones. If you need more just add them. +/// > +/// +public sealed class InstallationResponse +{ + public required int Id { get; set; } + + public required GithubInstallationAccount Account { get; set; } +} + +/// +public sealed class GithubInstallationAccount +{ + public required string Login { get; set; } +} + diff --git a/Content.Server/Github/Responses/TokenResponse.cs b/Content.Server/Github/Responses/TokenResponse.cs new file mode 100644 index 0000000000..5b3748219c --- /dev/null +++ b/Content.Server/Github/Responses/TokenResponse.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Content.Server.Github.Responses; + +/// +/// Not all fields are filled out - only the necessary ones. If you need more just add them. +/// > +/// +public sealed class TokenResponse +{ + public required string Token { get; set; } + + [JsonPropertyName("expires_at")] + public required DateTime ExpiresAt { get; set; } +} diff --git a/Content.Server/Github/RetryHttpHandler.cs b/Content.Server/Github/RetryHttpHandler.cs new file mode 100644 index 0000000000..28dcff643b --- /dev/null +++ b/Content.Server/Github/RetryHttpHandler.cs @@ -0,0 +1,100 @@ +using System.Net.Http; +using System.Threading.Tasks; +using System.Threading; +using System.Net; + +namespace Content.Server.Github; + +/// +/// Basic rate limiter for the GitHub api! Will ensure there is only ever one outgoing request at a time and all +/// requests respect the rate limit the best they can. +///
+///
Links to the api for more information: +///
Best practices +///
Rate limit information +///
+/// This was designed for the 2022-11-28 version of the API. +public sealed class RetryHandler(HttpMessageHandler innerHandler, int maxRetries, ISawmill sawmill) : DelegatingHandler(innerHandler) +{ + private const int MaxWaitSeconds = 32; + + /// Extra buffer time (In seconds) after getting rate limited we don't make the request exactly when we get more credits. + private const long ExtraBufferTime = 1L; + + #region Headers + + private const string RetryAfterHeader = "retry-after"; + + private const string RemainingHeader = "x-ratelimit-remaining"; + private const string RateLimitResetHeader = "x-ratelimit-reset"; + + #endregion + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + HttpResponseMessage response; + var i = 0; + do + { + response = await base.SendAsync(request, cancellationToken); + if (response.IsSuccessStatusCode) + return response; + + i++; + if (i < maxRetries) + { + var waitTime = CalculateNextRequestTime(response, i); + await Task.Delay(waitTime, cancellationToken); + } + } while (!response.IsSuccessStatusCode && i < maxRetries); + + return response; + } + + /// + /// Follows these guidelines but also has a small buffer so you should never quite hit zero: + ///
+ /// + ///
+ /// The last response from the API. + /// Number of current call attempt. + /// The amount of time to wait until the next request. + private TimeSpan CalculateNextRequestTime(HttpResponseMessage response, int attempt) + { + var headers = response.Headers; + var statusCode = response.StatusCode; + + // Specific checks for rate limits. + if (statusCode is HttpStatusCode.Forbidden or HttpStatusCode.TooManyRequests) + { + // Retry after header + if (GithubClient.TryGetHeaderAsLong(headers, RetryAfterHeader, out var retryAfterSeconds)) + return TimeSpan.FromSeconds(retryAfterSeconds.Value + ExtraBufferTime); + + // Reset header (Tells us when we get more api credits) + if (GithubClient.TryGetHeaderAsLong(headers, RemainingHeader, out var remainingRequests) + && GithubClient.TryGetHeaderAsLong(headers, RateLimitResetHeader, out var resetTime) + && remainingRequests == 0) + { + var delayTime = resetTime.Value - DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + sawmill.Warning( + "github returned '{status}' status, have to wait until limit reset - in '{delay}' seconds", + response.StatusCode, + delayTime + ); + return TimeSpan.FromSeconds(delayTime + ExtraBufferTime); + } + } + + // If the status code is not the expected one or the rate limit checks are failing, just do an exponential backoff. + return ExponentialBackoff(attempt); + } + + private static TimeSpan ExponentialBackoff(int i) + { + return TimeSpan.FromSeconds(Math.Min(MaxWaitSeconds, Math.Pow(2, i))); + } +} diff --git a/Content.Server/IoC/ServerContentIoC.cs b/Content.Server/IoC/ServerContentIoC.cs index 4f51d3e345..b8ab53f81d 100644 --- a/Content.Server/IoC/ServerContentIoC.cs +++ b/Content.Server/IoC/ServerContentIoC.cs @@ -8,6 +8,7 @@ using Content.Server.Administration.Logs; using Content.Server.Administration.Managers; using Content.Server.Administration.Notes; using Content.Server.Afk; +using Content.Server.BugReports; using Content.Server.Chat.Managers; using Content.Server.Connection; using Content.Server.Database; @@ -16,6 +17,7 @@ using Content.Server.Discord.DiscordLink; using Content.Server.Discord.WebhookMessages; using Content.Server.EUI; using Content.Server.GhostKick; +using Content.Server.Github; using Content.Server.Info; using Content.Server.Mapping; using Content.Server.Maps; @@ -64,6 +66,7 @@ namespace Content.Server.IoC IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); // Sunrise-TTS @@ -82,9 +85,11 @@ namespace Content.Server.IoC IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); - IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); + IoCManager.Register(); + IoCManager.Register(); // Sunrise-Start IoCManager.Register(); diff --git a/Content.Shared.Database/LogType.cs b/Content.Shared.Database/LogType.cs index 0cd33aa41a..55e8d37130 100644 --- a/Content.Shared.Database/LogType.cs +++ b/Content.Shared.Database/LogType.cs @@ -464,6 +464,7 @@ public enum LogType /// Logs related to botany, such as planting and harvesting crops /// Botany = 100, + /// /// Artifact node got activated. /// @@ -479,5 +480,10 @@ public enum LogType /// Instrument = 103, - Interactions = 104, + /// + /// For anything relating to bug reports. + /// + BugReport = 104, + + Interactions = 105, } diff --git a/Content.Shared/Buckle/SharedBuckleSystem.Buckle.cs b/Content.Shared/Buckle/SharedBuckleSystem.Buckle.cs index 55ec505213..59760c3fd3 100644 --- a/Content.Shared/Buckle/SharedBuckleSystem.Buckle.cs +++ b/Content.Shared/Buckle/SharedBuckleSystem.Buckle.cs @@ -2,7 +2,6 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Numerics; using Content.Shared.Alert; -using Content.Shared.ActionBlocker; using Content.Shared.Buckle.Components; using Content.Shared.Cuffs.Components; using Content.Shared.Database; @@ -13,14 +12,12 @@ using Content.Shared.Movement.Events; using Content.Shared.Movement.Pulling.Events; using Content.Shared.Popups; using Content.Shared.Pulling.Events; -using Content.Shared.Rotation; using Content.Shared.Standing; using Content.Shared.Storage.Components; using Content.Shared.Stunnable; using Content.Shared.Throwing; using Content.Shared.Whitelist; using Robust.Shared.Containers; -using Robust.Shared.GameStates; using Robust.Shared.Map; using Robust.Shared.Physics.Components; using Robust.Shared.Physics.Events; @@ -492,9 +489,9 @@ public abstract partial class SharedBuckleSystem private void Unbuckle(Entity buckle, Entity strap, EntityUid? user) { if (user == buckle.Owner) - _adminLogger.Add(LogType.Action, LogImpact.Low, $"{user} unbuckled themselves from {strap}"); + _adminLogger.Add(LogType.Action, LogImpact.Low, $"{ToPrettyString(user):user} unbuckled themselves from {ToPrettyString(strap):strap}"); else if (user != null) - _adminLogger.Add(LogType.Action, LogImpact.Low, $"{user} unbuckled {buckle} from {strap}"); + _adminLogger.Add(LogType.Action, LogImpact.Low, $"{ToPrettyString(user):user} unbuckled {ToPrettyString(buckle):target} from {ToPrettyString(strap):strap}"); _audio.PlayPredicted(strap.Comp.UnbuckleSound, strap, user); diff --git a/Content.Shared/BugReport/BugReportMessage.cs b/Content.Shared/BugReport/BugReportMessage.cs new file mode 100644 index 0000000000..46976eda6e --- /dev/null +++ b/Content.Shared/BugReport/BugReportMessage.cs @@ -0,0 +1,42 @@ +using Lidgren.Network; +using Robust.Shared.Network; +using Robust.Shared.Serialization; + +namespace Content.Shared.BugReport; + +/// +/// Message with bug report data, which should be handled by server and used to create issue on issue tracker +/// (or some other notification). +/// +public sealed class BugReportMessage : NetMessage +{ + public override MsgGroups MsgGroup => MsgGroups.Command; + + public PlayerBugReportInformation ReportInformation = new(); + + public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer) + { + ReportInformation.BugReportTitle = buffer.ReadString(); + ReportInformation.BugReportDescription = buffer.ReadString(); + } + + public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer serializer) + { + buffer.Write(ReportInformation.BugReportTitle); + buffer.Write(ReportInformation.BugReportDescription); + } + + public override NetDeliveryMethod DeliveryMethod => NetDeliveryMethod.ReliableUnordered; +} + +/// +/// Stores user specified information from a bug report. +/// +/// +/// Clients can put whatever they want here so be careful! +/// +public sealed class PlayerBugReportInformation +{ + public string BugReportTitle = string.Empty; + public string BugReportDescription = string.Empty; +} diff --git a/Content.Shared/CCVar/CCVars.BugReports.cs b/Content.Shared/CCVar/CCVars.BugReports.cs new file mode 100644 index 0000000000..789ffc9a48 --- /dev/null +++ b/Content.Shared/CCVar/CCVars.BugReports.cs @@ -0,0 +1,64 @@ +using Robust.Shared.Configuration; + +namespace Content.Shared.CCVar; + +public sealed partial class CCVars +{ + /// + /// Allow users to submit bug reports. Will enable a button on the hotbar. See for + /// setting up the GitHub API! + /// + public static readonly CVarDef EnablePlayerBugReports = + CVarDef.Create("bug_reports.enable_player_bug_reports", false, CVar.SERVER | CVar.REPLICATED); + + /// + /// Minimum playtime that players need to have played to submit bug reports. + /// + public static readonly CVarDef MinimumPlaytimeInMinutesToEnableBugReports = + CVarDef.Create("bug_reports.minimum_playtime_in_minutes_to_enable_bug_reports", 120, CVar.SERVER | CVar.REPLICATED); + + /// + /// Maximum number of bug reports a user can submit per round. + /// + public static readonly CVarDef MaximumBugReportsPerRound = + CVarDef.Create("bug_reports.maximum_bug_reports_per_round", 5, CVar.SERVER | CVar.REPLICATED); + + /// + /// Minimum time between bug reports. + /// + public static readonly CVarDef MinimumSecondsBetweenBugReports = + CVarDef.Create("bug_reports.minimum_seconds_between_bug_reports", 120, CVar.SERVER | CVar.REPLICATED); + + /// + /// Maximum length of a bug report title. + /// + public static readonly CVarDef MaximumBugReportTitleLength = + CVarDef.Create("bug_reports.maximum_bug_report_title_length", 35, CVar.SERVER | CVar.REPLICATED); + + /// + /// Minimum length of a bug report title. + /// + public static readonly CVarDef MinimumBugReportTitleLength = + CVarDef.Create("bug_reports.minimum_bug_report_title_length", 10, CVar.SERVER | CVar.REPLICATED); + + /// + /// Maximum length of a bug report description. + /// + public static readonly CVarDef MaximumBugReportDescriptionLength = + CVarDef.Create("bug_reports.maximum_bug_report_description_length", 750, CVar.SERVER | CVar.REPLICATED); + + /// + /// Minimum length of a bug report description. + /// + public static readonly CVarDef MinimumBugReportDescriptionLength = + CVarDef.Create("bug_reports.minimum_bug_report_description_length", 10, CVar.SERVER | CVar.REPLICATED); + + /// + /// List of tags that are added to the report. Separate each value with ",". + /// + /// + /// IG report, Bug + /// + public static readonly CVarDef BugReportTags = + CVarDef.Create("bug_reports.tags", "IG bug report", CVar.SERVER | CVar.REPLICATED); +} diff --git a/Content.Shared/CCVar/CCVars.Github.cs b/Content.Shared/CCVar/CCVars.Github.cs new file mode 100644 index 0000000000..77c1ffc2fe --- /dev/null +++ b/Content.Shared/CCVar/CCVars.Github.cs @@ -0,0 +1,71 @@ +using Robust.Shared.Configuration; + +namespace Content.Shared.CCVar; + +public sealed partial class CCVars +{ + /// + /// Marker, for if the GitHub api is enabled. If it is not enabled, any actions that require GitHub API will be ignored. + /// To fully set up the API, you also need to set , , + /// and . + /// + public static readonly CVarDef GithubEnabled = + CVarDef.Create("github.github_enabled", true, CVar.SERVERONLY); + + /// + /// GitHub app private keys location. PLEASE READ THIS CAREFULLY!! + /// + /// + /// Its highly recommend to create a new (private) repository specifically for this app. This will help avoid + /// moderation issues and also allow you to ignore duplicate or useless issues. You can just transfer legitimate + /// issues from the private repository to the main public one. + /// + /// + /// Only create the auth token with the MINIMUM required access (Specifically only give it access to one + /// repository - and the minimum required access for your use case). + ///

If this token is only for forwarding issues then you should only need to grant read and write + /// permission to "Issues" and read only permissions to "Metadata". + ///
+ ///
+ /// Also remember to use the testgithubapi command to test if you set everything up correctly. + /// [Insert YouTube video link with walkthrough here] + ///
+ /// + /// (If your on linux): /home/beck/key.pem + /// + public static readonly CVarDef GithubAppPrivateKeyPath = + CVarDef.Create("github.github_app_private_key_path", "", CVar.SERVERONLY | CVar.CONFIDENTIAL); + + /// + /// The GitHub apps app id. Go to https://github.com/settings/apps/APPNAME to find the app id. + /// + /// + /// 1009555 + /// + public static readonly CVarDef GithubAppId = + CVarDef.Create("github.github_app_id", "", CVar.SERVERONLY | CVar.CONFIDENTIAL); + + /// + /// Name of the targeted GitHub repository. + /// + /// + /// If your URL was https://github.com/space-wizards/space-station-14 the repo name would be "space-station-14". + /// > + public static readonly CVarDef GithubRepositoryName = + CVarDef.Create("github.github_repository_name", string.Empty, CVar.SERVERONLY | CVar.CONFIDENTIAL); + + /// + /// Owner of the GitHub repository. + /// + /// + /// If your URL was https://github.com/space-wizards/space-station-14 the owner would be "space-wizards". + /// + public static readonly CVarDef GithubRepositoryOwner = + CVarDef.Create("github.github_repository_owner", string.Empty, CVar.SERVERONLY | CVar.CONFIDENTIAL); + + /// + /// The maximum number of times the api will retry requests before giving up. + /// + public static readonly CVarDef GithubMaxRetries = + CVarDef.Create("github.github_max_retries", 3, CVar.SERVERONLY | CVar.CONFIDENTIAL); +} diff --git a/Content.Shared/EntityTable/Conditions/HasBudgetCondition.cs b/Content.Shared/EntityTable/Conditions/HasBudgetCondition.cs new file mode 100644 index 0000000000..f2489d04aa --- /dev/null +++ b/Content.Shared/EntityTable/Conditions/HasBudgetCondition.cs @@ -0,0 +1,51 @@ +using Content.Shared.EntityTable.EntitySelectors; +using Content.Shared.GameTicking.Rules; +using Robust.Shared.Prototypes; + +namespace Content.Shared.EntityTable.Conditions; + +/// +/// Condition that only succeeds if a table supplies a sufficient "cost" to a given +/// +public sealed partial class HasBudgetCondition : EntityTableCondition +{ + public const string BudgetContextKey = "Budget"; + + /// + /// Used for determining the cost for the budget. + /// If null, attempts to fetch the cost from the attached selector. + /// + [DataField] + public int? CostOverride; + + protected override bool EvaluateImplementation(EntityTableSelector root, + IEntityManager entMan, + IPrototypeManager proto, + EntityTableContext ctx) + { + if (!ctx.TryGetData(BudgetContextKey, out var budget)) + return false; + + int cost; + if (CostOverride != null) + { + cost = CostOverride.Value; + } + else + { + if (root is not EntSelector entSelector) + return false; + + if (!proto.Index(entSelector.Id).TryGetComponent(out DynamicRuleCostComponent? costComponent, entMan.ComponentFactory)) + { + var log = Logger.GetSawmill("HasBudgetCondition"); + log.Error($"Rule {entSelector.Id} does not have a DynamicRuleCostComponent."); + return false; + } + + cost = costComponent.Cost; + } + + return budget >= cost; + } +} diff --git a/Content.Shared/EntityTable/Conditions/MaxRuleOccurenceCondition.cs b/Content.Shared/EntityTable/Conditions/MaxRuleOccurenceCondition.cs new file mode 100644 index 0000000000..1e55feb338 --- /dev/null +++ b/Content.Shared/EntityTable/Conditions/MaxRuleOccurenceCondition.cs @@ -0,0 +1,54 @@ +using System.Linq; +using Content.Shared.EntityTable.EntitySelectors; +using Content.Shared.GameTicking; +using Robust.Shared.Prototypes; + +namespace Content.Shared.EntityTable.Conditions; + +/// +/// Condition that succeeds only when the specified gamerule has been run under a certain amount of times +/// +/// +/// This is meant to be attached directly to EntSelector. If it is not, then you'll need to specify what rule +/// is being used inside RuleOverride. +/// +public sealed partial class MaxRuleOccurenceCondition : EntityTableCondition +{ + /// + /// The maximum amount of times this rule can have already be run. + /// + [DataField] + public int Max = 1; + + /// + /// The rule that is being checked for occurrences. + /// If null, it will use the value on the attached selector. + /// + [DataField] + public EntProtoId? RuleOverride; + + protected override bool EvaluateImplementation(EntityTableSelector root, + IEntityManager entMan, + IPrototypeManager proto, + EntityTableContext ctx) + { + string rule; + if (RuleOverride is { } ruleOverride) + { + rule = ruleOverride; + } + else + { + rule = root is EntSelector entSelector + ? entSelector.Id + : string.Empty; + } + + if (rule == string.Empty) + return false; + + var gameTicker = entMan.System(); + + return gameTicker.AllPreviousGameRules.Count(p => p.Item2 == rule) < Max; + } +} diff --git a/Content.Shared/EntityTable/Conditions/ReoccurrenceDelayCondition.cs b/Content.Shared/EntityTable/Conditions/ReoccurrenceDelayCondition.cs new file mode 100644 index 0000000000..0329592a4a --- /dev/null +++ b/Content.Shared/EntityTable/Conditions/ReoccurrenceDelayCondition.cs @@ -0,0 +1,49 @@ +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Content.Shared.EntityTable.EntitySelectors; +using Content.Shared.GameTicking; +using Robust.Shared.Prototypes; + +namespace Content.Shared.EntityTable.Conditions; + +public sealed partial class ReoccurrenceDelayCondition : EntityTableCondition +{ + /// + /// The maximum amount of times this rule can have already be run. + /// + [DataField] + public TimeSpan Delay = TimeSpan.Zero; + + /// + /// The rule that is being checked for occurrences. + /// If null, it will use the value on the attached selector. + /// + [DataField] + public EntProtoId? RuleOverride; + + protected override bool EvaluateImplementation(EntityTableSelector root, + IEntityManager entMan, + IPrototypeManager proto, + EntityTableContext ctx) + { + string rule; + if (RuleOverride is { } ruleOverride) + { + rule = ruleOverride; + } + else + { + rule = root is EntSelector entSelector + ? entSelector.Id + : string.Empty; + } + + if (rule == string.Empty) + return false; + + var gameTicker = entMan.System(); + + return gameTicker.AllPreviousGameRules.Any( + p => p.Item2 == rule && p.Item1 + Delay <= gameTicker.RoundDuration()); + } +} diff --git a/Content.Shared/EntityTable/Conditions/RoundDurationCondition.cs b/Content.Shared/EntityTable/Conditions/RoundDurationCondition.cs new file mode 100644 index 0000000000..518faf4bc6 --- /dev/null +++ b/Content.Shared/EntityTable/Conditions/RoundDurationCondition.cs @@ -0,0 +1,34 @@ +using Content.Shared.EntityTable.EntitySelectors; +using Content.Shared.GameTicking; +using Robust.Shared.Prototypes; + +namespace Content.Shared.EntityTable.Conditions; + +/// +/// Condition that passes only if the current round time falls between the minimum and maximum time values. +/// +public sealed partial class RoundDurationCondition : EntityTableCondition +{ + /// + /// Minimum time the round must have gone on for this condition to pass. + /// + [DataField] + public TimeSpan Min = TimeSpan.Zero; + + /// + /// Maximum amount of time the round could go on for this condition to pass. + /// + [DataField] + public TimeSpan Max = TimeSpan.MaxValue; + + protected override bool EvaluateImplementation(EntityTableSelector root, + IEntityManager entMan, + IPrototypeManager proto, + EntityTableContext ctx) + { + var gameTicker = entMan.System(); + var duration = gameTicker.RoundDuration(); + + return duration >= Min && duration <= Max; + } +} diff --git a/Content.Shared/EntityTable/EntitySelectors/GroupSelector.cs b/Content.Shared/EntityTable/EntitySelectors/GroupSelector.cs index 25c81a4565..0d2a451bdc 100644 --- a/Content.Shared/EntityTable/EntitySelectors/GroupSelector.cs +++ b/Content.Shared/EntityTable/EntitySelectors/GroupSelector.cs @@ -26,6 +26,9 @@ public sealed partial class GroupSelector : EntityTableSelector children.Add(child, child.Weight); } + if (children.Count == 0) + return Array.Empty(); + var pick = SharedRandomExtensions.Pick(children, rand); return pick.GetSpawns(rand, entMan, proto, ctx); diff --git a/Content.Shared/Flash/SharedFlashSystem.cs b/Content.Shared/Flash/SharedFlashSystem.cs index dd6c9c91c1..7f69e86042 100644 --- a/Content.Shared/Flash/SharedFlashSystem.cs +++ b/Content.Shared/Flash/SharedFlashSystem.cs @@ -21,6 +21,7 @@ using Robust.Shared.Random; using Robust.Shared.Timing; using System.Linq; using Content.Shared.Movement.Systems; +using Content.Shared.Random.Helpers; namespace Content.Shared.Flash; @@ -204,7 +205,8 @@ public abstract class SharedFlashSystem : EntitySystem foreach (var entity in _entSet) { // TODO: Use RandomPredicted https://github.com/space-wizards/RobustToolbox/pull/5849 - var rand = new System.Random((int)_timing.CurTick.Value + GetNetEntity(entity).Id); + var seed = SharedRandomExtensions.HashCodeCombine(new() { (int)_timing.CurTick.Value, GetNetEntity(entity).Id }); + var rand = new System.Random(seed); if (!rand.Prob(probability)) continue; diff --git a/Content.Shared/GameTicking/Rules/DynamicRuleComponent.cs b/Content.Shared/GameTicking/Rules/DynamicRuleComponent.cs new file mode 100644 index 0000000000..7782717758 --- /dev/null +++ b/Content.Shared/GameTicking/Rules/DynamicRuleComponent.cs @@ -0,0 +1,71 @@ +using Content.Shared.EntityTable.EntitySelectors; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; + +namespace Content.Shared.GameTicking.Rules; + +/// +/// Gamerule the spawns multiple antags at intervals based on a budget +/// +[RegisterComponent, AutoGenerateComponentPause] +public sealed partial class DynamicRuleComponent : Component +{ + /// + /// The total budget for antags. + /// + [DataField] + public float Budget; + + /// + /// The last time budget was updated. + /// + [DataField] + public TimeSpan LastBudgetUpdate; + + /// + /// The amount of budget accumulated every second. + /// + [DataField] + public float BudgetPerSecond = 0.1f; + + /// + /// The minimum or lower bound for budgets to start at. + /// + [DataField] + public int StartingBudgetMin = 200; + + /// + /// The maximum or upper bound for budgets to start at. + /// + [DataField] + public int StartingBudgetMax = 350; + + /// + /// The time at which the next rule will start + /// + [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoPausedField] + public TimeSpan NextRuleTime; + + /// + /// Minimum delay between rules + /// + [DataField] + public TimeSpan MinRuleInterval = TimeSpan.FromMinutes(10); + + /// + /// Maximum delay between rules + /// + [DataField] + public TimeSpan MaxRuleInterval = TimeSpan.FromMinutes(30); + + /// + /// A table of rules that are picked from. + /// + [DataField] + public EntityTableSelector Table = new NoneSelector(); + + /// + /// The rules that have been spawned + /// + [DataField] + public List Rules = new(); +} diff --git a/Content.Shared/GameTicking/Rules/DynamicRuleCostComponent.cs b/Content.Shared/GameTicking/Rules/DynamicRuleCostComponent.cs new file mode 100644 index 0000000000..180b168dc1 --- /dev/null +++ b/Content.Shared/GameTicking/Rules/DynamicRuleCostComponent.cs @@ -0,0 +1,14 @@ +namespace Content.Shared.GameTicking.Rules; + +/// +/// Component that tracks how much a rule "costs" for Dynamic +/// +[RegisterComponent] +public sealed partial class DynamicRuleCostComponent : Component +{ + /// + /// The amount of budget a rule takes up + /// + [DataField(required: true)] + public int Cost; +} diff --git a/Content.Shared/GameTicking/SharedGameTicker.cs b/Content.Shared/GameTicking/SharedGameTicker.cs index 293ee54d1f..c9390f881d 100644 --- a/Content.Shared/GameTicking/SharedGameTicker.cs +++ b/Content.Shared/GameTicking/SharedGameTicker.cs @@ -18,6 +18,12 @@ namespace Content.Shared.GameTicking [Dependency] private readonly IReplayRecordingManager _replay = default!; [Dependency] private readonly IGameTiming _gameTiming = default!; + /// + /// A list storing the start times of all game rules that have been started this round. + /// Game rules can be started and stopped at any time, including midround. + /// + public abstract IReadOnlyList<(TimeSpan, string)> AllPreviousGameRules { get; } + // See ideally these would be pulled from the job definition or something. // But this is easier, and at least it isn't hardcoded. //TODO: Move these, they really belong in StationJobsSystem or a cvar. diff --git a/Content.Shared/Sound/Components/EmitSoundOnThrowComponent.cs b/Content.Shared/Sound/Components/EmitSoundOnThrowComponent.cs index 76e9f08076..0498642c00 100644 --- a/Content.Shared/Sound/Components/EmitSoundOnThrowComponent.cs +++ b/Content.Shared/Sound/Components/EmitSoundOnThrowComponent.cs @@ -3,7 +3,7 @@ using Robust.Shared.GameStates; namespace Content.Shared.Sound.Components; /// -/// Simple sound emitter that emits sound on ThrowEvent +/// Simple sound emitter that emits sound on ThrownEvent /// [RegisterComponent, NetworkedComponent, AutoGenerateComponentState] public sealed partial class EmitSoundOnThrowComponent : BaseEmitSoundComponent; diff --git a/Content.Shared/Throwing/CatchableSystem.cs b/Content.Shared/Throwing/CatchableSystem.cs index 8f2fd355ba..586397f58b 100644 --- a/Content.Shared/Throwing/CatchableSystem.cs +++ b/Content.Shared/Throwing/CatchableSystem.cs @@ -3,6 +3,7 @@ using Content.Shared.Hands.Components; using Content.Shared.Hands.EntitySystems; using Content.Shared.IdentityManagement; using Content.Shared.Popups; +using Content.Shared.Random.Helpers; using Content.Shared.Whitelist; using Robust.Shared.Audio.Systems; using Robust.Shared.Network; @@ -55,7 +56,7 @@ public sealed partial class CatchableSystem : EntitySystem return; // TODO: Replace with RandomPredicted once the engine PR is merged - var seed = HashCode.Combine((int)_timing.CurTick.Value, GetNetEntity(ent).Id); + var seed = SharedRandomExtensions.HashCodeCombine(new() { (int)_timing.CurTick.Value, GetNetEntity(ent).Id }); var rand = new System.Random(seed); if (!rand.Prob(ent.Comp.CatchChance)) return; diff --git a/Content.Shared/Throwing/ThrowEvents.cs b/Content.Shared/Throwing/ThrowEvents.cs index fbda80b8ca..8b60a7b4b1 100644 --- a/Content.Shared/Throwing/ThrowEvents.cs +++ b/Content.Shared/Throwing/ThrowEvents.cs @@ -1,39 +1,25 @@ -namespace Content.Shared.Throwing -{ - /// - /// Base class for all throw events. - /// - public abstract class ThrowEvent : HandledEntityEventArgs - { - public readonly EntityUid Thrown; - public readonly EntityUid Target; - public ThrownItemComponent Component; +namespace Content.Shared.Throwing; - public ThrowEvent(EntityUid thrown, EntityUid target, ThrownItemComponent component) - { - Thrown = thrown; - Target = target; - Component = component; - } - } +/// +/// Raised on an entity after it has thrown something. +/// +[ByRefEvent] +public readonly record struct ThrowEvent(EntityUid? User, EntityUid Thrown); - /// - /// Raised directed on the target entity being hit by the thrown entity. - /// - public sealed class ThrowHitByEvent : ThrowEvent - { - public ThrowHitByEvent(EntityUid thrown, EntityUid target, ThrownItemComponent component) : base(thrown, target, component) - { - } - } +/// +/// Raised on an entity after it has been thrown. +/// +[ByRefEvent] +public readonly record struct ThrownEvent(EntityUid? User, EntityUid Thrown); - /// - /// Raised directed on the thrown entity that hits another. - /// - public sealed class ThrowDoHitEvent : ThrowEvent - { - public ThrowDoHitEvent(EntityUid thrown, EntityUid target, ThrownItemComponent component) : base(thrown, target, component) - { - } - } -} +/// +/// Raised directed on the target entity being hit by the thrown entity. +/// +[ByRefEvent] +public readonly record struct ThrowHitByEvent(EntityUid Thrown, EntityUid Target, ThrownItemComponent Component); + +/// +/// Raised directed on the thrown entity that hits another. +/// +[ByRefEvent] +public readonly record struct ThrowDoHitEvent(EntityUid Thrown, EntityUid Target, ThrownItemComponent Component); diff --git a/Content.Shared/Throwing/ThrowingSystem.cs b/Content.Shared/Throwing/ThrowingSystem.cs index ceb9cf8bfb..4e44901c57 100644 --- a/Content.Shared/Throwing/ThrowingSystem.cs +++ b/Content.Shared/Throwing/ThrowingSystem.cs @@ -192,8 +192,6 @@ public sealed class ThrowingSystem : EntitySystem } } - var throwEvent = new ThrownEvent(user, uid); - RaiseLocalEvent(uid, ref throwEvent, true); if (user != null) _adminLogger.Add(LogType.Throw, LogImpact.Low, $"{ToPrettyString(user.Value):user} threw {ToPrettyString(uid):entity}"); @@ -206,6 +204,14 @@ public sealed class ThrowingSystem : EntitySystem var impulseVector = direction.Normalized() * throwSpeed * physics.Mass; _physics.ApplyLinearImpulse(uid, impulseVector, body: physics); + var thrownEvent = new ThrownEvent(user, uid); + RaiseLocalEvent(uid, ref thrownEvent, true); + if (user != null) + { + var throwEvent = new ThrowEvent(user, uid); + RaiseLocalEvent(user.Value, ref throwEvent, true); + } + if (comp.LandTime == null || comp.LandTime <= TimeSpan.Zero) { _thrownSystem.LandComponent(uid, comp, physics, playSound); diff --git a/Content.Shared/Throwing/ThrownEvent.cs b/Content.Shared/Throwing/ThrownEvent.cs deleted file mode 100644 index 70cb6ee43d..0000000000 --- a/Content.Shared/Throwing/ThrownEvent.cs +++ /dev/null @@ -1,10 +0,0 @@ -using JetBrains.Annotations; - -namespace Content.Shared.Throwing; - -/// -/// Raised on thrown entity. -/// -[PublicAPI] -[ByRefEvent] -public readonly record struct ThrownEvent(EntityUid? User, EntityUid Thrown); diff --git a/Content.Shared/Throwing/ThrownItemSystem.cs b/Content.Shared/Throwing/ThrownItemSystem.cs index 65c5a0f13e..5adad359e5 100644 --- a/Content.Shared/Throwing/ThrownItemSystem.cs +++ b/Content.Shared/Throwing/ThrownItemSystem.cs @@ -140,8 +140,10 @@ namespace Content.Shared.Throwing _adminLogger.Add(LogType.ThrowHit, LogImpact.Low, $"{ToPrettyString(thrown):thrown} thrown by {ToPrettyString(component.Thrower.Value):thrower} hit {ToPrettyString(target):target}."); - RaiseLocalEvent(target, new ThrowHitByEvent(thrown, target, component), true); - RaiseLocalEvent(thrown, new ThrowDoHitEvent(thrown, target, component), true); + var hitByEv = new ThrowHitByEvent(thrown, target, component); + var doHitEv = new ThrowDoHitEvent(thrown, target, component); + RaiseLocalEvent(target, ref hitByEv, true); + RaiseLocalEvent(thrown, ref doHitEv, true); } public override void Update(float frameTime) diff --git a/Content.Shared/Trigger/Components/Triggers/TriggerOnGotInsertedIntoContainerComponent.cs b/Content.Shared/Trigger/Components/Triggers/TriggerOnGotInsertedIntoContainerComponent.cs new file mode 100644 index 0000000000..132167a747 --- /dev/null +++ b/Content.Shared/Trigger/Components/Triggers/TriggerOnGotInsertedIntoContainerComponent.cs @@ -0,0 +1,18 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared.Trigger.Components.Triggers; + +/// +/// Triggers an entity when it gets inserted into a container. +/// The user is the owner of the container the entity is being inserted into. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class TriggerOnGotInsertedIntoContainerComponent : BaseTriggerOnXComponent +{ + /// + /// The container to the entity has to be inserted into. + /// Null will allow all containers. + /// + [DataField, AutoNetworkedField] + public string? ContainerId; +} diff --git a/Content.Shared/Trigger/Components/Triggers/TriggerOnGotRemovedFromContainerComponent.cs b/Content.Shared/Trigger/Components/Triggers/TriggerOnGotRemovedFromContainerComponent.cs new file mode 100644 index 0000000000..8c66bf4e81 --- /dev/null +++ b/Content.Shared/Trigger/Components/Triggers/TriggerOnGotRemovedFromContainerComponent.cs @@ -0,0 +1,18 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared.Trigger.Components.Triggers; + +/// +/// Triggers an entity when it gets removed from a container. +/// The user is the owner of the container the entity is being removed from. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class TriggerOnGotRemovedFromContainerComponent : BaseTriggerOnXComponent +{ + /// + /// The container to the entity has to be removed from. + /// Null will allow all containers. + /// + [DataField, AutoNetworkedField] + public string? ContainerId; +} diff --git a/Content.Shared/Trigger/Components/Triggers/TriggerOnInsertedIntoContainerComponent.cs b/Content.Shared/Trigger/Components/Triggers/TriggerOnInsertedIntoContainerComponent.cs new file mode 100644 index 0000000000..d5616dfd85 --- /dev/null +++ b/Content.Shared/Trigger/Components/Triggers/TriggerOnInsertedIntoContainerComponent.cs @@ -0,0 +1,18 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared.Trigger.Components.Triggers; + +/// +/// Triggers an entity when something is inserted into it. +/// The user is the entity being inserted into the container. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class TriggerOnInsertedIntoContainerComponent : BaseTriggerOnXComponent +{ + /// + /// The container to the entity has to be inserted into. + /// Null will allow all containers. + /// + [DataField, AutoNetworkedField] + public string? ContainerId; +} diff --git a/Content.Shared/Trigger/Components/Triggers/TriggerOnRemovedFromContainerComponent.cs b/Content.Shared/Trigger/Components/Triggers/TriggerOnRemovedFromContainerComponent.cs new file mode 100644 index 0000000000..095b040834 --- /dev/null +++ b/Content.Shared/Trigger/Components/Triggers/TriggerOnRemovedFromContainerComponent.cs @@ -0,0 +1,18 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared.Trigger.Components.Triggers; + +/// +/// Triggers an entity when something is removed from it. +/// The user is the entity being removed from the container. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class TriggerOnRemovedFromContainerComponent : BaseTriggerOnXComponent +{ + /// + /// The container to the entity has to be removed from. + /// Null will allow all containers. + /// + [DataField, AutoNetworkedField] + public string? ContainerId; +} diff --git a/Content.Shared/Trigger/Components/Triggers/TriggerOnThrowComponent.cs b/Content.Shared/Trigger/Components/Triggers/TriggerOnThrowComponent.cs new file mode 100644 index 0000000000..e9249a8f2a --- /dev/null +++ b/Content.Shared/Trigger/Components/Triggers/TriggerOnThrowComponent.cs @@ -0,0 +1,10 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared.Trigger.Components.Triggers; + +/// +/// Triggers when after an entity has thrown something. +/// The user is the thrown item. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class TriggerOnThrowComponent : BaseTriggerOnXComponent; diff --git a/Content.Shared/Trigger/Components/Triggers/TriggerOnThrownComponent.cs b/Content.Shared/Trigger/Components/Triggers/TriggerOnThrownComponent.cs new file mode 100644 index 0000000000..e309261711 --- /dev/null +++ b/Content.Shared/Trigger/Components/Triggers/TriggerOnThrownComponent.cs @@ -0,0 +1,10 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared.Trigger.Components.Triggers; + +/// +/// Triggers when an entity was thrown. +/// The user is the thrower. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class TriggerOnThrownComponent : BaseTriggerOnXComponent; diff --git a/Content.Shared/Trigger/Systems/TriggerOnContainerInteractionSystem.cs b/Content.Shared/Trigger/Systems/TriggerOnContainerInteractionSystem.cs new file mode 100644 index 0000000000..8fa9308f70 --- /dev/null +++ b/Content.Shared/Trigger/Systems/TriggerOnContainerInteractionSystem.cs @@ -0,0 +1,70 @@ +using Content.Shared.Trigger.Components.Triggers; +using Robust.Shared.Containers; +using Robust.Shared.Timing; + +namespace Content.Shared.Trigger.Systems; + +/// +/// System for creating triggers when entities are inserted into or removed from containers. +/// +public sealed class TriggerOnContainerInteractionSystem : EntitySystem +{ + [Dependency] private readonly TriggerSystem _trigger = default!; + [Dependency] private readonly IGameTiming _timing = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnInsertedIntoContainer); + SubscribeLocalEvent(OnRemovedFromContainer); + SubscribeLocalEvent(OnGotInsertedIntoContainer); + SubscribeLocalEvent(OnGotRemovedFromContainer); + } + + // Used by containers to trigger when entities are inserted into or removed from them + private void OnInsertedIntoContainer(Entity ent, ref EntInsertedIntoContainerMessage args) + { + if (_timing.ApplyingState) + return; + + if (ent.Comp.ContainerId != null && ent.Comp.ContainerId != args.Container.ID) + return; + + _trigger.Trigger(ent.Owner, args.Entity, ent.Comp.KeyOut); + } + + private void OnRemovedFromContainer(Entity ent, ref EntRemovedFromContainerMessage args) + { + if (_timing.ApplyingState) + return; + + if (ent.Comp.ContainerId != null && ent.Comp.ContainerId != args.Container.ID) + return; + + _trigger.Trigger(ent.Owner, args.Entity, ent.Comp.KeyOut); + } + + // Used by entities to trigger when they are inserted into or removed from a container + private void OnGotInsertedIntoContainer(Entity ent, ref EntGotInsertedIntoContainerMessage args) + { + if (_timing.ApplyingState) + return; + + if (ent.Comp.ContainerId != null && ent.Comp.ContainerId != args.Container.ID) + return; + + _trigger.Trigger(ent.Owner, args.Container.Owner, ent.Comp.KeyOut); + } + + private void OnGotRemovedFromContainer(Entity ent, ref EntGotRemovedFromContainerMessage args) + { + if (_timing.ApplyingState) + return; + + if (ent.Comp.ContainerId != null && ent.Comp.ContainerId != args.Container.ID) + return; + + _trigger.Trigger(ent.Owner, args.Container.Owner, ent.Comp.KeyOut); + } +} diff --git a/Content.Shared/Trigger/Systems/TriggerSystem.Interaction.cs b/Content.Shared/Trigger/Systems/TriggerSystem.Interaction.cs index 230b628663..39ef4889de 100644 --- a/Content.Shared/Trigger/Systems/TriggerSystem.Interaction.cs +++ b/Content.Shared/Trigger/Systems/TriggerSystem.Interaction.cs @@ -2,6 +2,7 @@ using Content.Shared.Interaction; using Content.Shared.Interaction.Events; using Content.Shared.Item.ItemToggle.Components; +using Content.Shared.Throwing; using Content.Shared.Trigger.Components.Triggers; using Content.Shared.Trigger.Components.Effects; @@ -12,10 +13,11 @@ public sealed partial class TriggerSystem private void InitializeInteraction() { SubscribeLocalEvent(OnExamined); - SubscribeLocalEvent(OnActivate); SubscribeLocalEvent(OnUse); SubscribeLocalEvent(OnInteractHand); + SubscribeLocalEvent(OnThrow); + SubscribeLocalEvent(OnThrown); SubscribeLocalEvent(HandleItemToggleOnTrigger); SubscribeLocalEvent(HandleAnchorOnTrigger); @@ -57,6 +59,16 @@ public sealed partial class TriggerSystem args.Handled = true; } + private void OnThrow(Entity ent, ref ThrowEvent args) + { + Trigger(ent.Owner, args.Thrown, ent.Comp.KeyOut); + } + + private void OnThrown(Entity ent, ref ThrownEvent args) + { + Trigger(ent.Owner, args.User, ent.Comp.KeyOut); + } + private void HandleItemToggleOnTrigger(Entity ent, ref TriggerEvent args) { if (args.Key != null && !ent.Comp.KeysIn.Contains(args.Key)) diff --git a/Resources/Changelog/Admin.yml b/Resources/Changelog/Admin.yml index 988b9038a7..60e38dc6e3 100644 --- a/Resources/Changelog/Admin.yml +++ b/Resources/Changelog/Admin.yml @@ -1330,5 +1330,13 @@ Entries: id: 162 time: '2025-08-08T19:00:41.0000000+00:00' url: https://github.com/space-wizards/space-station-14/pull/35531 +- author: EmoGarbage404, soutbridge-fur + changes: + - message: A new experimental gamemode "Dynamic" has been added to the voting options + and admin seletion. + type: Add + id: 163 + time: '2025-08-15T14:06:51.0000000+00:00' + url: https://github.com/space-wizards/space-station-14/pull/37783 Name: Admin Order: 2 diff --git a/Resources/Changelog/Changelog.yml b/Resources/Changelog/Changelog.yml index 1c96d95537..7974427302 100644 --- a/Resources/Changelog/Changelog.yml +++ b/Resources/Changelog/Changelog.yml @@ -1,19 +1,4 @@ Entries: -- author: Blackern5000 - changes: - - message: The mining hardsuit has had it's armor tweaked to be more effective against - fish and less effective against bullets and/or bombs - type: Tweak - id: 8341 - time: '2025-04-25T04:05:29.0000000+00:00' - url: https://github.com/space-wizards/space-station-14/pull/31450 -- author: Krzeszny - changes: - - message: Rewrote the controls guide. - type: Tweak - id: 8342 - time: '2025-04-25T04:49:35.0000000+00:00' - url: https://github.com/space-wizards/space-station-14/pull/36363 - author: ScarKy0 changes: - message: Interdyne cigs now cost 1TC instead of 2TC. @@ -3948,3 +3933,24 @@ id: 8853 time: '2025-08-14T17:32:22.0000000+00:00' url: https://github.com/space-wizards/space-station-14/pull/39472 +- author: ScholarNZL + changes: + - message: Added IC visibility of "animal organs", e.g "animal lungs" etc, to facilitate + recipe creation. + type: Tweak + id: 8854 + time: '2025-08-15T04:21:51.0000000+00:00' + url: https://github.com/space-wizards/space-station-14/pull/39228 +- author: FlipBrooke + changes: + - message: Banana peels and their variants can now be worn as headgear + type: Add + - message: Banana peels will correctly render when selected using chameleon headgear + type: Fix + - message: Banana peels will no longer render as headgear when off a player's head + type: Fix + - message: Banana peels as headgear now correctly displays in the change log + type: Fix + id: 8855 + time: '2025-08-15T05:56:19.0000000+00:00' + url: https://github.com/space-wizards/space-station-14/pull/39457 diff --git a/Resources/Locale/en-US/_strings/HUD/game-hud.ftl b/Resources/Locale/en-US/_strings/HUD/game-hud.ftl index ea423f080a..d88403a6af 100644 --- a/Resources/Locale/en-US/_strings/HUD/game-hud.ftl +++ b/Resources/Locale/en-US/_strings/HUD/game-hud.ftl @@ -7,3 +7,4 @@ game-hud-open-crafting-menu-button-tooltip = Open crafting menu. game-hud-open-actions-menu-button-tooltip = Open actions menu. game-hud-open-admin-menu-button-tooltip = Open admin menu. game-hud-open-sandbox-menu-button-tooltip = Open sandbox menu. +game-hud-open-bug-report-window-button-tooltip = Open bug report menu. diff --git a/Resources/Locale/en-US/_strings/commands/toolshed-commands.ftl b/Resources/Locale/en-US/_strings/commands/toolshed-commands.ftl index cc5c03d52b..33bf53f9e3 100644 --- a/Resources/Locale/en-US/_strings/commands/toolshed-commands.ftl +++ b/Resources/Locale/en-US/_strings/commands/toolshed-commands.ftl @@ -106,3 +106,19 @@ command-description-scale-multiplyvector = Multiply an entity's sprite size with a certain 2d vector (without changing its fixture). command-description-scale-multiplywithfixture = Multiply an entity's sprite size with a certain factor (including its fixture). +command-description-dynamicrule-list = + Lists all currently active dynamic rules, usually this is just one. +command-description-dynamicrule-get = + Gets the currently active dynamic rule. +command-description-dynamicrule-budget = + Gets the current budget of the piped dynamic rule(s). +command-description-dynamicrule-adjust = + Adjusts the budget of the piped dynamic rule(s) by the specified amount. +command-description-dynamicrule-set = + Sets the budget of the piped dynamic rule(s) to the specified amount. +command-description-dynamicrule-dryrun = + Returns a list of rules that could be activated if the rule ran at this moment with all current context. This is not a complete list of every single rule that could be run, just a sample of the current valid ones. +command-description-dynamicrule-executenow = + Executes the piped dynamic rule as if it had reached its regular update time. +command-description-dynamicrule-rules = + Gets a list of all the rules spawned by the piped dynamic rule. diff --git a/Resources/Locale/en-US/_strings/game-ticking/game-presets/preset-secret.ftl b/Resources/Locale/en-US/_strings/game-ticking/game-presets/preset-secret.ftl index 892e5c3994..2551b0073d 100644 --- a/Resources/Locale/en-US/_strings/game-ticking/game-presets/preset-secret.ftl +++ b/Resources/Locale/en-US/_strings/game-ticking/game-presets/preset-secret.ftl @@ -1,2 +1,5 @@ secret-title = Secret secret-description = It's a secret to everyone. The threats you encounter are randomized. + +dynamic-title = Dynamic +dynamic-description = No one knows what's coming. You can encounter any number of threats. diff --git a/Resources/Locale/en-US/_strings/kitchen/components/kitchen-spike-component.ftl b/Resources/Locale/en-US/_strings/kitchen/components/kitchen-spike-component.ftl index aa555b24ae..aaa1779f53 100644 --- a/Resources/Locale/en-US/_strings/kitchen/components/kitchen-spike-component.ftl +++ b/Resources/Locale/en-US/_strings/kitchen/components/kitchen-spike-component.ftl @@ -1,7 +1,7 @@ comp-kitchen-spike-deny-collect = { CAPITALIZE(THE($this)) } already has something on it, finish collecting its meat first! comp-kitchen-spike-deny-butcher = { CAPITALIZE(THE($victim)) } can't be butchered on { THE($this) }. comp-kitchen-spike-deny-butcher-knife = { CAPITALIZE(THE($victim)) } can't be butchered on { THE($this) }, you need to butcher it using a knife. -comp-kitchen-spike-deny-not-dead = { CAPITALIZE(THE($victim)) } can't be butchered. { CAPITALIZE(SUBJECT($victim)) } { CONJUGATE-BE($victim) } is not dead! +comp-kitchen-spike-deny-not-dead = { CAPITALIZE(THE($victim)) } can't be butchered. { CAPITALIZE(SUBJECT($victim)) } { CONJUGATE-BE($victim) } not dead! comp-kitchen-spike-begin-hook-victim = { CAPITALIZE(THE($user)) } begins dragging you onto { THE($this) }! comp-kitchen-spike-begin-hook-self = You begin dragging yourself onto { THE($this) }! diff --git a/Resources/Locale/en-US/bugreport/bug-report-report.ftl b/Resources/Locale/en-US/bugreport/bug-report-report.ftl new file mode 100644 index 0000000000..c6296c520f --- /dev/null +++ b/Resources/Locale/en-US/bugreport/bug-report-report.ftl @@ -0,0 +1 @@ +bug-report-report-unknown = unknown diff --git a/Resources/Locale/en-US/bugreport/bug-report-window.ftl b/Resources/Locale/en-US/bugreport/bug-report-window.ftl new file mode 100644 index 0000000000..794014ca98 --- /dev/null +++ b/Resources/Locale/en-US/bugreport/bug-report-window.ftl @@ -0,0 +1,13 @@ +bug-report-window-name = Create bug report +bug-report-window-explanation = Try to be as detailed as possible. If you have recreation steps, list them! +bug-report-window-disabled-not-enabled = Bug reports are currently disabled! +bug-report-window-disabled-playtime = You do not have enough playtime to submit a bug report! +bug-report-window-disabled-cooldown = You can submit a new bug report in {$time}. +bug-report-window-disabled-submissions = You have reached the maximum number of bug reports ({$num}) for this round. +bug-report-window-title-place-holder = Bug report title +bug-report-window-description-place-holder = Type bug report here +bug-report-window-submit-button-text = Submit +bug-report-window-submit-button-confirm-text = Click again to submit! +bug-report-window-submit-button-disclaimer = Your SS14 username and other in game information will be saved. + +bug-report-window-submit-char-split = {$typed}/{$total} diff --git a/Resources/Locale/en-US/github/github-api.ftl b/Resources/Locale/en-US/github/github-api.ftl new file mode 100644 index 0000000000..c439503b60 --- /dev/null +++ b/Resources/Locale/en-US/github/github-api.ftl @@ -0,0 +1,36 @@ +github-command-test-name = testgithubapi + +cmd-testgithubapi-desc = This command makes an issue request to the github api. Remember to check the servers console for errors. +cmd-testgithubapi-help = Usage: testgithubapi + +github-command-not-enabled = The api is not enabled! +github-command-no-path = The key path is empty! +github-command-no-app-id = The app id is empty! +github-command-no-repo-name = The repository name is empty! +github-command-no-owner = The repository owner is empty! + +github-command-issue-title-one = This is a test issue! +github-command-issue-description-one = This is the description of the first issue. :) + +github-command-finish = Check your repository for a newly created issue. If you don't see any, check the server console for errors! + +github-issue-format = ## Description: + {$description} + + ## Meta Data: + Build version: {$buildVersion} + Engine version: {$engineVersion} + + Server name: {$serverName} + Submitted time: {$submittedTime} + + -- Round information -- + Round number: {$roundNumber} + Round time: {$roundTime} + Round type: {$roundType} + Map: {$map} + Number of players: {$numberOfPlayers} + + -- Submitter information -- + Player name: {$username} + Player GUID: {$playerGUID} diff --git a/Resources/Prototypes/Body/Organs/Animal/animal.yml b/Resources/Prototypes/Body/Organs/Animal/animal.yml index 0a8675e3ca..9d1339709e 100644 --- a/Resources/Prototypes/Body/Organs/Animal/animal.yml +++ b/Resources/Prototypes/Body/Organs/Animal/animal.yml @@ -33,7 +33,7 @@ - type: entity id: OrganAnimalLungs parent: BaseAnimalOrgan - name: lungs + name: animal lungs categories: [ HideSpawnMenu ] components: - type: Sprite @@ -67,7 +67,7 @@ - type: entity id: OrganAnimalStomach parent: BaseAnimalOrgan - name: stomach + name: animal stomach categories: [ HideSpawnMenu ] components: - type: Sprite @@ -96,7 +96,6 @@ - type: entity id: OrganMouseStomach parent: OrganAnimalStomach - name: stomach categories: [ HideSpawnMenu ] components: - type: SolutionContainerManager @@ -110,7 +109,7 @@ - type: entity id: OrganAnimalLiver parent: BaseAnimalOrgan - name: liver + name: animal liver categories: [ HideSpawnMenu ] components: - type: Sprite @@ -128,8 +127,8 @@ - type: entity id: OrganAnimalHeart - parent: [ BaseAnimalOrgan, BaseOrganHeart] - name: heart + parent: [BaseAnimalOrgan, BaseOrganHeart] + name: animal heart categories: [ HideSpawnMenu ] components: - type: Sprite @@ -149,7 +148,7 @@ - type: entity id: OrganAnimalKidneys parent: BaseAnimalOrgan - name: kidneys + name: animal kidneys categories: [ HideSpawnMenu ] components: - type: Sprite diff --git a/Resources/Prototypes/Body/Organs/Animal/bloodsucker.yml b/Resources/Prototypes/Body/Organs/Animal/bloodsucker.yml index e360f362d8..6e9b4d5900 100644 --- a/Resources/Prototypes/Body/Organs/Animal/bloodsucker.yml +++ b/Resources/Prototypes/Body/Organs/Animal/bloodsucker.yml @@ -1,7 +1,6 @@ - type: entity id: OrganBloodsuckerStomach parent: OrganAnimalStomach - name: stomach categories: [ HideSpawnMenu ] components: - type: Metabolizer @@ -10,7 +9,6 @@ - type: entity id: OrganBloodsuckerLiver parent: OrganAnimalLiver - name: liver categories: [ HideSpawnMenu ] components: - type: Metabolizer @@ -19,7 +17,6 @@ - type: entity id: OrganBloodsuckerHeart parent: OrganAnimalHeart - name: heart categories: [ HideSpawnMenu ] components: - type: Metabolizer diff --git a/Resources/Prototypes/Body/Organs/arachnid.yml b/Resources/Prototypes/Body/Organs/arachnid.yml index 5f590f2821..deeaa643c0 100644 --- a/Resources/Prototypes/Body/Organs/arachnid.yml +++ b/Resources/Prototypes/Body/Organs/arachnid.yml @@ -28,7 +28,6 @@ - type: entity id: OrganArachnidStomach parent: [OrganAnimalStomach, BaseOrganStomach] - name: stomach description: "Gross. This is hard to stomach." components: - type: Sprite diff --git a/Resources/Prototypes/Entities/Clothing/Head/misc.yml b/Resources/Prototypes/Entities/Clothing/Head/misc.yml index 10d19b9d15..53b4962573 100644 --- a/Resources/Prototypes/Entities/Clothing/Head/misc.yml +++ b/Resources/Prototypes/Entities/Clothing/Head/misc.yml @@ -219,7 +219,7 @@ sprite: Clothing/Head/Misc/fancycrown.rsi - type: TypingIndicatorClothing proto: regal - - type: MobPrice + - type: StaticPrice price: 3000 - type: AddAccentClothing accent: MobsterAccent diff --git a/Resources/Prototypes/Entities/Objects/Consumable/Food/produce.yml b/Resources/Prototypes/Entities/Objects/Consumable/Food/produce.yml index a50e0fa004..4c927f342d 100644 --- a/Resources/Prototypes/Entities/Objects/Consumable/Food/produce.yml +++ b/Resources/Prototypes/Entities/Objects/Consumable/Food/produce.yml @@ -443,6 +443,7 @@ - Trash - BananaPeel - Ruminant + - WhitelistChameleon - HamsterWearable - type: SolutionContainerManager @@ -459,7 +460,7 @@ requiresSpecialDigestion: true - type: Clothing sprite: Objects/Specific/Hydroponics/banana.rsi - equippedState: peel-equipped-HELMET + equippedPrefix: peel slots: - HEAD quickEquip: false @@ -471,7 +472,7 @@ components: - type: Clothing sprite: Objects/Specific/Hydroponics/banana.rsi - equippedState: baked-peel-equipped-HELMET + equippedPrefix: baked-peel - type: Sprite sprite: Objects/Specific/Hydroponics/banana.rsi state: baked-peel @@ -502,7 +503,7 @@ heldPrefix: peel - type: Clothing sprite: Objects/Specific/Hydroponics/mimana.rsi - equippedState: equipped-HELMET + equippedPrefix: peel - type: Slippery slipSound: path: /Audio/Effects/slip.ogg @@ -523,7 +524,7 @@ - type: Slippery - type: Clothing sprite: Objects/Materials/materials.rsi - equippedState: peel-equipped-HELMET + equippedPrefix: peel - type: entity name: carrot diff --git a/Resources/Prototypes/GameRules/dynamic_rules.yml b/Resources/Prototypes/GameRules/dynamic_rules.yml new file mode 100644 index 0000000000..02298e0faa --- /dev/null +++ b/Resources/Prototypes/GameRules/dynamic_rules.yml @@ -0,0 +1,112 @@ +- type: entity + parent: BaseGameRule + id: DynamicRule + components: + - type: GameRule + minPlayers: 5 # <5 is greenshift hours, buddy. + - type: DynamicRule + startingBudgetMin: 200 + startingBudgetMax: 350 + table: !type:AllSelector + children: + # Roundstart Major Rules + - !type:GroupSelector + conditions: + - !type:RoundDurationCondition + max: 1 + children: + - id: Traitor + weight: 60 + conditions: + - !type:HasBudgetCondition + - !type:MaxRuleOccurenceCondition + - id: Nukeops + weight: 25 + conditions: + - !type:HasBudgetCondition + - !type:MaxRuleOccurenceCondition + - !type:PlayerCountCondition + min: 20 + - id: Revolutionary + weight: 5 + conditions: + - !type:HasBudgetCondition + - !type:MaxRuleOccurenceCondition + - id: Zombie + weight: 5 + conditions: + - !type:HasBudgetCondition + - !type:MaxRuleOccurenceCondition + - !type:PlayerCountCondition + min: 20 + - id: Wizard + weight: 5 + conditions: + - !type:HasBudgetCondition + - !type:MaxRuleOccurenceCondition + - !type:PlayerCountCondition + min: 10 + # Roundstart Minor Rules + - !type:GroupSelector + conditions: + - !type:RoundDurationCondition + max: 1 + children: + - id: Thief + prob: 0.5 + conditions: + - !type:HasBudgetCondition + - !type:MaxRuleOccurenceCondition + # Midround rules + - !type:GroupSelector + conditions: + - !type:RoundDurationCondition + min: 300 # minimum 5 minutes + children: + - id: SleeperAgents + weight: 15 + conditions: + - !type:HasBudgetCondition + - !type:MaxRuleOccurenceCondition + - !type:RoundDurationCondition + min: 900 # 15 minutes + - id: DragonSpawn + weight: 15 + conditions: + - !type:HasBudgetCondition + - !type:MaxRuleOccurenceCondition + - !type:RoundDurationCondition + min: 900 # 15 minutes + - id: NinjaSpawn + weight: 20 + conditions: + - !type:HasBudgetCondition + - !type:MaxRuleOccurenceCondition + - !type:RoundDurationCondition + min: 900 # 15 minutes + - id: ParadoxCloneSpawn + weight: 25 + conditions: + - !type:HasBudgetCondition + - !type:MaxRuleOccurenceCondition + max: 2 + - !type:RoundDurationCondition + min: 600 # 10 minutes + - id: ZombieOutbreak + weight: 2.5 + conditions: + - !type:HasBudgetCondition + - !type:MaxRuleOccurenceCondition + - !type:PlayerCountCondition + min: 20 + - !type:RoundDurationCondition + min: 2700 # 45 minutes + - id: LoneOpsSpawn + weight: 5 + conditions: + - !type:HasBudgetCondition + - !type:MaxRuleOccurenceCondition + - !type:PlayerCountCondition + min: 20 + - !type:RoundDurationCondition + min: 2100 # 35 minutes diff --git a/Resources/Prototypes/GameRules/events.yml b/Resources/Prototypes/GameRules/events.yml index 7bdf74d3ef..66e0876da2 100644 --- a/Resources/Prototypes/GameRules/events.yml +++ b/Resources/Prototypes/GameRules/events.yml @@ -32,17 +32,22 @@ table: !type:AllSelector # we need to pass a list of rules, since rules have further restrictions to consider via StationEventComp children: - id: ClosetSkeleton - - id: DragonSpawn - id: KingRatMigration + - id: RevenantSpawn + - id: DerelictCyborgSpawn + +- type: entityTable + id: ModerateAntagEventsTable + table: !type:AllSelector # we need to pass a list of rules, since rules have further restrictions to consider via StationEventComp + children: + - id: DragonSpawn - id: NinjaSpawn - id: ParadoxCloneSpawn - - id: RevenantSpawn - id: SleeperAgents - id: ZombieOutbreak - id: LoneOpsSpawn - - id: DerelictCyborgSpawn - - id: AbductorsSpawn - id: WizardSpawn + - id: AbductorsSpawn # Sunrise-Edit - type: entity id: BaseStationEvent @@ -190,6 +195,8 @@ pickPlayer: false mindRoles: - MindRoleDragon + - type: DynamicRuleCost + cost: 75 - type: entity parent: BaseGameRule @@ -246,6 +253,8 @@ nameFormat: name-format-ninja mindRoles: - MindRoleNinja + - type: DynamicRuleCost + cost: 75 - type: entity parent: BaseGameRule @@ -282,6 +291,8 @@ sound: /Audio/Misc/paradox_clone_greeting.ogg mindRoles: - MindRoleParadoxClone + - type: DynamicRuleCost + cost: 50 - type: entity parent: BaseGameRule @@ -534,6 +545,8 @@ - type: InitialInfected mindRoles: - MindRoleInitialInfected + - type: DynamicRuleCost + cost: 200 - type: entity parent: BaseNukeopsRule @@ -576,6 +589,8 @@ - Syndicate mindRoles: - MindRoleNukeops + - type: DynamicRuleCost + cost: 75 - type: entity parent: BaseTraitorRule diff --git a/Resources/Prototypes/GameRules/roundstart.yml b/Resources/Prototypes/GameRules/roundstart.yml index 83a4995572..2339613a1c 100644 --- a/Resources/Prototypes/GameRules/roundstart.yml +++ b/Resources/Prototypes/GameRules/roundstart.yml @@ -200,6 +200,8 @@ - Syndicate mindRoles: - MindRoleNukeops + - type: DynamicRuleCost + cost: 200 - type: entity abstract: true @@ -215,6 +217,8 @@ maxDifficulty: 5 - type: AntagSelection agentName: traitor-round-end-agent-name + - type: DynamicRuleCost + cost: 100 - type: entity parent: BaseTraitorRule @@ -236,7 +240,7 @@ blacklist: components: - AntagImmune - lateJoinAdditional: true + lateJoinAdditional: false mindRoles: - MindRoleTraitor @@ -313,6 +317,7 @@ - type: HeadRevolutionary mindRoles: - MindRoleHeadRevolutionary + # Sunrise-Start - prefRoles: [ Rev ] fallbackRoles: [ HeadRev ] max: 30 @@ -325,6 +330,9 @@ - type: Revolutionary mindRoles: - MindRoleRevolutionary + # Sunrise-End + - type: DynamicRuleCost + cost: 200 - type: entity id: Sandbox @@ -394,6 +402,8 @@ nameFormat: name-format-wizard mindRoles: - MindRoleWizard + - type: DynamicRuleCost + cost: 150 - type: entity id: Zombie @@ -428,6 +438,8 @@ - type: InitialInfected mindRoles: - MindRoleInitialInfected + - type: DynamicRuleCost + cost: 200 # This rule makes the chosen players unable to get other antag rules, as a way to prevent metagaming job rolls. # Put this before antags assigned to station jobs, but after non-job antags (NukeOps/Wiz). @@ -455,6 +467,8 @@ tableId: BasicCalmEventsTable - !type:NestedSelector tableId: BasicAntagEventsTable + - !type:NestedSelector + tableId: ModerateAntagEventsTable - !type:NestedSelector tableId: CargoGiftsTable - !type:NestedSelector @@ -462,6 +476,21 @@ - !type:NestedSelector tableId: SpicyPestEventsTable +- type: entityTable + id: DynamicGameRulesTable + table: !type:AllSelector # we need to pass a list of rules, since rules have further restrictions to consider via StationEventComp + children: + - !type:NestedSelector + tableId: BasicCalmEventsTable + - !type:NestedSelector + tableId: BasicAntagEventsTable + - !type:NestedSelector + tableId: CargoGiftsTable + - !type:NestedSelector + tableId: CalmPestEventsTable + - !type:NestedSelector + tableId: SpicyPestEventsTable + - type: entityTable id: SpaceTrafficControlTable table: !type:AllSelector # we need to pass a list of rules, since rules have further restrictions to consider via StationEventComp @@ -487,6 +516,14 @@ scheduledGameRules: !type:NestedSelector tableId: BasicGameRulesTable +- type: entity + id: DynamicStationEventScheduler # this isn't the dynamic mode, but rather the station event scheduler used for dynamic + parent: BaseGameRule + components: + - type: BasicStationEventScheduler + scheduledGameRules: !type:NestedSelector + tableId: DynamicGameRulesTable + - type: entity id: RampingStationEventScheduler parent: BaseGameRule diff --git a/Resources/Prototypes/GameRules/subgamemodes.yml b/Resources/Prototypes/GameRules/subgamemodes.yml index 2213623c28..0dbef6e06b 100644 --- a/Resources/Prototypes/GameRules/subgamemodes.yml +++ b/Resources/Prototypes/GameRules/subgamemodes.yml @@ -34,6 +34,8 @@ - MindRoleThief briefing: sound: "/Audio/Misc/thief_greeting.ogg" + - type: DynamicRuleCost + cost: 75 # Needs testing - type: entity diff --git a/Resources/Prototypes/game_presets.yml b/Resources/Prototypes/game_presets.yml index 8c9b7c11c8..5bda3ab2da 100644 --- a/Resources/Prototypes/game_presets.yml +++ b/Resources/Prototypes/game_presets.yml @@ -114,6 +114,23 @@ - SpaceTrafficControlFriendlyEventScheduler - BasicRoundstartVariation +- type: gamePreset + id: Dynamic + alias: + - dynamic + - multiantag + - director + name: dynamic-title + showInVote: true + description: dynamic-description + rules: + - DynamicRule + - DummyNonAntag + - DynamicStationEventScheduler + - MeteorSwarmScheduler + - SpaceTrafficControlEventScheduler + - BasicRoundstartVariation + - type: gamePreset id: Secret alias: diff --git a/Resources/Textures/Interface/bug.svg b/Resources/Textures/Interface/bug.svg new file mode 100644 index 0000000000..f79bfe3e04 --- /dev/null +++ b/Resources/Textures/Interface/bug.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Resources/Textures/Interface/bug.svg.192dpi.png b/Resources/Textures/Interface/bug.svg.192dpi.png new file mode 100644 index 0000000000..d901996bd6 Binary files /dev/null and b/Resources/Textures/Interface/bug.svg.192dpi.png differ diff --git a/Resources/Textures/Interface/bug.svg.192dpi.png.yml b/Resources/Textures/Interface/bug.svg.192dpi.png.yml new file mode 100644 index 0000000000..5c43e23305 --- /dev/null +++ b/Resources/Textures/Interface/bug.svg.192dpi.png.yml @@ -0,0 +1,2 @@ +sample: + filter: true diff --git a/Resources/Textures/Interface/splat.svg b/Resources/Textures/Interface/splat.svg new file mode 100644 index 0000000000..5a267c9d96 --- /dev/null +++ b/Resources/Textures/Interface/splat.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Resources/Textures/Interface/splat.svg.192dpi.png b/Resources/Textures/Interface/splat.svg.192dpi.png new file mode 100644 index 0000000000..9540031f04 Binary files /dev/null and b/Resources/Textures/Interface/splat.svg.192dpi.png differ diff --git a/Resources/Textures/Interface/splat.svg.192dpi.png.yml b/Resources/Textures/Interface/splat.svg.192dpi.png.yml new file mode 100644 index 0000000000..dabd6601f7 --- /dev/null +++ b/Resources/Textures/Interface/splat.svg.192dpi.png.yml @@ -0,0 +1,2 @@ +sample: + filter: true diff --git a/Resources/Textures/Objects/Specific/Hydroponics/mimana.rsi/meta.json b/Resources/Textures/Objects/Specific/Hydroponics/mimana.rsi/meta.json index 9bc2def611..ed601a6e35 100644 --- a/Resources/Textures/Objects/Specific/Hydroponics/mimana.rsi/meta.json +++ b/Resources/Textures/Objects/Specific/Hydroponics/mimana.rsi/meta.json @@ -10,14 +10,6 @@ { "name": "dead" }, - { - "name": "equipped-HELMET", - "directions": 4 - }, - { - "name": "equipped-HELMET-hamster", - "directions": 4 - }, { "name": "harvest" }, @@ -33,6 +25,14 @@ { "name": "peel3" }, + { + "name": "peel-equipped-HELMET", + "directions": 4 + }, + { + "name": "peel-equipped-HELMET-hamster", + "directions": 4 + }, { "name": "peel-inhand-left", "directions": 4 diff --git a/Resources/Textures/Objects/Specific/Hydroponics/mimana.rsi/equipped-HELMET-hamster.png b/Resources/Textures/Objects/Specific/Hydroponics/mimana.rsi/peel-equipped-HELMET-hamster.png similarity index 100% rename from Resources/Textures/Objects/Specific/Hydroponics/mimana.rsi/equipped-HELMET-hamster.png rename to Resources/Textures/Objects/Specific/Hydroponics/mimana.rsi/peel-equipped-HELMET-hamster.png diff --git a/Resources/Textures/Objects/Specific/Hydroponics/mimana.rsi/equipped-HELMET.png b/Resources/Textures/Objects/Specific/Hydroponics/mimana.rsi/peel-equipped-HELMET.png similarity index 100% rename from Resources/Textures/Objects/Specific/Hydroponics/mimana.rsi/equipped-HELMET.png rename to Resources/Textures/Objects/Specific/Hydroponics/mimana.rsi/peel-equipped-HELMET.png