]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Add ghost role raffles (#26629)
authorno <165581243+pissdemon@users.noreply.github.com>
Tue, 7 May 2024 01:48:16 +0000 (03:48 +0200)
committerGitHub <noreply@github.com>
Tue, 7 May 2024 01:48:16 +0000 (18:48 -0700)
* Add ghost role raffles

* GRR: Fix dialogue sizing, fix merge

* GRR: Add raffle deciders (winner picker)

* GRR: Make settings prototype based with option to override

* GRR: Use Raffles folder and namespace

* GRR: DataFieldify and TimeSpanify

* GRR: Don't actually DataFieldify HashSet<ICommonSession>s

* GRR: add GetGhostRoleCount() + docs

* update engine on branch

* Ghost role raffles: docs, fix window size, cleanup, etc

* GRR: Admin UI

* GRR: Admin UI: Display initial/max/ext of selected raffle settings proto

* GRR: Make a ton of roles raffled

44 files changed:
Content.Client/Entry/EntryPoint.cs
Content.Client/UserInterface/Systems/Ghost/Controls/Roles/GhostRoleEntryButtons.xaml
Content.Client/UserInterface/Systems/Ghost/Controls/Roles/GhostRoleEntryButtons.xaml.cs
Content.Client/UserInterface/Systems/Ghost/Controls/Roles/GhostRolesEntry.xaml.cs
Content.Client/UserInterface/Systems/Ghost/Controls/Roles/GhostRolesEui.cs
Content.Client/UserInterface/Systems/Ghost/Controls/Roles/GhostRolesWindow.xaml
Content.Client/UserInterface/Systems/Ghost/Controls/Roles/GhostRolesWindow.xaml.cs
Content.Client/UserInterface/Systems/Ghost/Controls/Roles/MakeGhostRoleEui.cs
Content.Client/UserInterface/Systems/Ghost/Controls/Roles/MakeGhostRoleWindow.xaml
Content.Client/UserInterface/Systems/Ghost/Controls/Roles/MakeGhostRoleWindow.xaml.cs
Content.Server/Ghost/Roles/Components/GhostRoleComponent.cs
Content.Server/Ghost/Roles/Components/GhostRoleRaffleComponent.cs [new file with mode: 0644]
Content.Server/Ghost/Roles/GhostRoleSystem.cs
Content.Server/Ghost/Roles/MakeRaffledGhostRoleCommand.cs [new file with mode: 0644]
Content.Server/Ghost/Roles/Raffles/GhostRoleRaffleConfig.cs [new file with mode: 0644]
Content.Server/Ghost/Roles/Raffles/GhostRoleRaffleDeciderPrototype.cs [new file with mode: 0644]
Content.Server/Ghost/Roles/Raffles/IGhostRoleRaffleDecider.cs [new file with mode: 0644]
Content.Server/Ghost/Roles/Raffles/RngGhostRoleRaffleDecider.cs [new file with mode: 0644]
Content.Server/Ghost/Roles/UI/GhostRolesEui.cs
Content.Shared/Ghost/Roles/GhostRolesEuiMessages.cs
Content.Shared/Ghost/Roles/Raffles/GhostRoleRaffleSettings.cs [new file with mode: 0644]
Content.Shared/Ghost/Roles/Raffles/GhostRoleRaffleSettingsPrototype.cs [new file with mode: 0644]
Resources/Locale/en-US/ghost/ghost-gui.ftl
Resources/Prototypes/Entities/Markers/Spawners/ghost_roles.yml
Resources/Prototypes/Entities/Mobs/NPCs/animals.yml
Resources/Prototypes/Entities/Mobs/NPCs/behonker.yml
Resources/Prototypes/Entities/Mobs/NPCs/carp.yml
Resources/Prototypes/Entities/Mobs/NPCs/elemental.yml
Resources/Prototypes/Entities/Mobs/NPCs/hellspawn.yml
Resources/Prototypes/Entities/Mobs/NPCs/regalrat.yml
Resources/Prototypes/Entities/Mobs/NPCs/revenant.yml
Resources/Prototypes/Entities/Mobs/NPCs/silicon.yml
Resources/Prototypes/Entities/Mobs/NPCs/slimes.yml
Resources/Prototypes/Entities/Mobs/NPCs/xeno.yml
Resources/Prototypes/Entities/Mobs/Player/dragon.yml
Resources/Prototypes/Entities/Mobs/Player/familiars.yml
Resources/Prototypes/Entities/Mobs/Player/guardian.yml
Resources/Prototypes/Entities/Mobs/Player/humanoid.yml
Resources/Prototypes/Entities/Mobs/Player/skeleton.yml
Resources/Prototypes/Entities/Objects/Consumable/Food/Baked/bread.yml
Resources/Prototypes/Entities/Objects/Consumable/Food/Baked/cake.yml
Resources/Prototypes/Entities/Objects/Devices/Syndicate_Gadgets/reinforcement_teleporter.yml
Resources/Prototypes/GhostRoleRaffles/deciders.yml [new file with mode: 0644]
Resources/Prototypes/GhostRoleRaffles/settings.yml [new file with mode: 0644]

index 47f11ee16164e0729c10c55207844866a28d796d..25490874e9da183376e62eca7efce113b0fa9329 100644 (file)
@@ -118,6 +118,7 @@ namespace Content.Client.Entry
             _prototypeManager.RegisterIgnore("wireLayout");
             _prototypeManager.RegisterIgnore("alertLevels");
             _prototypeManager.RegisterIgnore("nukeopsRole");
+            _prototypeManager.RegisterIgnore("ghostRoleRaffleDecider");
 
             _componentFactory.GenerateNetIds();
             _adminManager.Initialize();
index 92e38e35e0e4d58e9d09765978802d09861d28d2..ffde5d69f764d3e858819f733c9f327ed6f03627 100644 (file)
@@ -5,7 +5,7 @@
             Text="{Loc 'ghost-roles-window-request-role-button'}"
             StyleClasses="OpenRight"
             HorizontalAlignment="Left"
-            SetWidth="150"/>
+            SetWidth="300"/>
     <Button Name="FollowButton"
             Access="Public"
             Text="{Loc 'ghost-roles-window-follow-role-button'}"
index 68bd39a14131b02f485a5065858f0ded29c76873..8a953b76a7b9c6fb85935f9cb82b2af7948da038 100644 (file)
@@ -1,9 +1,72 @@
-using Robust.Client.AutoGenerated;
+using Content.Shared.Ghost.Roles;
+using Robust.Client.AutoGenerated;
 using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Timing;
 
 namespace Content.Client.UserInterface.Systems.Ghost.Controls.Roles;
 
 [GenerateTypedNameReferences]
 public sealed partial class GhostRoleEntryButtons : BoxContainer
 {
+    [Dependency] private readonly IGameTiming _timing = default!;
+    private readonly GhostRoleKind _ghostRoleKind;
+    private readonly uint _playerCount;
+    private readonly TimeSpan _raffleEndTime = TimeSpan.MinValue;
+
+    public GhostRoleEntryButtons(GhostRoleInfo ghostRoleInfo)
+    {
+        RobustXamlLoader.Load(this);
+        IoCManager.InjectDependencies(this);
+
+        _ghostRoleKind = ghostRoleInfo.Kind;
+        if (IsActiveRaffle(_ghostRoleKind))
+        {
+            _playerCount = ghostRoleInfo.RafflePlayerCount;
+            _raffleEndTime = ghostRoleInfo.RaffleEndTime;
+        }
+
+        UpdateRequestButton();
+    }
+
+    private void UpdateRequestButton()
+    {
+        var messageId = _ghostRoleKind switch
+        {
+            GhostRoleKind.FirstComeFirstServe => "ghost-roles-window-request-role-button",
+            GhostRoleKind.RaffleReady => "ghost-roles-window-join-raffle-button",
+            GhostRoleKind.RaffleInProgress => "ghost-roles-window-raffle-in-progress-button",
+            GhostRoleKind.RaffleJoined => "ghost-roles-window-leave-raffle-button",
+            _ => throw new ArgumentOutOfRangeException(nameof(_ghostRoleKind),
+                $"Unknown {nameof(GhostRoleKind)} '{_ghostRoleKind}'")
+        };
+
+        if (IsActiveRaffle(_ghostRoleKind))
+        {
+            var timeLeft = _timing.CurTime <= _raffleEndTime
+                ? _raffleEndTime - _timing.CurTime
+                : TimeSpan.Zero;
+
+            var timeString = $"{timeLeft.Minutes:0}:{timeLeft.Seconds:00}";
+            RequestButton.Text = Loc.GetString(messageId, ("time", timeString), ("players", _playerCount));
+        }
+        else
+        {
+            RequestButton.Text = Loc.GetString(messageId);
+        }
+    }
+
+    private static bool IsActiveRaffle(GhostRoleKind kind)
+    {
+        return kind is GhostRoleKind.RaffleInProgress or GhostRoleKind.RaffleJoined;
+    }
+
+    protected override void FrameUpdate(FrameEventArgs args)
+    {
+        base.FrameUpdate(args);
+        if (IsActiveRaffle(_ghostRoleKind))
+        {
+            UpdateRequestButton();
+        }
+    }
 }
index d6a53adff2527f5a8465bdd8581b0376568dc1d3..fc53cc72ae6a8e13ed22c992caea648cf92524bd 100644 (file)
@@ -26,7 +26,7 @@ namespace Content.Client.UserInterface.Systems.Ghost.Controls.Roles
 
             foreach (var role in roles)
             {
-                var button = new GhostRoleEntryButtons();
+                var button = new GhostRoleEntryButtons(role);
                 button.RequestButton.OnPressed += _ => OnRoleSelected?.Invoke(role);
                 button.FollowButton.OnPressed += _ => OnRoleFollow?.Invoke(role);
 
index 8e72eafd97cb2069c2ebe8e04446b0e69487ad23..33358a68a4d4326da6cbfc630668a07b1de824fa 100644 (file)
@@ -20,13 +20,24 @@ namespace Content.Client.UserInterface.Systems.Ghost.Controls.Roles
         {
             _window = new GhostRolesWindow();
 
-            _window.OnRoleRequested += info =>
+            _window.OnRoleRequestButtonClicked += info =>
             {
-                if (_windowRules != null)
-                    _windowRules.Close();
+                _windowRules?.Close();
+
+                if (info.Kind == GhostRoleKind.RaffleJoined)
+                {
+                    SendMessage(new LeaveGhostRoleRaffleMessage(info.Identifier));
+                    return;
+                }
+
                 _windowRules = new GhostRoleRulesWindow(info.Rules, _ =>
                 {
-                    SendMessage(new GhostRoleTakeoverRequestMessage(info.Identifier));
+                    SendMessage(new RequestGhostRoleMessage(info.Identifier));
+
+                    // if raffle role, close rules window on request, otherwise do
+                    // old behavior of waiting for the server to close it
+                    if (info.Kind != GhostRoleKind.FirstComeFirstServe)
+                        _windowRules?.Close();
                 });
                 _windowRulesId = info.Identifier;
                 _windowRules.OnClose += () =>
@@ -38,7 +49,7 @@ namespace Content.Client.UserInterface.Systems.Ghost.Controls.Roles
 
             _window.OnRoleFollow += info =>
             {
-                SendMessage(new GhostRoleFollowRequestMessage(info.Identifier));
+                SendMessage(new FollowGhostRoleMessage(info.Identifier));
             };
 
             _window.OnClose += () =>
@@ -64,7 +75,8 @@ namespace Content.Client.UserInterface.Systems.Ghost.Controls.Roles
         {
             base.HandleState(state);
 
-            if (state is not GhostRolesEuiState ghostState) return;
+            if (state is not GhostRolesEuiState ghostState)
+                return;
             _window.ClearEntries();
 
             var entityManager = IoCManager.Resolve<IEntityManager>();
index c91269063edec09ae9c647510849db1617d37dc8..ea16a6f18a1bab586ad4f1d5efcb1d65cb2fa8a3 100644 (file)
@@ -1,7 +1,7 @@
 <DefaultWindow xmlns="https://spacestation14.io"
             Title="{Loc 'ghost-roles-window-title'}"
-            MinSize="450 400"
-            SetSize="400 500">
+            MinSize="490 400"
+            SetSize="490 500">
     <Label Name="NoRolesMessage"
            Text="{Loc 'ghost-roles-window-no-roles-available-label'}"
            VerticalAlignment="Top" />
index 547d990e76f7e47c334aad81870209a71bbc02ab..2e7c99641b71acb87a76ad6a3d87834d97ff65d9 100644 (file)
@@ -9,7 +9,7 @@ namespace Content.Client.UserInterface.Systems.Ghost.Controls.Roles
     [GenerateTypedNameReferences]
     public sealed partial class GhostRolesWindow : DefaultWindow
     {
-        public event Action<GhostRoleInfo>? OnRoleRequested;
+        public event Action<GhostRoleInfo>? OnRoleRequestButtonClicked;
         public event Action<GhostRoleInfo>? OnRoleFollow;
 
         public void ClearEntries()
@@ -23,7 +23,7 @@ namespace Content.Client.UserInterface.Systems.Ghost.Controls.Roles
             NoRolesMessage.Visible = false;
 
             var entry = new GhostRolesEntry(name, description, hasAccess, reason, roles, spriteSystem);
-            entry.OnRoleSelected += OnRoleRequested;
+            entry.OnRoleSelected += OnRoleRequestButtonClicked;
             entry.OnRoleFollow += OnRoleFollow;
             EntryContainer.AddChild(entry);
         }
index 5c5e31de03b5dff3f70da1a8f8e5367032afc716..1e24d4c84c1e6912f18a10949f103862fc164da3 100644 (file)
@@ -1,4 +1,5 @@
 using Content.Client.Eui;
+using Content.Server.Ghost.Roles.Raffles;
 using Content.Shared.Eui;
 using Content.Shared.Ghost.Roles;
 using JetBrains.Annotations;
@@ -41,7 +42,7 @@ public sealed class MakeGhostRoleEui : BaseEui
         _window.OpenCentered();
     }
 
-    private void OnMake(NetEntity entity, string name, string description, string rules, bool makeSentient)
+    private void OnMake(NetEntity entity, string name, string description, string rules, bool makeSentient, GhostRoleRaffleSettings? raffleSettings)
     {
         var session = _playerManager.LocalSession;
         if (session == null)
@@ -49,12 +50,22 @@ public sealed class MakeGhostRoleEui : BaseEui
             return;
         }
 
+        var command = raffleSettings is not null ? "makeghostroleraffled" : "makeghostrole";
+
         var makeGhostRoleCommand =
-            $"makeghostrole " +
+            $"{command} " +
             $"\"{CommandParsing.Escape(entity.ToString())}\" " +
             $"\"{CommandParsing.Escape(name)}\" " +
-            $"\"{CommandParsing.Escape(description)}\" " +
-            $"\"{CommandParsing.Escape(rules)}\"";
+            $"\"{CommandParsing.Escape(description)}\" ";
+
+        if (raffleSettings is not null)
+        {
+            makeGhostRoleCommand += $"{raffleSettings.InitialDuration} " +
+                                    $"{raffleSettings.JoinExtendsDurationBy} " +
+                                    $"{raffleSettings.MaxDuration} ";
+        }
+
+        makeGhostRoleCommand += $"\"{CommandParsing.Escape(rules)}\"";
 
         _consoleHost.ExecuteCommand(session, makeGhostRoleCommand);
 
index 1d4033eed39079c3e873c435ac6dfdeb73642371..ff8e56f8fe29cda25a94fbe15140220dfa830be7 100644 (file)
             <Label Name="MakeSentientLabel" Text="Make Sentient" />
             <CheckBox Name="MakeSentientCheckbox" />
         </BoxContainer>
+        <BoxContainer Orientation="Horizontal">
+            <Label Name="RaffleLabel" Text="Raffle Role?" />
+            <OptionButton Name="RaffleButton" />
+        </BoxContainer>
+        <BoxContainer Name="RaffleCustomSettingsContainer" Orientation="Vertical" Visible="False">
+            <BoxContainer Orientation="Horizontal">
+                <Label Name="RaffleInitialDurationLabel" Text="Initial Duration (s)" />
+                <SpinBox Name="RaffleInitialDuration" HorizontalExpand="True" />
+            </BoxContainer>
+            <BoxContainer Orientation="Horizontal">
+                <Label Name="RaffleJoinExtendsDurationByLabel" Text="Joins Extend By (s)" />
+                <SpinBox Name="RaffleJoinExtendsDurationBy" HorizontalExpand="True" />
+            </BoxContainer>
+            <BoxContainer Orientation="Horizontal">
+                <Label Name="RaffleMaxDurationLabel" Text="Max Duration (s)" />
+                <SpinBox Name="RaffleMaxDuration" HorizontalExpand="True" />
+            </BoxContainer>
+        </BoxContainer>
         <BoxContainer Orientation="Horizontal">
             <Button Name="MakeButton" Text="Make" />
         </BoxContainer>
index 083951097069544c2316670172194b72b80d5701..6711d76b10a0b2c5f5a511872b77e4c4648d267b 100644 (file)
@@ -1,7 +1,12 @@
-using System.Numerics;
+using System.Linq;
+using System.Numerics;
+using Content.Server.Ghost.Roles.Raffles;
+using Content.Shared.Ghost.Roles.Raffles;
 using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
 using Robust.Client.UserInterface.CustomControls;
 using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Prototypes;
 using static Robust.Client.UserInterface.Controls.BaseButton;
 
 namespace Content.Client.UserInterface.Systems.Ghost.Controls.Roles
@@ -9,10 +14,20 @@ namespace Content.Client.UserInterface.Systems.Ghost.Controls.Roles
     [GenerateTypedNameReferences]
     public sealed partial class MakeGhostRoleWindow : DefaultWindow
     {
-        public delegate void MakeRole(NetEntity uid, string name, string description, string rules, bool makeSentient);
+        [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+        private readonly List<GhostRoleRaffleSettingsPrototype> _rafflePrototypes = [];
+
+        private const int RaffleDontRaffleId = -1;
+        private const int RaffleCustomRaffleId = -2;
+        private int _raffleSettingId = RaffleDontRaffleId;
+
+        private NetEntity? Entity { get; set; }
+
+        public event MakeRole? OnMake;
 
         public MakeGhostRoleWindow()
         {
+            IoCManager.InjectDependencies(this);
             RobustXamlLoader.Load(this);
 
             MakeSentientLabel.MinSize = new Vector2(150, 0);
@@ -23,13 +38,87 @@ namespace Content.Client.UserInterface.Systems.Ghost.Controls.Roles
             RoleDescription.MinSize = new Vector2(300, 0);
             RoleRulesLabel.MinSize = new Vector2(150, 0);
             RoleRules.MinSize = new Vector2(300, 0);
+            RaffleLabel.MinSize = new Vector2(150, 0);
+            RaffleButton.MinSize = new Vector2(300, 0);
+            RaffleInitialDurationLabel.MinSize = new Vector2(150, 0);
+            RaffleInitialDuration.MinSize = new Vector2(300, 0);
+            RaffleJoinExtendsDurationByLabel.MinSize = new Vector2(150, 0);
+            RaffleJoinExtendsDurationBy.MinSize = new Vector2(270, 0);
+            RaffleMaxDurationLabel.MinSize = new Vector2(150, 0);
+            RaffleMaxDuration.MinSize = new Vector2(270, 0);
+
+            RaffleInitialDuration.OverrideValue(30);
+            RaffleJoinExtendsDurationBy.OverrideValue(5);
+            RaffleMaxDuration.OverrideValue(60);
+
+            RaffleInitialDuration.SetButtons(new List<int> { -30, -10 }, new List<int> { 10, 30 });
+            RaffleJoinExtendsDurationBy.SetButtons(new List<int> { -10, -5 }, new List<int> { 5, 10 });
+            RaffleMaxDuration.SetButtons(new List<int> { -30, -10 }, new List<int> { 10, 30 });
+
+            RaffleInitialDuration.IsValid = duration => duration > 0;
+            RaffleJoinExtendsDurationBy.IsValid = duration => duration >= 0;
+            RaffleMaxDuration.IsValid = duration => duration > 0;
+
+            RaffleInitialDuration.ValueChanged += OnRaffleDurationChanged;
+            RaffleJoinExtendsDurationBy.ValueChanged += OnRaffleDurationChanged;
+            RaffleMaxDuration.ValueChanged += OnRaffleDurationChanged;
+
 
-            MakeButton.OnPressed += OnPressed;
+            RaffleButton.AddItem("Don't raffle", RaffleDontRaffleId);
+            RaffleButton.AddItem("Custom settings", RaffleCustomRaffleId);
+
+            var raffleProtos =
+                _prototypeManager.EnumeratePrototypes<GhostRoleRaffleSettingsPrototype>();
+
+            var idx = 0;
+            foreach (var raffleProto in raffleProtos)
+            {
+                _rafflePrototypes.Add(raffleProto);
+                var s = raffleProto.Settings;
+                var label =
+                    $"{raffleProto.ID} (initial {s.InitialDuration}s, max {s.MaxDuration}s, join adds {s.JoinExtendsDurationBy}s)";
+                RaffleButton.AddItem(label, idx++);
+            }
+
+            MakeButton.OnPressed += OnMakeButtonPressed;
+            RaffleButton.OnItemSelected += OnRaffleButtonItemSelected;
         }
 
-        private NetEntity? Entity { get; set; }
+        private void OnRaffleDurationChanged(ValueChangedEventArgs args)
+        {
+            ValidateRaffleDurations();
+        }
 
-        public event MakeRole? OnMake;
+        private void ValidateRaffleDurations()
+        {
+            if (RaffleInitialDuration.Value > RaffleMaxDuration.Value)
+            {
+                MakeButton.Disabled = true;
+                MakeButton.ToolTip = "The initial duration must not exceed the maximum duration.";
+            }
+            else
+            {
+                MakeButton.Disabled = false;
+                MakeButton.ToolTip = null;
+            }
+        }
+
+        private void OnRaffleButtonItemSelected(OptionButton.ItemSelectedEventArgs args)
+        {
+            _raffleSettingId = args.Id;
+            args.Button.SelectId(args.Id);
+            if (args.Id != RaffleCustomRaffleId)
+            {
+                RaffleCustomSettingsContainer.Visible = false;
+                MakeButton.ToolTip = null;
+                MakeButton.Disabled = false;
+            }
+            else
+            {
+                RaffleCustomSettingsContainer.Visible = true;
+                ValidateRaffleDurations();
+            }
+        }
 
         public void SetEntity(IEntityManager entManager, NetEntity entity)
         {
@@ -38,14 +127,32 @@ namespace Content.Client.UserInterface.Systems.Ghost.Controls.Roles
             RoleEntity.Text = $"{entity}";
         }
 
-        private void OnPressed(ButtonEventArgs args)
+        private void OnMakeButtonPressed(ButtonEventArgs args)
         {
             if (Entity == null)
             {
                 return;
             }
 
-            OnMake?.Invoke(Entity.Value, RoleName.Text, RoleDescription.Text, RoleRules.Text, MakeSentientCheckbox.Pressed);
+            GhostRoleRaffleSettings? raffleSettings = null;
+
+            if (_raffleSettingId == RaffleCustomRaffleId)
+            {
+                raffleSettings = new GhostRoleRaffleSettings()
+                {
+                    InitialDuration = (uint) RaffleInitialDuration.Value,
+                    JoinExtendsDurationBy = (uint) RaffleJoinExtendsDurationBy.Value,
+                    MaxDuration = (uint) RaffleMaxDuration.Value
+                };
+            }
+            else if (_raffleSettingId != RaffleDontRaffleId)
+            {
+                raffleSettings = _rafflePrototypes[_raffleSettingId].Settings;
+            }
+
+            OnMake?.Invoke(Entity.Value, RoleName.Text, RoleDescription.Text, RoleRules.Text, MakeSentientCheckbox.Pressed, raffleSettings);
         }
+
+        public delegate void MakeRole(NetEntity uid, string name, string description, string rules, bool makeSentient, GhostRoleRaffleSettings? settings);
     }
 }
index abb26a8c8bc1586dc87c89c0e639940f16d4e1b7..ccd460a9cfc60d81f5fbf860348af000b5f5c77f 100644 (file)
@@ -1,4 +1,5 @@
-using Content.Server.Mind.Commands;
+using Content.Server.Ghost.Roles.Raffles;
+using Content.Server.Mind.Commands;
 using Content.Shared.Roles;
 
 namespace Content.Server.Ghost.Roles.Components
@@ -87,5 +88,12 @@ namespace Content.Server.Ghost.Roles.Components
         [ViewVariables(VVAccess.ReadWrite)]
         [DataField("reregister")]
         public bool ReregisterOnGhost { get; set; } = true;
+
+        /// <summary>
+        /// If set, ghost role is raffled, otherwise it is first-come-first-serve.
+        /// </summary>
+        [DataField("raffle")]
+        [Access(typeof(GhostRoleSystem), Other = AccessPermissions.ReadWriteExecute)] // FIXME Friends
+        public GhostRoleRaffleConfig? RaffleConfig { get; set; }
     }
 }
diff --git a/Content.Server/Ghost/Roles/Components/GhostRoleRaffleComponent.cs b/Content.Server/Ghost/Roles/Components/GhostRoleRaffleComponent.cs
new file mode 100644 (file)
index 0000000..ac51868
--- /dev/null
@@ -0,0 +1,58 @@
+using Content.Server.Ghost.Roles.Raffles;
+using Robust.Shared.Player;
+
+namespace Content.Server.Ghost.Roles.Components;
+
+/// <summary>
+/// Indicates that a ghost role is currently being raffled, and stores data about the raffle in progress.
+/// Raffles start when the first player joins a raffle.
+/// </summary>
+[RegisterComponent]
+[Access(typeof(GhostRoleSystem))]
+public sealed partial class GhostRoleRaffleComponent : Component
+{
+    /// <summary>
+    /// Identifier of the <see cref="GhostRoleComponent">Ghost Role</see> this raffle is for.
+    /// </summary>
+    [ViewVariables(VVAccess.ReadOnly)]
+    [DataField]
+    public uint Identifier { get; set; }
+
+    /// <summary>
+    /// List of sessions that are currently in the raffle.
+    /// </summary>
+    [ViewVariables(VVAccess.ReadOnly)]
+    public HashSet<ICommonSession> CurrentMembers = [];
+
+    /// <summary>
+    /// List of sessions that are currently or were previously in the raffle.
+    /// </summary>
+    [ViewVariables(VVAccess.ReadOnly)]
+    public HashSet<ICommonSession> AllMembers = [];
+
+    /// <summary>
+    /// Time left in the raffle in seconds. This must be initialized to a positive value.
+    /// </summary>
+    [ViewVariables(VVAccess.ReadOnly)]
+    [DataField]
+    public TimeSpan Countdown = TimeSpan.MaxValue;
+
+    /// <summary>
+    /// The cumulative time, i.e. how much time the raffle will take in total. Added to when the time is extended
+    /// by someone joining the raffle.
+    /// Must be set to the same value as <see cref="Countdown"/> on initialization.
+    /// </summary>
+    [ViewVariables(VVAccess.ReadOnly)]
+    [DataField("cumulativeTime")]
+    public TimeSpan CumulativeTime = TimeSpan.MaxValue;
+
+    /// <inheritdoc cref="GhostRoleRaffleSettings.JoinExtendsDurationBy"/>
+    [ViewVariables(VVAccess.ReadOnly)]
+    [DataField("joinExtendsDurationBy")]
+    public TimeSpan JoinExtendsDurationBy { get; set; }
+
+    /// <inheritdoc cref="GhostRoleRaffleSettings.MaxDuration"/>
+    [ViewVariables(VVAccess.ReadOnly)]
+    [DataField("maxDuration")]
+    public TimeSpan MaxDuration { get; set; }
+}
index e7495020c80104b27a45e6ec9eeb98f8540d7d98..5b9d6ae1adfe66073607a4210b0f86afb0bc4dec 100644 (file)
@@ -1,7 +1,10 @@
+using System.Linq;
 using Content.Server.Administration.Logs;
 using Content.Server.EUI;
 using Content.Server.Ghost.Roles.Components;
 using Content.Server.Ghost.Roles.Events;
+using Content.Server.Ghost.Roles.Raffles;
+using Content.Shared.Ghost.Roles.Raffles;
 using Content.Server.Ghost.Roles.UI;
 using Content.Server.Mind.Commands;
 using Content.Shared.Administration;
@@ -21,7 +24,9 @@ using Robust.Server.Player;
 using Robust.Shared.Console;
 using Robust.Shared.Enums;
 using Robust.Shared.Player;
+using Robust.Shared.Prototypes;
 using Robust.Shared.Random;
+using Robust.Shared.Timing;
 using Robust.Shared.Utility;
 using Content.Server.Popups;
 using Content.Shared.Verbs;
@@ -41,12 +46,16 @@ namespace Content.Server.Ghost.Roles
         [Dependency] private readonly TransformSystem _transform = default!;
         [Dependency] private readonly SharedMindSystem _mindSystem = default!;
         [Dependency] private readonly SharedRoleSystem _roleSystem = default!;
+        [Dependency] private readonly IGameTiming _timing = default!;
         [Dependency] private readonly PopupSystem _popupSystem = default!;
         [Dependency] private readonly IPrototypeManager _prototype = default!;
 
         private uint _nextRoleIdentifier;
         private bool _needsUpdateGhostRoleCount = true;
+
         private readonly Dictionary<uint, Entity<GhostRoleComponent>> _ghostRoles = new();
+        private readonly Dictionary<uint, Entity<GhostRoleRaffleComponent>> _ghostRoleRaffles = new();
+
         private readonly Dictionary<ICommonSession, GhostRolesEui> _openUis = new();
         private readonly Dictionary<ICommonSession, MakeGhostRoleEui> _openMakeGhostRoleUis = new();
 
@@ -63,10 +72,12 @@ namespace Content.Server.Ghost.Roles
             SubscribeLocalEvent<GhostTakeoverAvailableComponent, MindRemovedMessage>(OnMindRemoved);
             SubscribeLocalEvent<GhostTakeoverAvailableComponent, MobStateChangedEvent>(OnMobStateChanged);
             SubscribeLocalEvent<GhostRoleComponent, MapInitEvent>(OnMapInit);
-            SubscribeLocalEvent<GhostRoleComponent, ComponentStartup>(OnStartup);
-            SubscribeLocalEvent<GhostRoleComponent, ComponentShutdown>(OnShutdown);
+            SubscribeLocalEvent<GhostRoleComponent, ComponentStartup>(OnRoleStartup);
+            SubscribeLocalEvent<GhostRoleComponent, ComponentShutdown>(OnRoleShutdown);
             SubscribeLocalEvent<GhostRoleComponent, EntityPausedEvent>(OnPaused);
             SubscribeLocalEvent<GhostRoleComponent, EntityUnpausedEvent>(OnUnpaused);
+            SubscribeLocalEvent<GhostRoleRaffleComponent, ComponentInit>(OnRaffleInit);
+            SubscribeLocalEvent<GhostRoleRaffleComponent, ComponentShutdown>(OnRaffleShutdown);
             SubscribeLocalEvent<GhostRoleMobSpawnerComponent, TakeGhostRoleEvent>(OnSpawnerTakeRole);
             SubscribeLocalEvent<GhostTakeoverAvailableComponent, TakeGhostRoleEvent>(OnTakeoverTakeRole);
             SubscribeLocalEvent<GhostRoleMobSpawnerComponent, GetVerbsEvent<Verb>>(OnVerb);
@@ -165,15 +176,116 @@ namespace Content.Server.Ghost.Roles
         public override void Update(float frameTime)
         {
             base.Update(frameTime);
-            if (_needsUpdateGhostRoleCount)
+
+            UpdateGhostRoleCount();
+            UpdateRaffles(frameTime);
+        }
+
+        /// <summary>
+        /// Handles sending count update for the ghost role button in ghost UI, if ghost role count changed.
+        /// </summary>
+        private void UpdateGhostRoleCount()
+        {
+            if (!_needsUpdateGhostRoleCount)
+                return;
+
+            _needsUpdateGhostRoleCount = false;
+            var response = new GhostUpdateGhostRoleCountEvent(GetGhostRoleCount());
+            foreach (var player in _playerManager.Sessions)
             {
-                _needsUpdateGhostRoleCount = false;
-                var response = new GhostUpdateGhostRoleCountEvent(GetGhostRolesInfo().Length);
-                foreach (var player in _playerManager.Sessions)
+                RaiseNetworkEvent(response, player.Channel);
+            }
+        }
+
+        /// <summary>
+        /// Handles ghost role raffle logic.
+        /// </summary>
+        private void UpdateRaffles(float frameTime)
+        {
+            var query = EntityQueryEnumerator<GhostRoleRaffleComponent, MetaDataComponent>();
+            while (query.MoveNext(out var entityUid, out var raffle, out var meta))
+            {
+                if (meta.EntityPaused)
+                    continue;
+
+                // if all participants leave/were removed from the raffle, the raffle is canceled.
+                if (raffle.CurrentMembers.Count == 0)
                 {
-                    RaiseNetworkEvent(response, player.Channel);
+                    RemoveRaffleAndUpdateEui(entityUid, raffle);
+                    continue;
                 }
+
+                raffle.Countdown = raffle.Countdown.Subtract(TimeSpan.FromSeconds(frameTime));
+                if (raffle.Countdown.Ticks > 0)
+                    continue;
+
+                // the raffle is over! find someone to take over the ghost role
+                if (!TryComp(entityUid, out GhostRoleComponent? ghostRole))
+                {
+                    Log.Warning($"Ghost role raffle finished on {entityUid} but {nameof(GhostRoleComponent)} is missing");
+                    RemoveRaffleAndUpdateEui(entityUid, raffle);
+                    continue;
+                }
+
+                if (ghostRole.RaffleConfig is null)
+                {
+                    Log.Warning($"Ghost role raffle finished on {entityUid} but RaffleConfig became null");
+                    RemoveRaffleAndUpdateEui(entityUid, raffle);
+                    continue;
+                }
+
+                var foundWinner = false;
+                var deciderPrototype = _prototype.Index(ghostRole.RaffleConfig.Decider);
+
+                // use the ghost role's chosen winner picker to find a winner
+                deciderPrototype.Decider.PickWinner(
+                    raffle.CurrentMembers.AsEnumerable(),
+                    session =>
+                    {
+                        var success = TryTakeover(session, raffle.Identifier);
+                        foundWinner |= success;
+                        return success;
+                    }
+                );
+
+                if (!foundWinner)
+                {
+                    Log.Warning($"Ghost role raffle for {entityUid} ({ghostRole.RoleName}) finished without " +
+                                $"{ghostRole.RaffleConfig?.Decider} finding a winner");
+                }
+
+                // raffle over
+                RemoveRaffleAndUpdateEui(entityUid, raffle);
+            }
+        }
+
+        private bool TryTakeover(ICommonSession player, uint identifier)
+        {
+            // TODO: the following two checks are kind of redundant since they should already be removed
+            //           from the raffle
+            // can't win if you are disconnected (although you shouldn't be a candidate anyway)
+            if (player.Status != SessionStatus.InGame)
+                return false;
+
+            // can't win if you are no longer a ghost (e.g. if you returned to your body)
+            if (player.AttachedEntity == null || !HasComp<GhostComponent>(player.AttachedEntity))
+                return false;
+
+            if (Takeover(player, identifier))
+            {
+                // takeover successful, we have a winner! remove the winner from other raffles they might be in
+                LeaveAllRaffles(player);
+                return true;
             }
+
+            return false;
+        }
+
+        private void RemoveRaffleAndUpdateEui(EntityUid entityUid, GhostRoleRaffleComponent raffle)
+        {
+            _ghostRoleRaffles.Remove(raffle.Identifier);
+            RemComp(entityUid, raffle);
+            UpdateAllEui();
         }
 
         private void PlayerStatusChanged(object? blah, SessionStatusEventArgs args)
@@ -183,6 +295,11 @@ namespace Content.Server.Ghost.Roles
                 var response = new GhostUpdateGhostRoleCountEvent(_ghostRoles.Count);
                 RaiseNetworkEvent(response, args.Session.Channel);
             }
+            else
+            {
+                // people who disconnect are removed from ghost role raffles
+                LeaveAllRaffles(args.Session);
+            }
         }
 
         public void RegisterGhostRole(Entity<GhostRoleComponent> role)
@@ -201,24 +318,170 @@ namespace Content.Server.Ghost.Roles
                 return;
 
             _ghostRoles.Remove(comp.Identifier);
+            if (TryComp(role.Owner, out GhostRoleRaffleComponent? raffle))
+            {
+                // if a raffle is still running, get rid of it
+                RemoveRaffleAndUpdateEui(role.Owner, raffle);
+            }
+            else
+            {
+                UpdateAllEui();
+            }
+        }
+
+        // probably fine to be init because it's never added during entity initialization, but much later
+        private void OnRaffleInit(Entity<GhostRoleRaffleComponent> ent, ref ComponentInit args)
+        {
+            if (!TryComp(ent, out GhostRoleComponent? ghostRole))
+            {
+                // can't have a raffle for a ghost role that doesn't exist
+                RemComp<GhostRoleRaffleComponent>(ent);
+                return;
+            }
+
+            var config = ghostRole.RaffleConfig;
+            if (config is null)
+                return; // should, realistically, never be reached but you never know
+
+            var settings = config.SettingsOverride
+                           ?? _prototype.Index<GhostRoleRaffleSettingsPrototype>(config.Settings).Settings;
+
+            if (settings.MaxDuration < settings.InitialDuration)
+            {
+                Log.Error($"Ghost role on {ent} has invalid raffle settings (max duration shorter than initial)");
+                ghostRole.RaffleConfig = null; // make it a non-raffle role so stuff isn't entirely broken
+                RemComp<GhostRoleRaffleComponent>(ent);
+                return;
+            }
+
+            var raffle = ent.Comp;
+            raffle.Identifier = ghostRole.Identifier;
+            raffle.Countdown = TimeSpan.FromSeconds(settings.InitialDuration);
+            raffle.CumulativeTime = TimeSpan.FromSeconds(settings.InitialDuration);
+            // we copy these settings into the component because they would be cumbersome to access otherwise
+            raffle.JoinExtendsDurationBy = TimeSpan.FromSeconds(settings.JoinExtendsDurationBy);
+            raffle.MaxDuration = TimeSpan.FromSeconds(settings.MaxDuration);
+        }
+
+        private void OnRaffleShutdown(Entity<GhostRoleRaffleComponent> ent, ref ComponentShutdown args)
+        {
+            _ghostRoleRaffles.Remove(ent.Comp.Identifier);
+        }
+
+        /// <summary>
+        /// Joins the given player onto a ghost role raffle, or creates it if it doesn't exist.
+        /// </summary>
+        /// <param name="player">The player.</param>
+        /// <param name="identifier">The ID that represents the ghost role or ghost role raffle.
+        /// (A raffle will have the same ID as the ghost role it's for.)</param>
+        private void JoinRaffle(ICommonSession player, uint identifier)
+        {
+            if (!_ghostRoles.TryGetValue(identifier, out var roleEnt))
+                return;
+
+            // get raffle or create a new one if it doesn't exist
+            var raffle = _ghostRoleRaffles.TryGetValue(identifier, out var raffleEnt)
+                ? raffleEnt.Comp
+                : EnsureComp<GhostRoleRaffleComponent>(roleEnt.Owner);
+
+            _ghostRoleRaffles.TryAdd(identifier, (roleEnt.Owner, raffle));
+
+            if (!raffle.CurrentMembers.Add(player))
+            {
+                Log.Warning($"{player.Name} tried to join raffle for ghost role {identifier} but they are already in the raffle");
+                return;
+            }
+
+            // if this is the first time the player joins this raffle, and the player wasn't the starter of the raffle:
+            // extend the countdown, but only if doing so will not make the raffle take longer than the maximum
+            // duration
+            if (raffle.AllMembers.Add(player) && raffle.AllMembers.Count > 1
+                && raffle.CumulativeTime.Add(raffle.JoinExtendsDurationBy) <= raffle.MaxDuration)
+            {
+                    raffle.Countdown += raffle.JoinExtendsDurationBy;
+                    raffle.CumulativeTime += raffle.JoinExtendsDurationBy;
+            }
+
             UpdateAllEui();
         }
 
-        public void Takeover(ICommonSession player, uint identifier)
+        /// <summary>
+        /// Makes the given player leave the raffle corresponding to the given ID.
+        /// </summary>
+        public void LeaveRaffle(ICommonSession player, uint identifier)
         {
-            if (!_ghostRoles.TryGetValue(identifier, out var role))
+            if (!_ghostRoleRaffles.TryGetValue(identifier, out var raffleEnt))
                 return;
 
+            if (raffleEnt.Comp.CurrentMembers.Remove(player))
+            {
+                UpdateAllEui();
+            }
+            else
+            {
+                Log.Warning($"{player.Name} tried to leave raffle for ghost role {identifier} but they are not in the raffle");
+            }
+
+            // (raffle ending because all players left is handled in update())
+        }
+
+        /// <summary>
+        /// Makes the given player leave all ghost role raffles.
+        /// </summary>
+        public void LeaveAllRaffles(ICommonSession player)
+        {
+            var shouldUpdateEui = false;
+
+            foreach (var raffleEnt in _ghostRoleRaffles.Values)
+            {
+                shouldUpdateEui |= raffleEnt.Comp.CurrentMembers.Remove(player);
+            }
+
+            if (shouldUpdateEui)
+                UpdateAllEui();
+        }
+
+        /// <summary>
+        /// Request a ghost role. If it's a raffled role starts or joins a raffle, otherwise the player immediately
+        /// takes over the ghost role if possible.
+        /// </summary>
+        /// <param name="player">The player.</param>
+        /// <param name="identifier">ID of the ghost role.</param>
+        public void Request(ICommonSession player, uint identifier)
+        {
+            if (!_ghostRoles.TryGetValue(identifier, out var roleEnt))
+                return;
+
+            if (roleEnt.Comp.RaffleConfig is not null)
+            {
+                JoinRaffle(player, identifier);
+            }
+            else
+            {
+                Takeover(player, identifier);
+            }
+        }
+
+        /// <summary>
+        /// Attempts having the player take over the ghost role with the corresponding ID. Does not start a raffle.
+        /// </summary>
+        /// <returns>True if takeover was successful, otherwise false.</returns>
+        public bool Takeover(ICommonSession player, uint identifier)
+        {
+            if (!_ghostRoles.TryGetValue(identifier, out var role))
+                return false;
+
             var ev = new TakeGhostRoleEvent(player);
             RaiseLocalEvent(role, ref ev);
 
             if (!ev.TookRole)
-                return;
+                return false;
 
             if (player.AttachedEntity != null)
                 _adminLogger.Add(LogType.GhostRoleTaken, LogImpact.Low, $"{player:player} took the {role.Comp.RoleName:roleName} ghost role {ToPrettyString(player.AttachedEntity.Value):entity}");
 
             CloseEui(player);
+            return true;
         }
 
         public void Follow(ICommonSession player, uint identifier)
@@ -247,7 +510,22 @@ namespace Content.Server.Ghost.Roles
             _mindSystem.TransferTo(newMind, mob);
         }
 
-        public GhostRoleInfo[] GetGhostRolesInfo()
+        /// <summary>
+        /// Returns the number of available ghost roles.
+        /// </summary>
+        public int GetGhostRoleCount()
+        {
+            var metaQuery = GetEntityQuery<MetaDataComponent>();
+            return _ghostRoles.Count(pair => metaQuery.GetComponent(pair.Value.Owner).EntityPaused == false);
+        }
+
+        /// <summary>
+        /// Returns information about all available ghost roles.
+        /// </summary>
+        /// <param name="player">
+        /// If not null, the <see cref="GhostRoleInfo"/>s will show if the given player is in a raffle.
+        /// </param>
+        public GhostRoleInfo[] GetGhostRolesInfo(ICommonSession? player)
         {
             var roles = new List<GhostRoleInfo>();
             var metaQuery = GetEntityQuery<MetaDataComponent>();
@@ -257,7 +535,40 @@ namespace Content.Server.Ghost.Roles
                 if (metaQuery.GetComponent(uid).EntityPaused)
                     continue;
 
-                roles.Add(new GhostRoleInfo { Identifier = id, Name = role.RoleName, Description = role.RoleDescription, Rules = role.RoleRules, Requirements = role.Requirements });
+
+                var kind = GhostRoleKind.FirstComeFirstServe;
+                GhostRoleRaffleComponent? raffle = null;
+
+                if (role.RaffleConfig is not null)
+                {
+                    kind = GhostRoleKind.RaffleReady;
+
+                    if (_ghostRoleRaffles.TryGetValue(id, out var raffleEnt))
+                    {
+                        kind = GhostRoleKind.RaffleInProgress;
+                        raffle = raffleEnt.Comp;
+
+                        if (player is not null && raffle.CurrentMembers.Contains(player))
+                            kind = GhostRoleKind.RaffleJoined;
+                    }
+                }
+
+                var rafflePlayerCount = (uint?) raffle?.CurrentMembers.Count ?? 0;
+                var raffleEndTime = raffle is not null
+                    ? _timing.CurTime.Add(raffle.Countdown)
+                    : TimeSpan.MinValue;
+
+                roles.Add(new GhostRoleInfo
+                {
+                    Identifier = id,
+                    Name = role.RoleName,
+                    Description = role.RoleDescription,
+                    Rules = role.RoleRules,
+                    Requirements = role.Requirements,
+                    Kind = kind,
+                    RafflePlayerCount = rafflePlayerCount,
+                    RaffleEndTime = raffleEndTime
+                });
             }
 
             return roles.ToArray();
@@ -272,6 +583,10 @@ namespace Content.Server.Ghost.Roles
             if (HasComp<GhostComponent>(message.Entity))
                 return;
 
+            // The player is not a ghost (anymore), so they should not be in any raffles. Remove them.
+            // This ensures player doesn't win a raffle after returning to their (revived) body and ends up being
+            // forced into a ghost role.
+            LeaveAllRaffles(message.Player);
             CloseEui(message.Player);
         }
 
@@ -306,6 +621,7 @@ namespace Content.Server.Ghost.Roles
 
             _openUis.Clear();
             _ghostRoles.Clear();
+            _ghostRoleRaffles.Clear();
             _nextRoleIdentifier = 0;
         }
 
@@ -331,12 +647,12 @@ namespace Content.Server.Ghost.Roles
                 RemCompDeferred<GhostRoleComponent>(ent);
         }
 
-        private void OnStartup(Entity<GhostRoleComponent> ent, ref ComponentStartup args)
+        private void OnRoleStartup(Entity<GhostRoleComponent> ent, ref ComponentStartup args)
         {
             RegisterGhostRole(ent);
         }
 
-        private void OnShutdown(Entity<GhostRoleComponent> role, ref ComponentShutdown args)
+        private void OnRoleShutdown(Entity<GhostRoleComponent> role, ref ComponentShutdown args)
         {
             UnregisterGhostRole(role);
         }
diff --git a/Content.Server/Ghost/Roles/MakeRaffledGhostRoleCommand.cs b/Content.Server/Ghost/Roles/MakeRaffledGhostRoleCommand.cs
new file mode 100644 (file)
index 0000000..5f5eabd
--- /dev/null
@@ -0,0 +1,127 @@
+using System.Linq;
+using Content.Server.Administration;
+using Content.Server.Ghost.Roles.Components;
+using Content.Server.Ghost.Roles.Raffles;
+using Content.Shared.Administration;
+using Content.Shared.Ghost.Roles.Raffles;
+using Content.Shared.Mind.Components;
+using Robust.Shared.Console;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Ghost.Roles
+{
+    [AdminCommand(AdminFlags.Admin)]
+    public sealed class MakeRaffledGhostRoleCommand : IConsoleCommand
+    {
+        [Dependency] private readonly IPrototypeManager _protoManager = default!;
+        [Dependency] private readonly IEntityManager _entManager = default!;
+
+        public string Command => "makeghostroleraffled";
+        public string Description => "Turns an entity into a raffled ghost role.";
+        public string Help => $"Usage: {Command} <entity uid> <name> <description> (<settings prototype> | <initial duration> <extend by> <max duration>) [<rules>]\n" +
+                              $"Durations are in seconds.";
+
+        public void Execute(IConsoleShell shell, string argStr, string[] args)
+        {
+            if (args.Length is < 4 or > 7)
+            {
+                shell.WriteLine($"Invalid amount of arguments.\n{Help}");
+                return;
+            }
+
+            if (!NetEntity.TryParse(args[0], out var uidNet) || !_entManager.TryGetEntity(uidNet, out var uid))
+            {
+                shell.WriteLine($"{args[0]} is not a valid entity uid.");
+                return;
+            }
+
+            if (!_entManager.TryGetComponent(uid, out MetaDataComponent? metaData))
+            {
+                shell.WriteLine($"No entity found with uid {uid}");
+                return;
+            }
+
+            if (_entManager.TryGetComponent(uid, out MindContainerComponent? mind) &&
+                mind.HasMind)
+            {
+                shell.WriteLine($"Entity {metaData.EntityName} with id {uid} already has a mind.");
+                return;
+            }
+
+            if (_entManager.TryGetComponent(uid, out GhostRoleComponent? ghostRole))
+            {
+                shell.WriteLine($"Entity {metaData.EntityName} with id {uid} already has a {nameof(GhostRoleComponent)}");
+                return;
+            }
+
+            if (_entManager.HasComponent<GhostTakeoverAvailableComponent>(uid))
+            {
+                shell.WriteLine($"Entity {metaData.EntityName} with id {uid} already has a {nameof(GhostTakeoverAvailableComponent)}");
+                return;
+            }
+
+            var name = args[1];
+            var description = args[2];
+
+            // if the rules are specified then use those, otherwise use the default
+            var rules = args.Length switch
+            {
+                5 => args[4],
+                7 => args[6],
+                _ => Loc.GetString("ghost-role-component-default-rules"),
+            };
+
+            // is it an invocation with a prototype ID and optional rules?
+            var isProto = args.Length is 4 or 5;
+            GhostRoleRaffleSettings settings;
+
+            if (isProto)
+            {
+                if (!_protoManager.TryIndex<GhostRoleRaffleSettingsPrototype>(args[4], out var proto))
+                {
+                    var validProtos = string.Join(", ",
+                        _protoManager.EnumeratePrototypes<GhostRoleRaffleSettingsPrototype>().Select(p => p.ID)
+                    );
+
+                    shell.WriteLine($"{args[4]} is not a valid raffle settings prototype. Valid options: {validProtos}");
+                    return;
+                }
+
+                settings = proto.Settings;
+            }
+            else
+            {
+                if (!uint.TryParse(args[3], out var initial)
+                    || !uint.TryParse(args[4], out var extends)
+                    || !uint.TryParse(args[5], out var max)
+                    || initial == 0 || max == 0)
+                {
+                    shell.WriteLine($"The raffle initial/extends/max settings must be positive numbers.");
+                    return;
+                }
+
+                if (initial > max)
+                {
+                    shell.WriteLine("The initial duration must be smaller than or equal to the maximum duration.");
+                    return;
+                }
+
+                settings = new GhostRoleRaffleSettings()
+                {
+                    InitialDuration = initial,
+                    JoinExtendsDurationBy = extends,
+                    MaxDuration = max
+                };
+            }
+
+            ghostRole = _entManager.AddComponent<GhostRoleComponent>(uid.Value);
+            _entManager.AddComponent<GhostTakeoverAvailableComponent>(uid.Value);
+            ghostRole.RoleName = name;
+            ghostRole.RoleDescription = description;
+            ghostRole.RoleRules = rules;
+            ghostRole.RaffleConfig = new GhostRoleRaffleConfig(settings);
+
+            shell.WriteLine($"Made entity {metaData.EntityName} a raffled ghost role.");
+        }
+    }
+}
diff --git a/Content.Server/Ghost/Roles/Raffles/GhostRoleRaffleConfig.cs b/Content.Server/Ghost/Roles/Raffles/GhostRoleRaffleConfig.cs
new file mode 100644 (file)
index 0000000..052704d
--- /dev/null
@@ -0,0 +1,35 @@
+using Content.Shared.Ghost.Roles.Raffles;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Ghost.Roles.Raffles;
+
+/// <summary>
+/// Raffle configuration.
+/// </summary>
+[DataDefinition]
+public sealed partial class GhostRoleRaffleConfig
+{
+    public GhostRoleRaffleConfig(GhostRoleRaffleSettings settings)
+    {
+        SettingsOverride = settings;
+    }
+
+    /// <summary>
+    /// Specifies the raffle settings to use.
+    /// </summary>
+    [DataField("settings", required: true)]
+    public ProtoId<GhostRoleRaffleSettingsPrototype> Settings { get; set; } = "default";
+
+    /// <summary>
+    /// If not null, the settings from <see cref="Settings"/> are ignored and these settings are used instead.
+    /// Intended for allowing admins to set custom raffle settings for admeme ghost roles.
+    /// </summary>
+    [ViewVariables(VVAccess.ReadOnly)]
+    public GhostRoleRaffleSettings? SettingsOverride { get; set; }
+
+    /// <summary>
+    /// Sets which <see cref="IGhostRoleRaffleDecider"/> is used.
+    /// </summary>
+    [DataField("decider")]
+    public ProtoId<GhostRoleRaffleDeciderPrototype> Decider { get; set; } = "default";
+}
diff --git a/Content.Server/Ghost/Roles/Raffles/GhostRoleRaffleDeciderPrototype.cs b/Content.Server/Ghost/Roles/Raffles/GhostRoleRaffleDeciderPrototype.cs
new file mode 100644 (file)
index 0000000..b2ebf6c
--- /dev/null
@@ -0,0 +1,20 @@
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Ghost.Roles.Raffles;
+
+/// <summary>
+/// Allows getting a <see cref="IGhostRoleRaffleDecider"/> as prototype.
+/// </summary>
+[Prototype("ghostRoleRaffleDecider")]
+public sealed class GhostRoleRaffleDeciderPrototype : IPrototype
+{
+    /// <inheritdoc />
+    [IdDataField]
+    public string ID { get; private set; } = default!;
+
+    /// <summary>
+    /// The <see cref="IGhostRoleRaffleDecider"/> instance that chooses the winner of a raffle.
+    /// </summary>
+    [DataField("decider", required: true)]
+    public IGhostRoleRaffleDecider Decider { get; private set; } = new RngGhostRoleRaffleDecider();
+}
diff --git a/Content.Server/Ghost/Roles/Raffles/IGhostRoleRaffleDecider.cs b/Content.Server/Ghost/Roles/Raffles/IGhostRoleRaffleDecider.cs
new file mode 100644 (file)
index 0000000..bdd2bf0
--- /dev/null
@@ -0,0 +1,28 @@
+using Robust.Shared.Player;
+
+namespace Content.Server.Ghost.Roles.Raffles;
+
+/// <summary>
+/// Chooses a winner of a ghost role raffle.
+/// </summary>
+[ImplicitDataDefinitionForInheritors]
+public partial interface IGhostRoleRaffleDecider
+{
+    /// <summary>
+    /// Chooses a winner of a ghost role raffle draw from the given pool of candidates.
+    /// </summary>
+    /// <param name="candidates">The players in the session at the time of drawing.</param>
+    /// <param name="tryTakeover">
+    /// Call this with the chosen winner as argument.
+    /// <ul><li>If <c>true</c> is returned, your winner was able to take over the ghost role, and the drawing is complete.
+    /// <b>Do not call <see cref="tryTakeover"/> again after true is returned.</b></li>
+    /// <li>If <c>false</c> is returned, your winner was not able to take over the ghost role,
+    /// and you must choose another winner, and call <see cref="tryTakeover"/> with the new winner as argument.</li>
+    /// </ul>
+    ///
+    /// If <see cref="tryTakeover"/> is not called, or only returns false, the raffle will end without a winner.
+    /// Do not call <see cref="tryTakeover"/> with the same player several times.
+    /// </param>
+    void PickWinner(IEnumerable<ICommonSession> candidates, Func<ICommonSession, bool> tryTakeover);
+}
+
diff --git a/Content.Server/Ghost/Roles/Raffles/RngGhostRoleRaffleDecider.cs b/Content.Server/Ghost/Roles/Raffles/RngGhostRoleRaffleDecider.cs
new file mode 100644 (file)
index 0000000..b91d359
--- /dev/null
@@ -0,0 +1,27 @@
+using System.Linq;
+using JetBrains.Annotations;
+using Robust.Shared.Player;
+using Robust.Shared.Random;
+
+namespace Content.Server.Ghost.Roles.Raffles;
+
+/// <summary>
+/// Chooses the winner of a ghost role raffle entirely randomly, without any weighting.
+/// </summary>
+[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
+public sealed partial class RngGhostRoleRaffleDecider : IGhostRoleRaffleDecider
+{
+    public void PickWinner(IEnumerable<ICommonSession> candidates, Func<ICommonSession, bool> tryTakeover)
+    {
+        var random = IoCManager.Resolve<IRobustRandom>();
+
+        var choices = candidates.ToList();
+        random.Shuffle(choices); // shuffle the list so we can pick a lucky winner!
+
+        foreach (var candidate in choices)
+        {
+            if (tryTakeover(candidate))
+                return;
+        }
+    }
+}
index 627231db9e3a8901796f093c4b82b20605161811..fc73fc3454d2ca18b9d9ba07448855479de0cef1 100644 (file)
@@ -6,9 +6,16 @@ namespace Content.Server.Ghost.Roles.UI
 {
     public sealed class GhostRolesEui : BaseEui
     {
+        [Dependency] private readonly GhostRoleSystem _ghostRoleSystem;
+
+        public GhostRolesEui()
+        {
+            _ghostRoleSystem = IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<GhostRoleSystem>();
+        }
+
         public override GhostRolesEuiState GetNewState()
         {
-            return new(EntitySystem.Get<GhostRoleSystem>().GetGhostRolesInfo());
+            return new(_ghostRoleSystem.GetGhostRolesInfo(Player));
         }
 
         public override void HandleMessage(EuiMessageBase msg)
@@ -17,11 +24,14 @@ namespace Content.Server.Ghost.Roles.UI
 
             switch (msg)
             {
-                case GhostRoleTakeoverRequestMessage req:
-                    EntitySystem.Get<GhostRoleSystem>().Takeover(Player, req.Identifier);
+                case RequestGhostRoleMessage req:
+                    _ghostRoleSystem.Request(Player, req.Identifier);
+                    break;
+                case FollowGhostRoleMessage req:
+                    _ghostRoleSystem.Follow(Player, req.Identifier);
                     break;
-                case GhostRoleFollowRequestMessage req:
-                    EntitySystem.Get<GhostRoleSystem>().Follow(Player, req.Identifier);
+                case LeaveGhostRoleRaffleMessage req:
+                    _ghostRoleSystem.LeaveRaffle(Player, req.Identifier);
                     break;
             }
         }
index 8fbb931ca95873a28dbde7ec6548a7492481b00d..b7457538ebe0dc96388dc950db261a71ad786620 100644 (file)
@@ -12,6 +12,21 @@ namespace Content.Shared.Ghost.Roles
         public string Description { get; set; }
         public string Rules { get; set; }
         public HashSet<JobRequirement>? Requirements { get; set; }
+
+        /// <inheritdoc cref="GhostRoleKind"/>
+        public GhostRoleKind Kind { get; set; }
+
+        /// <summary>
+        /// if <see cref="Kind"/> is <see cref="GhostRoleKind.RaffleInProgress"/>, specifies how many players are currently
+        /// in the raffle for this role.
+        /// </summary>
+        public uint RafflePlayerCount { get; set; }
+
+        /// <summary>
+        /// if <see cref="Kind"/> is <see cref="GhostRoleKind.RaffleInProgress"/>, specifies when raffle finishes.
+        /// </summary>
+        public TimeSpan RaffleEndTime { get; set; }
+
     }
 
     [NetSerializable, Serializable]
@@ -26,24 +41,62 @@ namespace Content.Shared.Ghost.Roles
     }
 
     [NetSerializable, Serializable]
-    public sealed class GhostRoleTakeoverRequestMessage : EuiMessageBase
+    public sealed class RequestGhostRoleMessage : EuiMessageBase
+    {
+        public uint Identifier { get; }
+
+        public RequestGhostRoleMessage(uint identifier)
+        {
+            Identifier = identifier;
+        }
+    }
+
+    [NetSerializable, Serializable]
+    public sealed class FollowGhostRoleMessage : EuiMessageBase
     {
         public uint Identifier { get; }
 
-        public GhostRoleTakeoverRequestMessage(uint identifier)
+        public FollowGhostRoleMessage(uint identifier)
         {
             Identifier = identifier;
         }
     }
 
     [NetSerializable, Serializable]
-    public sealed class GhostRoleFollowRequestMessage : EuiMessageBase
+    public sealed class LeaveGhostRoleRaffleMessage : EuiMessageBase
     {
         public uint Identifier { get; }
 
-        public GhostRoleFollowRequestMessage(uint identifier)
+        public LeaveGhostRoleRaffleMessage(uint identifier)
         {
             Identifier = identifier;
         }
     }
+
+    /// <summary>
+    /// Determines whether a ghost role is a raffle role, and if it is, whether it's running.
+    /// </summary>
+    [NetSerializable, Serializable]
+    public enum GhostRoleKind
+    {
+        /// <summary>
+        /// Role is not a raffle role and can be taken immediately.
+        /// </summary>
+        FirstComeFirstServe,
+
+        /// <summary>
+        /// Role is a raffle role, but raffle hasn't started yet.
+        /// </summary>
+        RaffleReady,
+
+        /// <summary>
+        ///  Role is raffle role and currently being raffled, but player hasn't joined raffle.
+        /// </summary>
+        RaffleInProgress,
+
+        /// <summary>
+        /// Role is raffle role and currently being raffled, and player joined raffle.
+        /// </summary>
+        RaffleJoined
+    }
 }
diff --git a/Content.Shared/Ghost/Roles/Raffles/GhostRoleRaffleSettings.cs b/Content.Shared/Ghost/Roles/Raffles/GhostRoleRaffleSettings.cs
new file mode 100644 (file)
index 0000000..a7aa757
--- /dev/null
@@ -0,0 +1,30 @@
+namespace Content.Server.Ghost.Roles.Raffles;
+
+/// <summary>
+/// Defines settings for a ghost role raffle.
+/// </summary>
+[DataDefinition]
+public sealed partial class GhostRoleRaffleSettings
+{
+    /// <summary>
+    /// The initial duration of a raffle in seconds. This is the countdown timer's value when the raffle starts.
+    /// </summary>
+    [ViewVariables(VVAccess.ReadWrite)]
+    [DataField(required: true)]
+    public uint InitialDuration { get; set; }
+
+    /// <summary>
+    /// When the raffle is joined by a player, the countdown timer is extended by this value in seconds.
+    /// </summary>
+    [ViewVariables(VVAccess.ReadWrite)]
+    [DataField(required: true)]
+    public uint JoinExtendsDurationBy { get; set; }
+
+    /// <summary>
+    /// The maximum duration in seconds for the ghost role raffle. A raffle cannot run for longer than this
+    /// duration, even if extended by joiners. Must be greater than or equal to <see cref="InitialDuration"/>.
+    /// </summary>
+    [ViewVariables(VVAccess.ReadWrite)]
+    [DataField(required: true)]
+    public uint MaxDuration { get; set; }
+}
diff --git a/Content.Shared/Ghost/Roles/Raffles/GhostRoleRaffleSettingsPrototype.cs b/Content.Shared/Ghost/Roles/Raffles/GhostRoleRaffleSettingsPrototype.cs
new file mode 100644 (file)
index 0000000..f1447a0
--- /dev/null
@@ -0,0 +1,22 @@
+using Content.Server.Ghost.Roles.Raffles;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared.Ghost.Roles.Raffles;
+
+/// <summary>
+/// Allows specifying the settings for a ghost role raffle as a prototype.
+/// </summary>
+[Prototype("ghostRoleRaffleSettings")]
+public sealed class GhostRoleRaffleSettingsPrototype : IPrototype
+{
+    /// <inheritdoc />
+    [IdDataField]
+    public string ID { get; private set; } = default!;
+
+    /// <summary>
+    /// The settings for a ghost role raffle.
+    /// </summary>
+    /// <seealso cref="GhostRoleRaffleSettings"/>
+    [DataField(required: true)]
+    public GhostRoleRaffleSettings Settings { get; private set; } = new();
+}
index 40cd06743ebbb48b4ef67266175dda3102284931..cd6c2f027069a065b4d640fd8006b2728abeec3c 100644 (file)
@@ -13,6 +13,17 @@ ghost-target-window-current-button = Warp: {$name}
 ghost-target-window-warp-to-most-followed = Warp to Most Followed
 
 ghost-roles-window-title = Ghost Roles
+ghost-roles-window-join-raffle-button = Join raffle
+ghost-roles-window-raffle-in-progress-button =
+    Join raffle ({$time} left, { $players ->
+         [one] {$players} player
+        *[other] {$players} players
+    })
+ghost-roles-window-leave-raffle-button =
+    Leave raffle ({$time} left, { $players ->
+         [one] {$players} player
+        *[other] {$players} players
+    })
 ghost-roles-window-request-role-button = Request
 ghost-roles-window-request-role-button-timer = Request ({$time}s)
 ghost-roles-window-follow-role-button = Follow
index eb32b48e79cc8428b344523559112be715ee6829..be80f9c692cb95bb604a0294b2e72dffa169246b 100644 (file)
@@ -8,6 +8,8 @@
     name: ghost-role-information-rat-king-name
     description: ghost-role-information-rat-king-description
     rules: ghost-role-information-rat-king-rules
+    raffle:
+      settings: default
   - type: GhostRoleMobSpawner
     prototype: MobRatKing
   - type: Sprite
@@ -27,6 +29,8 @@
     name: ghost-role-information-remilia-name
     description: ghost-role-information-remilia-description
     rules: ghost-role-information-remilia-rules
+    raffle:
+      settings: short
   - type: GhostRoleMobSpawner
     prototype: MobBatRemilia
   - type: Sprite
@@ -46,6 +50,8 @@
     name: ghost-role-information-cerberus-name
     description: ghost-role-information-cerberus-description
     rules: ghost-role-information-cerberus-rules
+    raffle:
+      settings: default
   - type: GhostRoleMobSpawner
     prototype: MobCorgiCerberus
   - type: Sprite
@@ -64,6 +70,8 @@
   components:
   - type: GhostRole
     rules: ghost-role-information-nukeop-rules
+    raffle:
+      settings: default
   - type: GhostRoleMobSpawner
     prototype: MobHumanNukeOp
   - type: Sprite
     name: ghost-role-information-space-dragon-name
     description: ghost-role-information-space-dragon-description
     rules: ghost-role-component-default-rules
+    raffle:
+      settings: default
   - type: GhostRoleMobSpawner
     prototype: MobDragon
   - type: Sprite
     name: ghost-role-information-space-ninja-name
     description: ghost-role-information-space-ninja-description
     rules: ghost-role-information-space-ninja-rules
+    raffle:
+      settings: default
   - type: GhostRoleMobSpawner
     prototype: MobHumanSpaceNinja
   - type: Sprite
index 3a0b4d2858eb2b090aa6fd3ad649ba1071bd0cea..535cb734708f54dcf429d42983356fde623533fa 100644 (file)
     makeSentient: true
     name: ghost-role-information-monkey-name
     description: ghost-role-information-monkey-description
+    raffle:
+      settings: default
   - type: GhostTakeoverAvailable
   - type: Loadout
     prototypes: [SyndicateOperativeGearMonkey]
       makeSentient: true
       name: ghost-role-information-giant-spider-name
       description: ghost-role-information-giant-spider-description
+      raffle:
+        settings: short
     - type: GhostTakeoverAvailable
 
 - type: entity
     allowMovement: true
     description: ghost-role-information-SyndiCat-description
     rules: ghost-role-information-SyndiCat-rules
+    raffle:
+      settings: default
   - type: GhostTakeoverAvailable
   - type: AutoImplant
     implants:
index 6aea0e89b014d6e5d6c47e3c3f7af979c08ae315..07ee6b1536adf3506b74a2dda8bcfc0d8d5de4b4 100644 (file)
@@ -10,6 +10,8 @@
       makeSentient: true
       name: ghost-role-information-behonker-name
       description: ghost-role-information-behonker-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: HTN
       rootTask:
index 7308267473645284e7f74e5327da4005987ede84..3a77dbab4c98d74c0bb225aeb8c02e1597c968fa 100644 (file)
       makeSentient: true
       name: ghost-role-information-sentient-carp-name
       description: ghost-role-information-sentient-carp-description
+      raffle:
+        settings: short
     - type: GhostTakeoverAvailable
     - type: HTN
       rootTask:
index 58d30ccfc06a605bb2deb24a79ec2724692fa14d..a6641efe8cc6c220a391e2e0c73f1f2d429ff832 100644 (file)
   - type: GhostRole
     prob: 0
     description: ghost-role-information-angry-slimes-description
+    raffle:
+      settings: short
   - type: NpcFactionMember
     factions:
     - SimpleHostile
index 5511c40baade055eb8d404407155517edde59243..26fbe4e0734150c43dbe29ea197d860ef721642a 100644 (file)
@@ -12,6 +12,8 @@
     makeSentient: true
     name: ghost-role-information-hellspawn-name
     description: ghost-role-information-hellspawn-description
+    raffle:
+      settings: default
   - type: RotationVisuals
     defaultRotation: 90
     horizontalRotation: 90
index 1fcd074b8eb61e4baf04acc502ca2f6c5d9ddc51..aa12eac1c2dc92d61b2d8b82028d22e9b821cc45 100644 (file)
@@ -90,6 +90,8 @@
     name: ghost-role-information-rat-king-name
     description: ghost-role-information-rat-king-description
     rules: ghost-role-information-rat-king-rules
+    raffle:
+      settings: default
   - type: GhostTakeoverAvailable
   - type: Tag
     tags:
index 83d3dbd0696c107bb14add29a1392cf9e4db386a..8a56a3991633679194d07d36a2cd4a5e8e9a472d 100644 (file)
@@ -56,6 +56,8 @@
     name: ghost-role-information-revenant-name
     description: ghost-role-information-revenant-description
     rules: ghost-role-information-revenant-rules
+    raffle:
+      settings: default
   - type: GhostTakeoverAvailable
   - type: Revenant
     malfunctionWhitelist:
index 7a36046a0fd598d38791d8c90d5b6c43c98e21f8..2423082fd8de14e2455ca0c87adb17a59377c8a4 100644 (file)
     makeSentient: true
     name: ghost-role-information-honkbot-name
     description: ghost-role-information-honkbot-description
+    raffle:
+      settings: default
   - type: GhostTakeoverAvailable
   - type: InteractionPopup
     interactSuccessString: petting-success-honkbot
     makeSentient: true
     name: ghost-role-information-jonkbot-name
     description: ghost-role-information-jonkbot-description
+    raffle:
+      settings: default
   - type: InteractionPopup
     interactSuccessSound:
       path: /Audio/Items/brokenbikehorn.ogg
     makeSentient: true
     name: ghost-role-information-mimebot-name
     description: ghost-role-information-mimebot-description
+    raffle:
+      settings: default
   - type: GhostTakeoverAvailable
   - type: InteractionPopup
     interactSuccessString: petting-success-mimebot
index b3a80f90c76292d7e032d5ac8cdb43f2ce131d7d..77cf43cdbc7d1b09d63726b8e9f9828eb28ac05e 100644 (file)
     makeSentient: true
     name: ghost-role-information-slimes-name
     description: ghost-role-information-slimes-description
+    raffle:
+      settings: short
   - type: Speech
     speechVerb: Slime
     speechSounds: Slime
         - SimpleHostile
     - type: GhostRole
       description: ghost-role-information-angry-slimes-description
+      raffle:
+        settings: short
 
 - type: entity
   name: green slime
         - SimpleHostile
     - type: GhostRole
       description: ghost-role-information-angry-slimes-description
+      raffle:
+        settings: short
 
 - type: entity
   name: yellow slime
         - SimpleHostile
     - type: GhostRole
       description: ghost-role-information-angry-slimes-description
+      raffle:
+        settings: short
index bd5711f82e18f31c25fbd425e7a00f891eefb10b..de3a282eeb6e4fb6474dc42382e4b6738fa46165 100644 (file)
     name: ghost-role-information-xeno-name
     description: ghost-role-information-xeno-description
     rules: ghost-role-information-xeno-rules
+    raffle:
+      settings: default
   - type: GhostTakeoverAvailable
   - type: TypingIndicator
     proto: alien
index 0eda8ae9d76090f221cee866e9734ac62b72aba8..ee0db34fc2dc255ef9d95158a94edbb0e784c4e5 100644 (file)
@@ -14,6 +14,8 @@
     makeSentient: true
     name: ghost-role-information-space-dragon-name
     description: ghost-role-information-space-dragon-description
+    raffle:
+      settings: default
   - type: GhostTakeoverAvailable
   - type: HTN
     rootTask:
   components:
   - type: GhostRole
     description: ghost-role-information-space-dragon-dungeon-description
+    raffle:
+      settings: default
   - type: SlowOnDamage
     speedModifierThresholds:
       100: 0.7
index 63e27b8c53059c1fc7a466eb124b508a4c8375bb..b7fb7eb66c0ed1087200197a05df71463bc5daec 100644 (file)
@@ -44,6 +44,8 @@
     name: ghost-role-information-cerberus-name
     description: ghost-role-information-cerberus-description
     rules: ghost-role-information-cerberus-rules
+    raffle:
+      settings: default
   - type: GhostTakeoverAvailable
   - type: MeleeWeapon
     altDisarm: false
index 26c0ae30ec962cc876627e7e96f4e57fa9186fb2..c7b4464350e8d4211fb51b1dd2c2d8ddcf513a0d 100644 (file)
@@ -13,6 +13,8 @@
       makeSentient: true
       name: ghost-role-information-guardian-name
       description: ghost-role-information-guardian-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: Input
       context: "human"
       makeSentient: true
       name: ghost-role-information-holoparasite-name
       description: ghost-role-information-holoparasite-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: NameIdentifier
       group: Holoparasite
       makeSentient: true
       name: ghost-role-information-ifrit-name
       description: ghost-role-information-ifrit-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: RandomSprite
       available:
       makeSentient: true
       name: ghost-role-information-holoclown-name
       description: ghost-role-information-holoclown-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: NameIdentifier
       group: Holoparasite
index 20a6403ae3a3718b67325a8128cd013cb3b3a853..2435770ff2e124b82401fd85f511a1fe047d5251 100644 (file)
@@ -26,6 +26,8 @@
     - type: GhostRole
       name: ghost-role-information-Death-Squad-name
       description: ghost-role-information-Death-Squad-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: Loadout
       prototypes: [ DeathSquadGear ]
@@ -62,6 +64,8 @@
     - type: GhostRole
       name: ghost-role-information-ert-leader-name
       description: ghost-role-information-ert-leader-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: Loadout
       prototypes: [ ERTLeaderGear ]
@@ -92,6 +96,8 @@
     - type: GhostRole
       name: ghost-role-information-ert-leader-name
       description: ghost-role-information-ert-leader-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: Loadout
       prototypes: [ ERTLeaderGearEVA ]
     - type: GhostRole
       name: ghost-role-information-ert-leader-name
       description: ghost-role-information-ert-leader-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: Loadout
       prototypes: [ ERTLeaderGearEVALecter ]
     - type: GhostRole
       name: ghost-role-information-ert-chaplain-name
       description: ghost-role-information-ert-chaplain-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: RandomMetadata
       nameSegments:
     - type: GhostRole
       name: ghost-role-information-ert-chaplain-name
       description: ghost-role-information-ert-chaplain-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: Loadout
       prototypes: [ ERTChaplainGearEVA ]
     - type: GhostRole
       name: ghost-role-information-ert-janitor-name
       description: ghost-role-information-ert-janitor-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: RandomMetadata
       nameSegments:
     - type: GhostRole
       name: ghost-role-information-ert-janitor-name
       description: ghost-role-information-ert-janitor-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: Loadout
       prototypes: [ ERTJanitorGearEVA ]
     - type: GhostRole
       name: ghost-role-information-ert-engineer-name
       description: ghost-role-information-ert-engineer-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: RandomMetadata
       nameSegments:
     - type: GhostRole
       name: ghost-role-information-ert-engineer-name
       description: ghost-role-information-ert-engineer-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: Loadout
       prototypes: [ ERTEngineerGearEVA ]
     - type: GhostRole
       name: ghost-role-information-ert-security-name
       description: ghost-role-information-ert-security-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: RandomMetadata
       nameSegments:
     - type: GhostRole
       name: ghost-role-information-ert-security-name
       description: ghost-role-information-ert-security-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: Loadout
       prototypes: [ ERTSecurityGearEVA ]
     - type: GhostRole
       name: ghost-role-information-ert-security-name
       description: ghost-role-information-ert-security-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: Loadout
       prototypes: [ ERTSecurityGearEVALecter ]
     - type: GhostRole
       name: ghost-role-information-ert-medical-name
       description: ghost-role-information-ert-medical-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: RandomMetadata
       nameSegments:
     - type: GhostRole
       name: ghost-role-information-ert-medical-name
       description: ghost-role-information-ert-medical-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: Loadout
       prototypes: [ ERTMedicalGearEVA ]
     - type: GhostRole
       name: ghost-role-information-cburn-agent-name
       description: ghost-role-information-cburn-agent-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: RandomMetadata
       nameSegments:
     - type: GhostRole
       name: ghost-role-information-centcom-official-name
       description: ghost-role-information-centcom-official-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: Loadout
       prototypes: [ CentcomGear ]
     - type: GhostRole
       name: ghost-role-information-cluwne-name
       description: ghost-role-information-cluwne-description
+      raffle:
+        settings: default
     - type: GhostTakeoverAvailable
     - type: Cluwne
index f9132ce0ea02286c649ff619da0c4acfd7922610..76727181b4e8421dff9f7413b0374dbb0bff4414 100644 (file)
@@ -17,6 +17,8 @@
   - type: GhostRole
     name: ghost-role-information-skeleton-pirate-name
     description: ghost-role-information-skeleton-pirate-description
+    raffle:
+      settings: default
   - type: GhostTakeoverAvailable
   - type: Loadout
     prototypes: [PirateGear]
@@ -31,6 +33,8 @@
   - type: GhostRole
     name: ghost-role-information-skeleton-biker-name
     description: ghost-role-information-skeleton-biker-description
+    raffle:
+      settings: default
   - type: GhostTakeoverAvailable
   - type: Loadout
     prototypes: [SkeletonBiker]
@@ -44,6 +48,8 @@
   - type: GhostRole
     name: ghost-role-information-closet-skeleton-name
     description: ghost-role-information-closet-skeleton-description
+    raffle:
+      settings: default
   - type: GhostTakeoverAvailable
   - type: Loadout
     prototypes: [LimitedPassengerGear]
index 56bcfff306be86e482416f7376724b87ec4b49b9..127fc7e44b62ed3ac996a0f63dc58c6d2a267dba 100644 (file)
     allowMovement: true
     description: ghost-role-information-BreadDog-description
     rules: ghost-role-information-BreadDog-rules
+    raffle:
+      settings: short
   - type: GhostTakeoverAvailable
   - type: BarkAccent
   - type: Speech
     hidden: true
     damage:
       groups:
-        Brute: 1
\ No newline at end of file
+        Brute: 1
index 71b38959ce3e6304b44bbf8968bc9cb58589075d..cbb9ff2c45bca69d6ca4785a3bdb66b47b4b28e9 100644 (file)
     allowMovement: true
     description: ghost-role-information-Cak-description
     rules: ghost-role-information-Cak-rules
+    raffle:
+      settings: short
   - type: GhostTakeoverAvailable
   - type: OwOAccent
   - type: Speech
index 3ce39afcebbc13dc6c33bba4b06a3c86b779e4b6..c62783fcee98c33bdbb21cac1f6885e43cac797c 100644 (file)
@@ -12,6 +12,8 @@
     name: ghost-role-information-syndicate-reinforcement-name
     description: ghost-role-information-syndicate-reinforcement-description
     rules: ghost-role-information-syndicate-reinforcement-rules
+    raffle:
+      settings: default
   - type: GhostRoleMobSpawner
     prototype: MobHumanSyndicateAgent
   - type: EmitSoundOnUse
@@ -37,6 +39,8 @@
     name: ghost-role-information-syndicate-monkey-reinforcement-name
     description: ghost-role-information-syndicate-monkey-reinforcement-description
     rules: ghost-role-information-syndicate-monkey-reinforcement-rules
+    raffle:
+      settings: default
   - type: GhostRoleMobSpawner
     prototype: MobMonkeySyndicateAgent
     selectablePrototypes: ["SyndicateMonkey", "SyndicateKobold"]
@@ -61,5 +65,7 @@
       name: Syndicate Assault Cyborg
       description: Nuclear operatives needs reinforcements. You, a cold silicon killing machine, will help them.
       rules: Normal syndicate antagonist rules apply. Work with whoever called you in, and don't harm them.
+      raffle:
+        settings: default
     - type: GhostRoleMobSpawner
       prototype: PlayerBorgSyndicateAssaultBattery
diff --git a/Resources/Prototypes/GhostRoleRaffles/deciders.yml b/Resources/Prototypes/GhostRoleRaffles/deciders.yml
new file mode 100644 (file)
index 0000000..b23464c
--- /dev/null
@@ -0,0 +1,3 @@
+- type: ghostRoleRaffleDecider
+  id: default
+  decider: !type:RngGhostRoleRaffleDecider {}
diff --git a/Resources/Prototypes/GhostRoleRaffles/settings.yml b/Resources/Prototypes/GhostRoleRaffles/settings.yml
new file mode 100644 (file)
index 0000000..7ed9326
--- /dev/null
@@ -0,0 +1,15 @@
+# for important antag roles (nukie reinforcements, ninja, etc.)
+- type: ghostRoleRaffleSettings
+  id: default
+  settings:
+    initialDuration: 30
+    joinExtendsDurationBy: 10
+    maxDuration: 90
+
+# for roles that don't matter too much or are available plentifully (e.g. space carp)
+- type: ghostRoleRaffleSettings
+  id: short
+  settings:
+    initialDuration: 10
+    joinExtendsDurationBy: 5
+    maxDuration: 30