From 96cc6a77851068212bf14a1d920ad8e922188c83 Mon Sep 17 00:00:00 2001 From: ThatGuyUSA Date: Thu, 14 Aug 2025 15:48:50 -0700 Subject: [PATCH 01/13] Meat spike conjugation error (#39651) fixed it! --- .../Locale/en-US/kitchen/components/kitchen-spike-component.ftl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Resources/Locale/en-US/kitchen/components/kitchen-spike-component.ftl b/Resources/Locale/en-US/kitchen/components/kitchen-spike-component.ftl index aa555b24ae..aaa1779f53 100644 --- a/Resources/Locale/en-US/kitchen/components/kitchen-spike-component.ftl +++ b/Resources/Locale/en-US/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) }! From 2b31fa98c9eb3b282888e6f46f24ca3da4bfb129 Mon Sep 17 00:00:00 2001 From: Artxmisery <78118840+Artxmisery@users.noreply.github.com> Date: Thu, 14 Aug 2025 23:53:48 +0000 Subject: [PATCH 02/13] Add container-related triggers (#39647) * added triggers for when an entity is inserted or removed from a container, for both the entity and the container * removed unnecessary comments * consolidated into one shared system for all four triggers * consolidated into one shared system for all four triggers * named it right this time * made container owner user of got triggers and added guard statements * container id * Update Content.Shared/Trigger/Components/Triggers/TriggerOnRemovedFromContainerComponent.cs --------- Co-authored-by: slarticodefast <161409025+slarticodefast@users.noreply.github.com> --- ...ggerOnGotInsertedIntoContainerComponent.cs | 18 +++++ ...iggerOnGotRemovedFromContainerComponent.cs | 18 +++++ ...TriggerOnInsertedIntoContainerComponent.cs | 18 +++++ .../TriggerOnRemovedFromContainerComponent.cs | 18 +++++ .../TriggerOnContainerInteractionSystem.cs | 70 +++++++++++++++++++ 5 files changed, 142 insertions(+) create mode 100644 Content.Shared/Trigger/Components/Triggers/TriggerOnGotInsertedIntoContainerComponent.cs create mode 100644 Content.Shared/Trigger/Components/Triggers/TriggerOnGotRemovedFromContainerComponent.cs create mode 100644 Content.Shared/Trigger/Components/Triggers/TriggerOnInsertedIntoContainerComponent.cs create mode 100644 Content.Shared/Trigger/Components/Triggers/TriggerOnRemovedFromContainerComponent.cs create mode 100644 Content.Shared/Trigger/Systems/TriggerOnContainerInteractionSystem.cs 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/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); + } +} From d4f50c7f0a6a403ae304637a2a8c6c435d7a0967 Mon Sep 17 00:00:00 2001 From: ScholarNZL Date: Fri, 15 Aug 2025 16:21:51 +1200 Subject: [PATCH 03/13] Animal organs now prefixed with 'animal' (#39228) * Resolves #38980. Animal organs prefixed with 'animal' * Add "animal" prefix to arachnid organ types where base-type applies. * Removed child-level names per Maintainer guidance. * Helps if I leave the parent name details intact... * Hidden bonus mouse organ! --- Resources/Prototypes/Body/Organs/Animal/animal.yml | 11 +++++------ .../Prototypes/Body/Organs/Animal/bloodsucker.yml | 3 --- Resources/Prototypes/Body/Organs/arachnid.yml | 1 - 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/Resources/Prototypes/Body/Organs/Animal/animal.yml b/Resources/Prototypes/Body/Organs/Animal/animal.yml index e59aad9da3..dbf96068fa 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 @@ -129,7 +128,7 @@ - type: entity id: OrganAnimalHeart parent: BaseAnimalOrgan - name: heart + 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 20ff344fa1..b044706ecf 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 - name: stomach description: "Gross. This is hard to stomach." components: - type: Sprite From 7a31e3c1f8890d7fdacc537e2bfa9e2699e08082 Mon Sep 17 00:00:00 2001 From: PJBot Date: Fri, 15 Aug 2025 04:23:02 +0000 Subject: [PATCH 04/13] Automatic changelog update --- Resources/Changelog/Changelog.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Resources/Changelog/Changelog.yml b/Resources/Changelog/Changelog.yml index 1c96d95537..05dbd15682 100644 --- a/Resources/Changelog/Changelog.yml +++ b/Resources/Changelog/Changelog.yml @@ -1,12 +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. @@ -3948,3 +3940,11 @@ 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 From 7a3026b4f89888d6d79a047b902247a3348b63ba Mon Sep 17 00:00:00 2001 From: slarticodefast <161409025+slarticodefast@users.noreply.github.com> Date: Fri, 15 Aug 2025 07:33:37 +0200 Subject: [PATCH 05/13] Throwing triggers (#39650) throw triggers --- .../Components/EmitSoundOnThrowComponent.cs | 2 +- Content.Shared/Throwing/ThrowEvents.cs | 58 +++++++------------ Content.Shared/Throwing/ThrowingSystem.cs | 10 +++- Content.Shared/Throwing/ThrownEvent.cs | 10 ---- Content.Shared/Throwing/ThrownItemSystem.cs | 6 +- .../Triggers/TriggerOnThrowComponent.cs | 10 ++++ .../Triggers/TriggerOnThrownComponent.cs | 10 ++++ .../Systems/TriggerSystem.Interaction.cs | 14 ++++- 8 files changed, 68 insertions(+), 52 deletions(-) delete mode 100644 Content.Shared/Throwing/ThrownEvent.cs create mode 100644 Content.Shared/Trigger/Components/Triggers/TriggerOnThrowComponent.cs create mode 100644 Content.Shared/Trigger/Components/Triggers/TriggerOnThrownComponent.cs 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/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/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/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)) From 2b91f965a2aa68856e7c88c29d934eb67c995a63 Mon Sep 17 00:00:00 2001 From: Winkarst-cpu <74284083+Winkarst-cpu@users.noreply.github.com> Date: Fri, 15 Aug 2025 08:46:27 +0300 Subject: [PATCH 06/13] Add integration test for MobPriceComponent (#39524) Test --- Content.IntegrationTests/Tests/CargoTest.cs | 24 +++++++++++++++++++ .../Entities/Clothing/Head/misc.yml | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/Content.IntegrationTests/Tests/CargoTest.cs b/Content.IntegrationTests/Tests/CargoTest.cs index e5f9fa1e81..5830ea59da 100644 --- a/Content.IntegrationTests/Tests/CargoTest.cs +++ b/Content.IntegrationTests/Tests/CargoTest.cs @@ -5,7 +5,9 @@ using Content.Server.Cargo.Components; using Content.Server.Cargo.Systems; using Content.Server.Nutrition.Components; using Content.Server.Nutrition.EntitySystems; +using Content.Shared.Body.Components; using Content.Shared.Cargo.Prototypes; +using Content.Shared.Mobs.Components; using Content.Shared.Prototypes; using Content.Shared.Stacks; using Content.Shared.Whitelist; @@ -250,4 +252,26 @@ public sealed class CargoTest await pair.CleanReturnAsync(); } + + [Test] + public async Task MobPrice() + { + await using var pair = await PoolManager.GetServerClient(); + + var componentFactory = pair.Server.ResolveDependency(); + + 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/Resources/Prototypes/Entities/Clothing/Head/misc.yml b/Resources/Prototypes/Entities/Clothing/Head/misc.yml index 96844d4017..da0f17f324 100644 --- a/Resources/Prototypes/Entities/Clothing/Head/misc.yml +++ b/Resources/Prototypes/Entities/Clothing/Head/misc.yml @@ -198,7 +198,7 @@ sprite: Clothing/Head/Misc/fancycrown.rsi - type: TypingIndicatorClothing proto: regal - - type: MobPrice + - type: StaticPrice price: 3000 - type: AddAccentClothing accent: MobsterAccent From 5bfd1b180a60d66a445e34e035ea7b8baf2b6dfb Mon Sep 17 00:00:00 2001 From: FlipBrooke <87075384+FlipBrooke@users.noreply.github.com> Date: Thu, 14 Aug 2025 23:56:18 -0600 Subject: [PATCH 07/13] Banana peel headgear fixes (#39457) * Add wearable helmet sprites for banana and mimana peels Made it so banana peels and their variants can be worn as head gear. The sprites were done by myself, so feel free to change or update them! Includes hamster compatibility. * fixed a minor state bug * Review fixes - Removed blank space - Added "FlipBrooke" to all modified meta.jsons, with the added note of which sprites I got the colors from/referenced. * I forgot a comma adds a comma and some periods * Fixes --- .../Objects/Consumable/Food/produce.yml | 9 +++++---- .../Specific/Hydroponics/mimana.rsi/meta.json | 16 ++++++++-------- ...ter.png => peel-equipped-HELMET-hamster.png} | Bin ...pped-HELMET.png => peel-equipped-HELMET.png} | Bin 4 files changed, 13 insertions(+), 12 deletions(-) rename Resources/Textures/Objects/Specific/Hydroponics/mimana.rsi/{equipped-HELMET-hamster.png => peel-equipped-HELMET-hamster.png} (100%) rename Resources/Textures/Objects/Specific/Hydroponics/mimana.rsi/{equipped-HELMET.png => peel-equipped-HELMET.png} (100%) diff --git a/Resources/Prototypes/Entities/Objects/Consumable/Food/produce.yml b/Resources/Prototypes/Entities/Objects/Consumable/Food/produce.yml index 3841dc06b0..551ea802ca 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/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 From 766e7e462c424b6f64788fb7f3c7f425a20b3011 Mon Sep 17 00:00:00 2001 From: PJBot Date: Fri, 15 Aug 2025 05:57:26 +0000 Subject: [PATCH 08/13] Automatic changelog update --- Resources/Changelog/Changelog.yml | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/Resources/Changelog/Changelog.yml b/Resources/Changelog/Changelog.yml index 05dbd15682..7974427302 100644 --- a/Resources/Changelog/Changelog.yml +++ b/Resources/Changelog/Changelog.yml @@ -1,11 +1,4 @@ Entries: -- 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 +3941,16 @@ 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 From d08252facb5c1698539e07a77264cff3c88442f0 Mon Sep 17 00:00:00 2001 From: DrSmugleaf <10968691+DrSmugleaf@users.noreply.github.com> Date: Fri, 15 Aug 2025 01:45:41 -0700 Subject: [PATCH 09/13] Fix unbuckle admin logs only showing the ids and not names of the entities involved (#39655) --- Content.Shared/Buckle/SharedBuckleSystem.Buckle.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Content.Shared/Buckle/SharedBuckleSystem.Buckle.cs b/Content.Shared/Buckle/SharedBuckleSystem.Buckle.cs index 24560cad38..e29ac8f4ed 100644 --- a/Content.Shared/Buckle/SharedBuckleSystem.Buckle.cs +++ b/Content.Shared/Buckle/SharedBuckleSystem.Buckle.cs @@ -1,7 +1,6 @@ using System.Diagnostics.CodeAnalysis; 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; @@ -12,14 +11,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; @@ -448,9 +445,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); From b2c505df6a81da1a7062d01bf9df3fe1548d8c2e Mon Sep 17 00:00:00 2001 From: ScarKy0 <106310278+ScarKy0@users.noreply.github.com> Date: Fri, 15 Aug 2025 15:48:23 +0200 Subject: [PATCH 10/13] Fix instances of predicted randomness (#39661) init --- Content.Shared/Flash/SharedFlashSystem.cs | 4 +++- Content.Shared/Throwing/CatchableSystem.cs | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) 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/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; From f23e8c286153b5742de258a4e722803a1a4fba78 Mon Sep 17 00:00:00 2001 From: Nemanja <98561806+EmoGarbage404@users.noreply.github.com> Date: Fri, 15 Aug 2025 10:06:51 -0400 Subject: [PATCH 11/13] Multiantag Gamemode (#37783) Co-authored-by: slarticodefast <161409025+slarticodefast@users.noreply.github.com> Co-authored-by: Southbridge <7013162+southbridge-fur@users.noreply.github.com> Co-authored-by: ScarKy0 <106310278+ScarKy0@users.noreply.github.com> --- .../GameTicking/Managers/ClientGameTicker.cs | 2 + .../Commands/DynamicRuleCommand.cs | 103 +++++++++ .../GameTicking/GameTicker.GameRule.cs | 2 +- .../GameTicking/Rules/DynamicRuleSystem.cs | 195 ++++++++++++++++++ .../Conditions/HasBudgetCondition.cs | 51 +++++ .../Conditions/MaxRuleOccurenceCondition.cs | 54 +++++ .../Conditions/ReoccurrenceDelayCondition.cs | 49 +++++ .../Conditions/RoundDurationCondition.cs | 34 +++ .../EntitySelectors/GroupSelector.cs | 3 + .../GameTicking/Rules/DynamicRuleComponent.cs | 71 +++++++ .../Rules/DynamicRuleCostComponent.cs | 14 ++ .../GameTicking/SharedGameTicker.cs | 6 + .../en-US/commands/toolshed-commands.ftl | 16 ++ .../game-presets/preset-secret.ftl | 3 + .../Prototypes/GameRules/dynamic_rules.yml | 112 ++++++++++ Resources/Prototypes/GameRules/events.yml | 21 +- Resources/Prototypes/GameRules/roundstart.yml | 37 +++- .../Prototypes/GameRules/subgamemodes.yml | 2 + Resources/Prototypes/game_presets.yml | 17 ++ 19 files changed, 787 insertions(+), 5 deletions(-) create mode 100644 Content.Server/GameTicking/Commands/DynamicRuleCommand.cs create mode 100644 Content.Server/GameTicking/Rules/DynamicRuleSystem.cs create mode 100644 Content.Shared/EntityTable/Conditions/HasBudgetCondition.cs create mode 100644 Content.Shared/EntityTable/Conditions/MaxRuleOccurenceCondition.cs create mode 100644 Content.Shared/EntityTable/Conditions/ReoccurrenceDelayCondition.cs create mode 100644 Content.Shared/EntityTable/Conditions/RoundDurationCondition.cs create mode 100644 Content.Shared/GameTicking/Rules/DynamicRuleComponent.cs create mode 100644 Content.Shared/GameTicking/Rules/DynamicRuleCostComponent.cs create mode 100644 Resources/Prototypes/GameRules/dynamic_rules.yml diff --git a/Content.Client/GameTicking/Managers/ClientGameTicker.cs b/Content.Client/GameTicking/Managers/ClientGameTicker.cs index 170b24c02a..4bd91bd06c 100644 --- a/Content.Client/GameTicking/Managers/ClientGameTicker.cs +++ b/Content.Client/GameTicking/Managers/ClientGameTicker.cs @@ -34,6 +34,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.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/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.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/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 6b8bc8685b..877a849d07 100644 --- a/Content.Shared/GameTicking/SharedGameTicker.cs +++ b/Content.Shared/GameTicking/SharedGameTicker.cs @@ -15,6 +15,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/Resources/Locale/en-US/commands/toolshed-commands.ftl b/Resources/Locale/en-US/commands/toolshed-commands.ftl index cc5c03d52b..33bf53f9e3 100644 --- a/Resources/Locale/en-US/commands/toolshed-commands.ftl +++ b/Resources/Locale/en-US/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/game-ticking/game-presets/preset-secret.ftl b/Resources/Locale/en-US/game-ticking/game-presets/preset-secret.ftl index 892e5c3994..2551b0073d 100644 --- a/Resources/Locale/en-US/game-ticking/game-presets/preset-secret.ftl +++ b/Resources/Locale/en-US/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/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 78602f97b9..2642c0286b 100644 --- a/Resources/Prototypes/GameRules/events.yml +++ b/Resources/Prototypes/GameRules/events.yml @@ -30,15 +30,20 @@ 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: WizardSpawn - type: entity @@ -183,6 +188,8 @@ pickPlayer: false mindRoles: - MindRoleDragon + - type: DynamicRuleCost + cost: 75 - type: entity parent: BaseGameRule @@ -233,6 +240,8 @@ nameFormat: name-format-ninja mindRoles: - MindRoleNinja + - type: DynamicRuleCost + cost: 75 - type: entity parent: BaseGameRule @@ -269,6 +278,8 @@ sound: /Audio/Misc/paradox_clone_greeting.ogg mindRoles: - MindRoleParadoxClone + - type: DynamicRuleCost + cost: 50 - type: entity parent: BaseGameRule @@ -520,6 +531,8 @@ - type: InitialInfected mindRoles: - MindRoleInitialInfected + - type: DynamicRuleCost + cost: 200 - type: entity parent: BaseNukeopsRule @@ -556,6 +569,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 a7c7af7f37..df4d59fd5a 100644 --- a/Resources/Prototypes/GameRules/roundstart.yml +++ b/Resources/Prototypes/GameRules/roundstart.yml @@ -173,6 +173,8 @@ - Syndicate mindRoles: - MindRoleNukeops + - type: DynamicRuleCost + cost: 200 - type: entity abstract: true @@ -188,6 +190,8 @@ maxDifficulty: 5 - type: AntagSelection agentName: traitor-round-end-agent-name + - type: DynamicRuleCost + cost: 100 - type: entity parent: BaseTraitorRule @@ -207,7 +211,7 @@ blacklist: components: - AntagImmune - lateJoinAdditional: true + lateJoinAdditional: false mindRoles: - MindRoleTraitor @@ -278,6 +282,8 @@ - type: HeadRevolutionary mindRoles: - MindRoleHeadRevolutionary + - type: DynamicRuleCost + cost: 200 - type: entity id: Sandbox @@ -341,6 +347,8 @@ nameFormat: name-format-wizard mindRoles: - MindRoleWizard + - type: DynamicRuleCost + cost: 150 - type: entity id: Zombie @@ -373,6 +381,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). @@ -400,6 +410,8 @@ tableId: BasicCalmEventsTable - !type:NestedSelector tableId: BasicAntagEventsTable + - !type:NestedSelector + tableId: ModerateAntagEventsTable - !type:NestedSelector tableId: CargoGiftsTable - !type:NestedSelector @@ -407,6 +419,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 @@ -426,6 +453,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 160bb5e4a0..a9d33b0b76 100644 --- a/Resources/Prototypes/game_presets.yml +++ b/Resources/Prototypes/game_presets.yml @@ -96,6 +96,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: From 890ac9f64582329b61be307f4080cff3e66a4236 Mon Sep 17 00:00:00 2001 From: PJBot Date: Fri, 15 Aug 2025 14:07:58 +0000 Subject: [PATCH 12/13] Automatic changelog update --- Resources/Changelog/Admin.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Resources/Changelog/Admin.yml b/Resources/Changelog/Admin.yml index 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 From a8d6dbc3241c880f846a7abba8bd330788df9fc1 Mon Sep 17 00:00:00 2001 From: beck-thompson <107373427+beck-thompson@users.noreply.github.com> Date: Fri, 15 Aug 2025 09:10:38 -0700 Subject: [PATCH 13/13] Added button and manager for in game bug reports (Part 1) (#35350) * Added the button and manager * Minor cleanup * Reigstered to the wrong thing! * Unload UI * Address the review * First commit :) * Some cleanup * Added some comments and now the placehoder text goes away once you start typing * Some cleanup and better test command * Basic rate limiter class (Not finished) * Cleanup * Removed forgotten comment xD * Whitespace removal * Minor cleanup, cvar hours -> minutes * More minor tweaks * Don't cache timer and add examples to fields * Added CCvar for time between bug reports * Minor crash when restarting rounds fixed * It compiled on my computer! * Fix comment indents * Remove unecessary async, removed magic strings, simplfied sawmill to not use post inject * Make struct private * Simplfiy TryGetLongHeader * Changed list to enumerable * URI cleanup * Got rid of the queue, used a much better way! * Made the comments a little better and fix some issues with them * Added header consts * Maximum reports per round is now an error message * Time between reports is now in seconds * Change ordering * Change hotkey to O * only update window when its open * Split up validation * address review * Address a few issues * inheritance fix * API now doesn't keep track of requests, just uses the rate limited response from github * Rough idea of how channels would work * refactor: reorganized code, placed rate limiter into http-client-handler AND manager (usually only manager-one should work) * cleanup * Add user agent so api doesn't get mad * Better error logs * Cleanup * It now throws! * refactor: renaming, moved some methods, xml-doc cleanups * refactor: BugReportWindow formatted to convention, enforced 1 updates only 1 per sec * Add very basic licence info * Fixed the issues! * Set ccvar default to false * make the button better * fix test fail silly me * Adress the review! * refactor: cleanup of entry point code, binding server-side code with client-facing manager * Resolve the other issues and cleanup and stuff smile :) * not entity * fixes * Cleanup * Cleanup * forgor region * fixes * Split up function and more stuff * Better unsubs yaygit add -A * I pray... * Revert "I pray..." This reverts commit 9629fb4f1289c9009a03e4e4facd9ae975e6303e. * I think I have to add it in the pr * Revert "I think I have to add it in the pr" This reverts commit e185b42f570fe5f0f51e0e44761d7938e22e67f7. * Tweaks * Minor tweak to permissions --------- Co-authored-by: pa.pecherskij --- .../BugReport/BugReportUIController.cs | 113 +++++ .../BugReport/Windows/BugReportWindow.xaml | 31 ++ .../BugReport/Windows/BugReportWindow.xaml.cs | 181 ++++++++ .../MenuBar/GameTopMenuBarUIController.cs | 4 + .../MenuBar/Widgets/GameTopMenuBar.xaml | 9 + Content.Server/BugReports/BugReportManager.cs | 221 ++++++++++ Content.Server/BugReports/IBugReportEvents.cs | 94 ++++ .../BugReports/IBugReportManager.cs | 22 + Content.Server/Entry/EntryPoint.cs | 10 +- .../GameTicking/GameTicker.RoundFlow.cs | 2 + Content.Server/GameTicking/GameTicker.cs | 2 + .../Github/Commands/TestGithubApiCommand.cs | 71 +++ Content.Server/Github/GithubApiManager.cs | 53 +++ .../Github/GithubBackgroundWorker.cs | 74 ++++ Content.Server/Github/GithubClient.cs | 417 ++++++++++++++++++ .../Github/Requests/CreateIssueRequest.cs | 38 ++ .../Github/Requests/IGithubRequest.cs | 44 ++ .../Github/Requests/InstallationsRequest.cs | 18 + .../Github/Requests/TokenRequest.cs | 22 + .../Github/Responses/InstallationResponse.cs | 21 + .../Github/Responses/TokenResponse.cs | 15 + Content.Server/Github/RetryHttpHandler.cs | 100 +++++ Content.Server/IoC/ServerContentIoC.cs | 7 +- Content.Shared.Database/LogType.cs | 6 + Content.Shared/BugReport/BugReportMessage.cs | 42 ++ Content.Shared/CCVar/CCVars.BugReports.cs | 64 +++ Content.Shared/CCVar/CCVars.Github.cs | 71 +++ Resources/Locale/en-US/HUD/game-hud.ftl | 1 + .../en-US/bugreport/bug-report-report.ftl | 1 + .../en-US/bugreport/bug-report-window.ftl | 13 + Resources/Locale/en-US/github/github-api.ftl | 36 ++ Resources/Textures/Interface/bug.svg | 13 + .../Textures/Interface/bug.svg.192dpi.png | Bin 0 -> 1852 bytes .../Textures/Interface/bug.svg.192dpi.png.yml | 2 + Resources/Textures/Interface/splat.svg | 14 + .../Textures/Interface/splat.svg.192dpi.png | Bin 0 -> 2069 bytes .../Interface/splat.svg.192dpi.png.yml | 2 + 37 files changed, 1831 insertions(+), 3 deletions(-) create mode 100644 Content.Client/UserInterface/Systems/BugReport/BugReportUIController.cs create mode 100644 Content.Client/UserInterface/Systems/BugReport/Windows/BugReportWindow.xaml create mode 100644 Content.Client/UserInterface/Systems/BugReport/Windows/BugReportWindow.xaml.cs create mode 100644 Content.Server/BugReports/BugReportManager.cs create mode 100644 Content.Server/BugReports/IBugReportEvents.cs create mode 100644 Content.Server/BugReports/IBugReportManager.cs create mode 100644 Content.Server/Github/Commands/TestGithubApiCommand.cs create mode 100644 Content.Server/Github/GithubApiManager.cs create mode 100644 Content.Server/Github/GithubBackgroundWorker.cs create mode 100644 Content.Server/Github/GithubClient.cs create mode 100644 Content.Server/Github/Requests/CreateIssueRequest.cs create mode 100644 Content.Server/Github/Requests/IGithubRequest.cs create mode 100644 Content.Server/Github/Requests/InstallationsRequest.cs create mode 100644 Content.Server/Github/Requests/TokenRequest.cs create mode 100644 Content.Server/Github/Responses/InstallationResponse.cs create mode 100644 Content.Server/Github/Responses/TokenResponse.cs create mode 100644 Content.Server/Github/RetryHttpHandler.cs create mode 100644 Content.Shared/BugReport/BugReportMessage.cs create mode 100644 Content.Shared/CCVar/CCVars.BugReports.cs create mode 100644 Content.Shared/CCVar/CCVars.Github.cs create mode 100644 Resources/Locale/en-US/bugreport/bug-report-report.ftl create mode 100644 Resources/Locale/en-US/bugreport/bug-report-window.ftl create mode 100644 Resources/Locale/en-US/github/github-api.ftl create mode 100644 Resources/Textures/Interface/bug.svg create mode 100644 Resources/Textures/Interface/bug.svg.192dpi.png create mode 100644 Resources/Textures/Interface/bug.svg.192dpi.png.yml create mode 100644 Resources/Textures/Interface/splat.svg create mode 100644 Resources/Textures/Interface/splat.svg.192dpi.png create mode 100644 Resources/Textures/Interface/splat.svg.192dpi.png.yml 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 e314310bc0..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(); @@ -47,6 +49,7 @@ public sealed class GameTopMenuBarUIController : UIController _action.UnloadButton(); _sandbox.UnloadButton(); _emotes.UnloadButton(); + _bug.UnloadButton(); } public void LoadButtons() @@ -60,5 +63,6 @@ public sealed class GameTopMenuBarUIController : UIController _action.LoadButton(); _sandbox.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}" /> + +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 df4af14f1e..1f2d035a4a 100644 --- a/Content.Server/Entry/EntryPoint.cs +++ b/Content.Server/Entry/EntryPoint.cs @@ -3,6 +3,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; @@ -10,13 +11,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; @@ -111,6 +111,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(); _voteManager.Initialize(); _updateManager.Initialize(); @@ -192,6 +196,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/GameTicker.RoundFlow.cs b/Content.Server/GameTicking/GameTicker.RoundFlow.cs index 1dadca4c03..cb5a1a4187 100644 --- a/Content.Server/GameTicking/GameTicker.RoundFlow.cs +++ b/Content.Server/GameTicking/GameTicker.RoundFlow.cs @@ -725,6 +725,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/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 b4d999bef4..96124330f0 100644 --- a/Content.Server/IoC/ServerContentIoC.cs +++ b/Content.Server/IoC/ServerContentIoC.cs @@ -3,6 +3,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; @@ -11,6 +12,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; @@ -59,6 +61,7 @@ namespace Content.Server.IoC IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); @@ -76,9 +79,11 @@ namespace Content.Server.IoC IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); - IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); + IoCManager.Register(); + IoCManager.Register(); } } } diff --git a/Content.Shared.Database/LogType.cs b/Content.Shared.Database/LogType.cs index 58a41a5f7a..388c44bb28 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. /// @@ -478,4 +479,9 @@ public enum LogType /// Events relating to midi playback. /// Instrument = 103, + + /// + /// For anything relating to bug reports. + /// + BugReport = 104, } 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/Resources/Locale/en-US/HUD/game-hud.ftl b/Resources/Locale/en-US/HUD/game-hud.ftl index ea423f080a..d88403a6af 100644 --- a/Resources/Locale/en-US/HUD/game-hud.ftl +++ b/Resources/Locale/en-US/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/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/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 0000000000000000000000000000000000000000..d901996bd6ba1130b25e69c088893dbaa16199e0 GIT binary patch literal 1852 zcmV-C2gCS@P)EX>4Tx04R}tkv&MmKpe$iQ>8^J4rUN>$WWauh>AFB6^c+H)C#RSm|Xe=O$fqw6tAnc`2!4RL3r>nIQsV!TLW>v=j{EWM-sA2aAT+8>x4Xsw-Egc- zDkN7|Tmsr#XZa7O)5z5>(VsK@|=Xv>K$?$k2W=z(45uWpb(Hs(_JW z9vjf1cz*Cd_}#5toEZ0$Vo9L;#dSZ1fyge4ux=P;w2Zz8& ziL%!M-W~1i?cX!4{(b<}^>Sz{?N6})000JJOGiWi{{a600FA#$9smFU32;bRa{vGx zhX4Q_hXIe}@nrx200(qQO+^Rk0u%=cBQDOb`~Uz2I!Q!9RCwC$n{Q}Ubr{FLzqRI; zfA(SpsYU+7W|Si%Qev?%XO<9V5e7x{a){2dmr=cnO0(WX80Csy)Iv-lA`KkE`a@9| zlxQt&mb2Pg>2Pi8KE3c6-F}>N&%O7YbMIO13m5jg_nhbV{e92x&+|OznKX$>Od>9T z7N7%o45&-7pE~QK18B+0;O77%{EHr7bqf8gHrwME0p?^ufK>!z4=^tQ`}53J5LRVD zfT_UY5Q1lbr+`|Z3TOtl0A0XIU>Fzz4gvdtXMiPUx7O}G6Jq-?Ff}X7pdNS$I2%It zH{fRqN3RJKgtOLHeHJ&v#lQj5!vSD%)=vZnJOuoa;J^pWrc0V{Rni8Xb4co!bXma9 zBa+^hbf2U-lIkQ)lhi2bK}nsGMg#7fCaKqKXWl1R2%HLdihF^p3b=nhush)X-+{Rk zL4f@p@iB8=T#c z+yva5#B>D~c&;|UjTc+6)#(ZFbY_pil(5@tH)l=&;BIqwkEal%eCf4kC+4?A45~`f zMoCSQ_Db66oEr(b_PC_+bAC|Lc1g1&)yCxbw>egua=tt5oSPBCV~V7;l3FDll=QN5 zZmiU&+U~8reqbxm=v`Ms`BCF>Xf$s9-gWJ9ruB}H&c`gIT?5NqSh)H> z!*Nh6X{MxGC9SXkDhmCQ-jei+bFL?;U49C%&V2o`kVM56pdm$npaFO>B=It4+}7Df zm>P*1JL+=rC$OT7j!~=inTwzNjWMmxHwHZU)(UusD}i$!mks6U_=wl()(X1F+x$dn z+8r(m$oe)^P=JPjtZ`$SuJCfNz0Wx}P$>bd?~fxmCMtlR((2AYpyYG7I!ys)dhNap z2+$Y7F;M~P0s@svV>p82{~{ld8ZLSPszc)cTSCNHHUY-84Co9AoIcN+9|(y5qtKRt zd|_LWoZIs)fu@+pVkJ;4ho*c2ys6-Kb7A-{#*Ot6(z-R7_~75?*Y!aE^{T9 zgzN#EynSFK2Ju%dXE^?Msk6!F@TsJ2A$!1A&bgzZ0+Elvi3;$m*UrN0YM;Z&GzB=~wXe^B05?Q%O!UmX&O1~8 z0;)^2^&gaI<5J+XXX@HC1#m#0*I{b~1-Q#Ib0XDPz-n|p@VgRw3<(>gni{vLoo68}90}gr}@2r3Tej(M@WvL$mW&!U81R3|1O90^hfd1bBW~DXg zDl3ZKm8Tqbu$ZM#ssJ|i2lJHC?lP`bNe%pQ;8=m7<`2M?X$b&a;g!ti!!hGt6-R)m z{J>C%2=h}C;DrJ?6kHz=rA}CW;2TNnEr{vxew#1p3t&wOtIRnR}ydR?e&ai!<#2Ii6@U6WG?(n@(V5xB&%K5L7mbm-CT=Tf<^ITJ% zr<&xPof>!M5)Ko<_MQ3&PBjs~*Bj^Z1#9LXYzQc7%*83-L*V&<-=4Gg`{TZSXCga! z1(K9aKzBlQK(}im!e+w*0Ul$qSfYsx6QUW|4xA`r;3t61<>2VbEXX1|mKN&5OU&uI qP-Qk?pYJ7o=A7%*BqlM5a^N4Qe+CTN2#ARQ0000 + + + + + + + + + + + + + diff --git a/Resources/Textures/Interface/splat.svg.192dpi.png b/Resources/Textures/Interface/splat.svg.192dpi.png new file mode 100644 index 0000000000000000000000000000000000000000..9540031f0445426f6148d774edb4906bb4cc9810 GIT binary patch literal 2069 zcmV+w2EX>4Tx04R}tkv&MmKpe$iQ?()$hjtKA$WWau6u)rPDionYsTEpvFuC+YXws0R zxHt-~1qVMCs}3&Cx;nTDg5U>;yWphgA|?JWDYS_3;J6>}?mh0_0YbgZRI?)nsG4P@ z6LB${TM+}V=)w>}m_$rsraqfWCE+=~?&0I>U6f~epZjz4DtVIuK9P8a>4rtTPCT_~ z>74h8!>lAJ#OK7L23?T&k?XR{Z=8z`3p_JyWYTlQVPdh^#&R38lA#h$5JwbMqkJLj zvch?bvs$jQ<~{ifgL!RviR&~6k-!2Lk%9;rRcxRP3sG7%QcR?2KknflaQrE9$>iDq zBgZ@{P$4;f@IUz7tyvf!bCZH`p!3DHKZb$8F3_mi_V=-EH%pV2qvfY2?_v+DNN+{ftykfE-UH^9Lm zFjA!KHIH|9xA*q%nPz`K`UP^`_=WEk00006VoOIv03HAy03zi}T>=0A010qNS#tmY z9ftq_9ftvr{DWHV|581~32s1`dy~sld|l&_ndoA@4ZDQxgYkPo&V0v?wOsP*(3eO$>f3% zK!K!UNqr?9m9*a&(|AE!9vB5Y1MC2rIiL0etAHs${)MFYFkpRv(m}Kfxa&eteim?& z)M-7?wLL0sfEC0;JAjgQKF(x;NDBpsC0LsF@v$&zl9ROsQ`E@?u$ zaFoDJ0q^@JU}O^BB4Bv{|C;uod;##2NBEbccy?eaaNL7)U9KBox<}}D0FwgN-JX^3 zZ7NuqG{9Gmzz2bzY2dB)7<62&DqjSga)e!&CgwoTHo7=h4KT$sTO(q?{oKKQUo<3)Q(J{#Rp@Vx2D2&4ZZ+nDpvS;{-z!2a{;5pz(3PyS( z1{nu5}+!A@>}h0lCGSKJlNOAVSvRR;E73idIBFLQTz* z#uGsAII#01^>Y5J%fZ8CU#Gp>7*ijY0bGR#g;08P*0tB?=O2mVRX2=&0# zVVur=!26cLrK%iojD3K)z&ADwk3t84fnf*g3cT(CDO8S-T@{0DQfCNI0`#`+1p$+| zA<6)ccmRsjP6!z6N%%AaW5Y1dwug__RIPSKz&KCdF9c-%0M~l=B$+L}CChEL8 zK>5maDlu6$K)IXw3hH=IfbyGxVzpDU!O%H*&jQ~TC~1j@|3yhp8e>jpN%+2!MoKD@ z)F^4EF(ztJWU$k&@eMT1GxIC5IBOGuEjIn`E6zL^qDs}WRsQVoZ}fF^lbsFO*z&9s zUKuj5sYn*$dftD4GGE8PoldK=puDkBXcTG!o^p@JcIM}LR?y+|Ce}t!F5R0213Z?- zfoG3R_0I&J_bmGsD)$Y1g{R}#3@hI&Aas4p7I8WQLxH6MSLedeCtkl{9ga(-88*PI zuz{B~L`4e9_Xrw%yoX_Uh7GX6v!wGA^e?b=gSrrv?DB*=2GHNbI4Q#hsC34EHNvYi z+g`z~woY-cO`wzkeZqhgc;2gJac^%)p1W6Tm$s$B&sh#O%5!Uj(`HILUhPTKXLJer?)T0~1MkuVH&2>?o1Om4Jbel?X@DM{g6XlK`vOU@ihQn)%7r2In;_VNh)H)J!5QUYvCL-{)3)_}m}_YnMFWW8K(9n%cEOvBPC zd;P8?0q*#KgHH>67b=3wpdq^1Z+F%O#NJKzJDvq$D=*d`eB_+ht1U*GCtppP2I;P| zS8BTG>{Xk6spbsrw?w6q8g%wndb9qgwx@pqQe3SuX=^^u00000NkvXXu0mjfb}!J1 literal 0 HcmV?d00001 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