Add ghost role raffles (#26629)

* 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
This commit is contained in:
no
2024-05-07 03:48:16 +02:00
committed by GitHub
parent 6685146a1e
commit 630a7a78ed
44 changed files with 1138 additions and 51 deletions

View 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();

View 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'}"

View 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();
}
}
}

View 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);

View 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>();

View 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" />

View 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);
}

View 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);

View File

@@ -22,6 +22,24 @@
<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>

View 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);
MakeButton.OnPressed += OnPressed;
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;
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);
}
}

View 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; }
}
}

View File

@@ -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; }
}

View 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,17 +176,118 @@ 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);
}
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)
{
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)
{
if (args.NewStatus == SessionStatus.InGame)
@@ -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 (!_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;
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);
}

View File

@@ -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.");
}
}
}

View File

@@ -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";
}

View File

@@ -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();
}

View File

@@ -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);
}

View File

@@ -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;
}
}
}

View 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 GhostRoleFollowRequestMessage req:
EntitySystem.Get<GhostRoleSystem>().Follow(Player, req.Identifier);
case FollowGhostRoleMessage req:
_ghostRoleSystem.Follow(Player, req.Identifier);
break;
case LeaveGhostRoleRaffleMessage req:
_ghostRoleSystem.LeaveRaffle(Player, req.Identifier);
break;
}
}

View 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 GhostRoleTakeoverRequestMessage(uint identifier)
public RequestGhostRoleMessage(uint identifier)
{
Identifier = identifier;
}
}
[NetSerializable, Serializable]
public sealed class GhostRoleFollowRequestMessage : EuiMessageBase
public sealed class FollowGhostRoleMessage : EuiMessageBase
{
public uint Identifier { get; }
public GhostRoleFollowRequestMessage(uint identifier)
public FollowGhostRoleMessage(uint identifier)
{
Identifier = identifier;
}
}
[NetSerializable, Serializable]
public sealed class LeaveGhostRoleRaffleMessage : EuiMessageBase
{
public uint Identifier { get; }
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
}
}

View File

@@ -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; }
}

View File

@@ -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();
}

View 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

View 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
@@ -103,6 +111,8 @@
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
@@ -121,6 +131,8 @@
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

View File

@@ -1308,6 +1308,8 @@
makeSentient: true
name: ghost-role-information-monkey-name
description: ghost-role-information-monkey-description
raffle:
settings: default
- type: GhostTakeoverAvailable
- type: Loadout
prototypes: [SyndicateOperativeGearMonkey]
@@ -2292,6 +2294,8 @@
makeSentient: true
name: ghost-role-information-giant-spider-name
description: ghost-role-information-giant-spider-description
raffle:
settings: short
- type: GhostTakeoverAvailable
- type: entity
@@ -2816,6 +2820,8 @@
allowMovement: true
description: ghost-role-information-SyndiCat-description
rules: ghost-role-information-SyndiCat-rules
raffle:
settings: default
- type: GhostTakeoverAvailable
- type: AutoImplant
implants:

View 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:

View File

@@ -163,6 +163,8 @@
makeSentient: true
name: ghost-role-information-sentient-carp-name
description: ghost-role-information-sentient-carp-description
raffle:
settings: short
- type: GhostTakeoverAvailable
- type: HTN
rootTask:

View File

@@ -232,6 +232,8 @@
- type: GhostRole
prob: 0
description: ghost-role-information-angry-slimes-description
raffle:
settings: short
- type: NpcFactionMember
factions:
- SimpleHostile

View 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

View 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:

View 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:

View File

@@ -154,6 +154,8 @@
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
@@ -179,6 +181,8 @@
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
@@ -315,6 +319,8 @@
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

View File

@@ -126,6 +126,8 @@
makeSentient: true
name: ghost-role-information-slimes-name
description: ghost-role-information-slimes-description
raffle:
settings: short
- type: Speech
speechVerb: Slime
speechSounds: Slime
@@ -197,6 +199,8 @@
- SimpleHostile
- type: GhostRole
description: ghost-role-information-angry-slimes-description
raffle:
settings: short
- type: entity
name: green slime
@@ -232,6 +236,8 @@
- SimpleHostile
- type: GhostRole
description: ghost-role-information-angry-slimes-description
raffle:
settings: short
- type: entity
name: yellow slime
@@ -267,3 +273,5 @@
- SimpleHostile
- type: GhostRole
description: ghost-role-information-angry-slimes-description
raffle:
settings: short

View File

@@ -104,6 +104,8 @@
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

View 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:
@@ -150,6 +152,8 @@
components:
- type: GhostRole
description: ghost-role-information-space-dragon-dungeon-description
raffle:
settings: default
- type: SlowOnDamage
speedModifierThresholds:
100: 0.7

View 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

View 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"
@@ -120,6 +122,8 @@
makeSentient: true
name: ghost-role-information-holoparasite-name
description: ghost-role-information-holoparasite-description
raffle:
settings: default
- type: GhostTakeoverAvailable
- type: NameIdentifier
group: Holoparasite
@@ -150,6 +154,8 @@
makeSentient: true
name: ghost-role-information-ifrit-name
description: ghost-role-information-ifrit-description
raffle:
settings: default
- type: GhostTakeoverAvailable
- type: RandomSprite
available:
@@ -176,6 +182,8 @@
makeSentient: true
name: ghost-role-information-holoclown-name
description: ghost-role-information-holoclown-description
raffle:
settings: default
- type: GhostTakeoverAvailable
- type: NameIdentifier
group: Holoparasite

View 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 ]
@@ -114,6 +120,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: [ ERTLeaderGearEVALecter ]
@@ -145,6 +153,8 @@
- type: GhostRole
name: ghost-role-information-ert-chaplain-name
description: ghost-role-information-ert-chaplain-description
raffle:
settings: default
- type: GhostTakeoverAvailable
- type: RandomMetadata
nameSegments:
@@ -174,6 +184,8 @@
- 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 ]
@@ -205,6 +217,8 @@
- type: GhostRole
name: ghost-role-information-ert-janitor-name
description: ghost-role-information-ert-janitor-description
raffle:
settings: default
- type: GhostTakeoverAvailable
- type: RandomMetadata
nameSegments:
@@ -234,6 +248,8 @@
- 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 ]
@@ -265,6 +281,8 @@
- type: GhostRole
name: ghost-role-information-ert-engineer-name
description: ghost-role-information-ert-engineer-description
raffle:
settings: default
- type: GhostTakeoverAvailable
- type: RandomMetadata
nameSegments:
@@ -294,6 +312,8 @@
- 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 ]
@@ -325,6 +345,8 @@
- type: GhostRole
name: ghost-role-information-ert-security-name
description: ghost-role-information-ert-security-description
raffle:
settings: default
- type: GhostTakeoverAvailable
- type: RandomMetadata
nameSegments:
@@ -354,6 +376,8 @@
- 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 ]
@@ -375,6 +399,8 @@
- 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 ]
@@ -406,6 +432,8 @@
- type: GhostRole
name: ghost-role-information-ert-medical-name
description: ghost-role-information-ert-medical-description
raffle:
settings: default
- type: GhostTakeoverAvailable
- type: RandomMetadata
nameSegments:
@@ -435,6 +463,8 @@
- 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 ]
@@ -463,6 +493,8 @@
- type: GhostRole
name: ghost-role-information-cburn-agent-name
description: ghost-role-information-cburn-agent-description
raffle:
settings: default
- type: GhostTakeoverAvailable
- type: RandomMetadata
nameSegments:
@@ -489,6 +521,8 @@
- 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 ]
@@ -551,5 +585,7 @@
- type: GhostRole
name: ghost-role-information-cluwne-name
description: ghost-role-information-cluwne-description
raffle:
settings: default
- type: GhostTakeoverAvailable
- type: Cluwne

View 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]

View File

@@ -810,6 +810,8 @@
allowMovement: true
description: ghost-role-information-BreadDog-description
rules: ghost-role-information-BreadDog-rules
raffle:
settings: short
- type: GhostTakeoverAvailable
- type: BarkAccent
- type: Speech

View File

@@ -672,6 +672,8 @@
allowMovement: true
description: ghost-role-information-Cak-description
rules: ghost-role-information-Cak-rules
raffle:
settings: short
- type: GhostTakeoverAvailable
- type: OwOAccent
- type: Speech

View 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

View File

@@ -0,0 +1,3 @@
- type: ghostRoleRaffleDecider
id: default
decider: !type:RngGhostRoleRaffleDecider {}

View File

@@ -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