_prototypeManager.RegisterIgnore("wireLayout");
_prototypeManager.RegisterIgnore("alertLevels");
_prototypeManager.RegisterIgnore("nukeopsRole");
+ _prototypeManager.RegisterIgnore("ghostRoleRaffleDecider");
_componentFactory.GenerateNetIds();
_adminManager.Initialize();
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'}"
-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();
+ }
+ }
}
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);
{
_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 += () =>
_window.OnRoleFollow += info =>
{
- SendMessage(new GhostRoleFollowRequestMessage(info.Identifier));
+ SendMessage(new FollowGhostRoleMessage(info.Identifier));
};
_window.OnClose += () =>
{
base.HandleState(state);
- if (state is not GhostRolesEuiState ghostState) return;
+ if (state is not GhostRolesEuiState ghostState)
+ return;
_window.ClearEntries();
var entityManager = IoCManager.Resolve<IEntityManager>();
<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" />
[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()
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);
}
using Content.Client.Eui;
+using Content.Server.Ghost.Roles.Raffles;
using Content.Shared.Eui;
using Content.Shared.Ghost.Roles;
using JetBrains.Annotations;
_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)
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);
<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>
-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
[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);
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)
{
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);
}
}
-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
[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; }
}
}
--- /dev/null
+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; }
+}
+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;
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;
[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();
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);
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)
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)
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)
_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>();
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();
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);
}
_openUis.Clear();
_ghostRoles.Clear();
+ _ghostRoleRaffles.Clear();
_nextRoleIdentifier = 0;
}
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);
}
--- /dev/null
+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.");
+ }
+ }
+}
--- /dev/null
+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";
+}
--- /dev/null
+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();
+}
--- /dev/null
+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);
+}
+
--- /dev/null
+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;
+ }
+ }
+}
{
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)
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;
}
}
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]
}
[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
+ }
}
--- /dev/null
+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; }
+}
--- /dev/null
+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();
+}
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
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
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
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
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
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:
makeSentient: true
name: ghost-role-information-behonker-name
description: ghost-role-information-behonker-description
+ raffle:
+ settings: default
- type: GhostTakeoverAvailable
- type: HTN
rootTask:
makeSentient: true
name: ghost-role-information-sentient-carp-name
description: ghost-role-information-sentient-carp-description
+ raffle:
+ settings: short
- type: GhostTakeoverAvailable
- type: HTN
rootTask:
- type: GhostRole
prob: 0
description: ghost-role-information-angry-slimes-description
+ raffle:
+ settings: short
- type: NpcFactionMember
factions:
- SimpleHostile
makeSentient: true
name: ghost-role-information-hellspawn-name
description: ghost-role-information-hellspawn-description
+ raffle:
+ settings: default
- type: RotationVisuals
defaultRotation: 90
horizontalRotation: 90
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:
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:
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
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
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
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
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
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
- 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 ]
- 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 ]
- 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
- 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]
- 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]
- 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]
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
allowMovement: true
description: ghost-role-information-Cak-description
rules: ghost-role-information-Cak-rules
+ raffle:
+ settings: short
- type: GhostTakeoverAvailable
- type: OwOAccent
- type: Speech
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
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"]
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
--- /dev/null
+- type: ghostRoleRaffleDecider
+ id: default
+ decider: !type:RngGhostRoleRaffleDecider {}
--- /dev/null
+# 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