diff --git a/Content.Client/NukeOps/WarDeclaratorBoundUserInterface.cs b/Content.Client/NukeOps/WarDeclaratorBoundUserInterface.cs
new file mode 100644
index 0000000000..7394e27043
--- /dev/null
+++ b/Content.Client/NukeOps/WarDeclaratorBoundUserInterface.cs
@@ -0,0 +1,49 @@
+using Content.Shared.NukeOps;
+using JetBrains.Annotations;
+using Robust.Client.GameObjects;
+using Robust.Shared.Timing;
+
+namespace Content.Client.NukeOps;
+
+[UsedImplicitly]
+public sealed class WarDeclaratorBoundUserInterface : BoundUserInterface
+{
+ [ViewVariables]
+ private WarDeclaratorWindow? _window;
+
+ public WarDeclaratorBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey) {}
+
+ protected override void Open()
+ {
+ base.Open();
+
+ _window = new WarDeclaratorWindow();
+ if (State != null)
+ UpdateState(State);
+
+ _window.OpenCentered();
+
+ _window.OnClose += Close;
+ _window.OnActivated += OnWarDeclaratorActivated;
+ }
+
+ protected override void UpdateState(BoundUserInterfaceState state)
+ {
+ base.UpdateState(state);
+ if (_window == null || state is not WarDeclaratorBoundUserInterfaceState cast)
+ return;
+
+ _window?.UpdateState(cast);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (disposing) _window?.Dispose();
+ }
+
+ private void OnWarDeclaratorActivated(string message)
+ {
+ SendMessage(new WarDeclaratorActivateMessage(message));
+ }
+}
diff --git a/Content.Client/NukeOps/WarDeclaratorWindow.xaml b/Content.Client/NukeOps/WarDeclaratorWindow.xaml
new file mode 100644
index 0000000000..f90ed865a0
--- /dev/null
+++ b/Content.Client/NukeOps/WarDeclaratorWindow.xaml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
diff --git a/Content.Client/NukeOps/WarDeclaratorWindow.xaml.cs b/Content.Client/NukeOps/WarDeclaratorWindow.xaml.cs
new file mode 100644
index 0000000000..8fb10b8215
--- /dev/null
+++ b/Content.Client/NukeOps/WarDeclaratorWindow.xaml.cs
@@ -0,0 +1,138 @@
+using Content.Client.Stylesheets;
+using Content.Shared.NukeOps;
+using Robust.Client.AutoGenerated;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+
+namespace Content.Client.NukeOps;
+
+[GenerateTypedNameReferences]
+public sealed partial class WarDeclaratorWindow : DefaultWindow
+{
+ private readonly IGameTiming _gameTiming;
+
+ public event Action? OnActivated;
+
+ private TimeSpan _endTime;
+ private TimeSpan _timeStamp;
+ private WarConditionStatus _status;
+
+ public WarDeclaratorWindow()
+ {
+ RobustXamlLoader.Load(this);
+
+ _gameTiming = IoCManager.Resolve();
+
+ WarButton.OnPressed += ActivateWarDeclarator;
+
+ var loc = IoCManager.Resolve();
+ MessageEdit.Placeholder = new Rope.Leaf(loc.GetString("war-declarator-message-placeholder"));
+ }
+
+ protected override void Draw(DrawingHandleScreen handle)
+ {
+ base.Draw(handle);
+ UpdateTimer();
+ }
+
+ public void UpdateState(WarDeclaratorBoundUserInterfaceState state)
+ {
+ WarButton.Disabled = state.Status != WarConditionStatus.YES_WAR;
+
+ _timeStamp = state.Delay;
+ _endTime = state.EndTime;
+ _status = state.Status;
+
+ switch(state.Status)
+ {
+ case WarConditionStatus.WAR_READY:
+ StatusLabel.Text = Loc.GetString("war-declarator-boost-declared");
+ InfoLabel.Text = Loc.GetString("war-declarator-conditions-ready");
+ StatusLabel.SetOnlyStyleClass(StyleNano.StyleClassPowerStateLow);
+ break;
+ case WarConditionStatus.WAR_DELAY:
+ StatusLabel.Text = Loc.GetString("war-declarator-boost-declared-delay");
+ UpdateTimer();
+ StatusLabel.SetOnlyStyleClass(StyleNano.StyleClassPowerStateLow);
+ break;
+ case WarConditionStatus.YES_WAR:
+ StatusLabel.Text = Loc.GetString("war-declarator-boost-possible");
+ UpdateTimer();
+ StatusLabel.SetOnlyStyleClass(StyleNano.StyleClassPowerStateGood);
+ break;
+ case WarConditionStatus.NO_WAR_SMALL_CREW:
+ StatusLabel.Text = Loc.GetString("war-declarator-boost-impossible");
+ InfoLabel.Text = Loc.GetString("war-declarator-conditions-small-crew", ("min", state.MinCrew));
+ StatusLabel.SetOnlyStyleClass(StyleNano.StyleClassPowerStateNone);
+ break;
+ case WarConditionStatus.NO_WAR_SHUTTLE_DEPARTED:
+ StatusLabel.Text = Loc.GetString("war-declarator-boost-impossible");
+ InfoLabel.Text = Loc.GetString("war-declarator-conditions-left-outpost");
+ StatusLabel.SetOnlyStyleClass(StyleNano.StyleClassPowerStateNone);
+ break;
+ case WarConditionStatus.NO_WAR_TIMEOUT:
+ StatusLabel.Text = Loc.GetString("war-declarator-boost-impossible");
+ InfoLabel.Text = Loc.GetString("war-declarator-conditions-time-out");
+ StatusLabel.SetOnlyStyleClass(StyleNano.StyleClassPowerStateNone);
+ break;
+ default:
+ StatusLabel.Text = Loc.GetString("war-declarator-boost-impossible");
+ InfoLabel.Text = Loc.GetString("war-declarator-conditions-unknown");
+ StatusLabel.SetOnlyStyleClass(StyleNano.StyleClassPowerStateNone);
+ break;
+ }
+ }
+
+ public void UpdateTimer()
+ {
+ switch(_status)
+ {
+ case WarConditionStatus.YES_WAR:
+ var gameruleTime = _gameTiming.CurTime.Subtract(_timeStamp);
+ var timeLeft = _endTime.Subtract(gameruleTime);
+
+ if (timeLeft > TimeSpan.Zero)
+ {
+ InfoLabel.Text = Loc.GetString("war-declarator-boost-timer", ("minutes", timeLeft.Minutes), ("seconds", timeLeft.Seconds));
+ }
+ else
+ {
+ _status = WarConditionStatus.NO_WAR_TIMEOUT;
+ StatusLabel.Text = Loc.GetString("war-declarator-boost-impossible");
+ InfoLabel.Text = Loc.GetString("war-declarator-conditions-time-out");
+ StatusLabel.SetOnlyStyleClass(StyleNano.StyleClassPowerStateNone);
+ WarButton.Disabled = true;
+ }
+ break;
+ case WarConditionStatus.WAR_DELAY:
+ var timeAfterDeclaration = _gameTiming.CurTime.Subtract(_timeStamp);
+ var timeRemain = _endTime.Subtract(timeAfterDeclaration);
+
+ if (timeRemain > TimeSpan.Zero)
+ {
+ InfoLabel.Text = Loc.GetString("war-declarator-boost-timer", ("minutes", timeRemain.Minutes), ("seconds", timeRemain.Seconds));
+ }
+ else
+ {
+ _status = WarConditionStatus.WAR_READY;
+ StatusLabel.Text = Loc.GetString("war-declarator-boost-declared");
+ InfoLabel.Text = Loc.GetString("war-declarator-conditions-ready");
+ StatusLabel.SetOnlyStyleClass(StyleNano.StyleClassPowerStateLow);
+ WarButton.Disabled = true;
+ }
+ break;
+ default:
+ return;
+ }
+ }
+
+ private void ActivateWarDeclarator(BaseButton.ButtonEventArgs obj)
+ {
+ var message = Rope.Collapse(MessageEdit.TextRope);
+ OnActivated?.Invoke(message);
+ }
+}
diff --git a/Content.Server/Communications/CommunicationsConsoleSystem.cs b/Content.Server/Communications/CommunicationsConsoleSystem.cs
index 66ef092eee..8f64e88f14 100644
--- a/Content.Server/Communications/CommunicationsConsoleSystem.cs
+++ b/Content.Server/Communications/CommunicationsConsoleSystem.cs
@@ -1,9 +1,7 @@
using System.Globalization;
-using System.Linq;
using Content.Server.Access.Systems;
using Content.Server.Administration.Logs;
using Content.Server.AlertLevel;
-using Content.Server.Chat;
using Content.Server.Chat.Systems;
using Content.Server.Interaction;
using Content.Server.Popups;
@@ -16,11 +14,9 @@ using Content.Shared.CCVar;
using Content.Shared.Communications;
using Content.Shared.Database;
using Content.Shared.Emag.Components;
-using Content.Shared.Examine;
using Content.Shared.Popups;
using Robust.Server.GameObjects;
using Robust.Shared.Configuration;
-using Robust.Shared.Player;
namespace Content.Server.Communications
{
@@ -262,6 +258,9 @@ namespace Content.Server.Communications
comp.AnnouncementCooldownRemaining = comp.DelayBetweenAnnouncements;
UpdateCommsConsoleInterface(uid, comp);
+ var ev = new CommunicationConsoleAnnouncementEvent(uid, comp, msg, message.Session.AttachedEntity);
+ RaiseLocalEvent(ref ev);
+
// allow admemes with vv
Loc.TryGetString(comp.AnnouncementDisplayName, out var title);
title ??= comp.AnnouncementDisplayName;
@@ -291,6 +290,15 @@ namespace Content.Server.Communications
_popupSystem.PopupEntity(Loc.GetString("comms-console-permission-denied"), uid, message.Session);
return;
}
+
+ var ev = new CommunicationConsoleCallShuttleAttemptEvent(uid, comp, mob);
+ RaiseLocalEvent(ref ev);
+ if (ev.Cancelled)
+ {
+ _popupSystem.PopupEntity(ev.Reason ?? Loc.GetString("comms-console-shuttle-unavailable"), uid, message.Session);
+ return;
+ }
+
_roundEndSystem.RequestRoundEnd(uid);
_adminLogger.Add(LogType.Action, LogImpact.Extreme, $"{ToPrettyString(mob):player} has called the shuttle.");
}
@@ -309,4 +317,29 @@ namespace Content.Server.Communications
_adminLogger.Add(LogType.Action, LogImpact.Extreme, $"{ToPrettyString(mob):player} has recalled the shuttle.");
}
}
+
+ ///
+ /// Raised on announcement
+ ///
+ [ByRefEvent]
+ public record struct CommunicationConsoleAnnouncementEvent(EntityUid Uid, CommunicationsConsoleComponent Component, string Text, EntityUid? Sender)
+ {
+ public EntityUid Uid = Uid;
+ public CommunicationsConsoleComponent Component = Component;
+ public EntityUid? Sender = Sender;
+ public string Text = Text;
+ }
+
+ ///
+ /// Raised on shuttle call attempt. Can be cancelled
+ ///
+ [ByRefEvent]
+ public record struct CommunicationConsoleCallShuttleAttemptEvent(EntityUid Uid, CommunicationsConsoleComponent Component, EntityUid? Sender)
+ {
+ public bool Cancelled = false;
+ public EntityUid Uid = Uid;
+ public CommunicationsConsoleComponent Component = Component;
+ public EntityUid? Sender = Sender;
+ public string? Reason;
+ }
}
diff --git a/Content.Server/GameTicking/GameTicker.GameRule.cs b/Content.Server/GameTicking/GameTicker.GameRule.cs
index f3845e753a..31a37f85a3 100644
--- a/Content.Server/GameTicking/GameTicker.GameRule.cs
+++ b/Content.Server/GameTicking/GameTicker.GameRule.cs
@@ -104,6 +104,7 @@ public sealed partial class GameTicker
_sawmill.Info($"Started game rule {ToPrettyString(ruleEntity)}");
ruleData.Active = true;
+ ruleData.ActivatedAt = _gameTiming.CurTime;
var ev = new GameRuleStartedEvent(ruleEntity, id);
RaiseLocalEvent(ruleEntity, ref ev, true);
return true;
diff --git a/Content.Server/GameTicking/Rules/Components/GameRuleComponent.cs b/Content.Server/GameTicking/Rules/Components/GameRuleComponent.cs
index e918a5b796..cc384b47d3 100644
--- a/Content.Server/GameTicking/Rules/Components/GameRuleComponent.cs
+++ b/Content.Server/GameTicking/Rules/Components/GameRuleComponent.cs
@@ -1,4 +1,6 @@
-namespace Content.Server.GameTicking.Rules.Components;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Server.GameTicking.Rules.Components;
///
/// Component attached to all gamerule entities.
@@ -14,6 +16,12 @@ public sealed partial class GameRuleComponent : Component
[DataField("active")]
public bool Active;
+ ///
+ /// Game time when game rule was activated
+ ///
+ [DataField("activatedAt", customTypeSerializer:typeof(TimeOffsetSerializer))]
+ public TimeSpan ActivatedAt;
+
///
/// Whether or not the gamerule finished.
/// Used for tracking whether a non-active gamerule has been started before.
diff --git a/Content.Server/GameTicking/Rules/Components/NukeOpsShuttleComponent.cs b/Content.Server/GameTicking/Rules/Components/NukeOpsShuttleComponent.cs
new file mode 100644
index 0000000000..358b157cdf
--- /dev/null
+++ b/Content.Server/GameTicking/Rules/Components/NukeOpsShuttleComponent.cs
@@ -0,0 +1,9 @@
+namespace Content.Server.GameTicking.Rules.Components;
+
+///
+/// Tags grid as nuke ops shuttle
+///
+[RegisterComponent]
+public sealed partial class NukeOpsShuttleComponent : Component
+{
+}
diff --git a/Content.Server/GameTicking/Rules/Components/NukeopsRuleComponent.cs b/Content.Server/GameTicking/Rules/Components/NukeopsRuleComponent.cs
index d58a90715c..33f4663988 100644
--- a/Content.Server/GameTicking/Rules/Components/NukeopsRuleComponent.cs
+++ b/Content.Server/GameTicking/Rules/Components/NukeopsRuleComponent.cs
@@ -1,15 +1,13 @@
using Content.Server.NPC.Components;
using Content.Server.StationEvents.Events;
using Content.Shared.Dataset;
-using Content.Shared.Humanoid.Prototypes;
using Content.Shared.Roles;
using Robust.Server.Player;
-using Robust.Shared.Audio;
using Robust.Shared.Map;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
-using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Set;
using Robust.Shared.Utility;
namespace Content.Server.GameTicking.Rules.Components;
@@ -44,6 +42,48 @@ public sealed partial class NukeopsRuleComponent : Component
[DataField("spawnOutpost")]
public bool SpawnOutpost = true;
+ ///
+ /// Whether or not nukie left their outpost
+ ///
+ [DataField("leftOutpost")]
+ public bool LeftOutpost = false;
+
+ ///
+ /// Enables opportunity to get extra TC for war declaration
+ ///
+ [DataField("canEnableWarOps")]
+ public bool CanEnableWarOps = true;
+
+ ///
+ /// Indicates time when war has been declared, null if not declared
+ ///
+ [DataField("warDeclaredTime", customTypeSerializer: typeof(TimeOffsetSerializer))]
+ public TimeSpan? WarDeclaredTime;
+
+ ///
+ /// This amount of TC will be given to each nukie
+ ///
+ [DataField("warTCAmountPerNukie")]
+ public int WarTCAmountPerNukie = 40;
+
+ ///
+ /// Time allowed for declaration of war
+ ///
+ [DataField("warDeclarationDelay")]
+ public TimeSpan WarDeclarationDelay = TimeSpan.FromMinutes(6);
+
+ ///
+ /// Delay between war declaration and nuke ops arrival on station map. Gives crew time to prepare
+ ///
+ [DataField("warNukieArriveDelay")]
+ public TimeSpan? WarNukieArriveDelay = TimeSpan.FromMinutes(15);
+
+ ///
+ /// Minimal operatives count for war declaration
+ ///
+ [DataField("warDeclarationMinOps")]
+ public int WarDeclarationMinOps = 4;
+
[DataField("spawnPointProto", customTypeSerializer: typeof(PrototypeIdSerializer))]
public string SpawnPointPrototype = "SpawnPointNukies";
diff --git a/Content.Server/GameTicking/Rules/NukeopsRuleSystem.cs b/Content.Server/GameTicking/Rules/NukeopsRuleSystem.cs
index 63aee534ee..ab23fb07fc 100644
--- a/Content.Server/GameTicking/Rules/NukeopsRuleSystem.cs
+++ b/Content.Server/GameTicking/Rules/NukeopsRuleSystem.cs
@@ -1,7 +1,10 @@
+using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Numerics;
using Content.Server.Administration.Commands;
using Content.Server.Chat.Managers;
+using Content.Server.Chat.Systems;
+using Content.Server.Communications;
using Content.Server.GameTicking.Rules.Components;
using Content.Server.Ghost.Roles.Components;
using Content.Server.Ghost.Roles.Events;
@@ -11,30 +14,40 @@ using Content.Server.Mind.Components;
using Content.Server.NPC.Components;
using Content.Server.NPC.Systems;
using Content.Server.Nuke;
+using Content.Server.NukeOps;
+using Content.Server.Popups;
using Content.Server.Preferences.Managers;
using Content.Server.Roles;
using Content.Server.RoundEnd;
using Content.Server.Shuttles.Components;
+using Content.Server.Shuttles.Events;
using Content.Server.Shuttles.Systems;
using Content.Server.Spawners.Components;
using Content.Server.Station.Components;
using Content.Server.Station.Systems;
+using Content.Server.Store.Components;
+using Content.Server.Store.Systems;
using Content.Shared.Dataset;
using Content.Shared.Humanoid;
using Content.Shared.Humanoid.Prototypes;
using Content.Shared.Mobs;
using Content.Shared.Mobs.Components;
using Content.Shared.Nuke;
+using Content.Shared.NukeOps;
using Content.Shared.Preferences;
using Content.Shared.Roles;
+using Content.Shared.Store;
+using Content.Shared.Tag;
using Content.Shared.Zombies;
using Robust.Server.GameObjects;
using Robust.Server.Maps;
using Robust.Server.Player;
+using Robust.Shared.Audio;
using Robust.Shared.Map;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
+using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Server.GameTicking.Rules;
@@ -58,6 +71,18 @@ public sealed class NukeopsRuleSystem : GameRuleSystem
[Dependency] private readonly MindSystem _mindSystem = default!;
[Dependency] private readonly RoleSystem _roles = default!;
[Dependency] private readonly MetaDataSystem _metaData = default!;
+ [Dependency] private readonly IGameTiming _gameTiming = default!;
+ [Dependency] private readonly ChatSystem _chatSystem = default!;
+ [Dependency] private readonly StoreSystem _storeSystem = default!;
+ [Dependency] private readonly TagSystem _tag = default!;
+ [Dependency] private readonly PopupSystem _popupSystem = default!;
+ [Dependency] private readonly WarDeclaratorSystem _warDeclaratorSystem = default!;
+
+ [ValidatePrototypeId]
+ private const string TelecrystalCurrencyPrototype = "Telecrystal";
+
+ [ValidatePrototypeId]
+ private const string NukeOpsUplinkTagPrototype = "NukeOpsUplink";
[ValidatePrototypeId]
public const string NukeopsId = "Nukeops";
@@ -78,6 +103,119 @@ public sealed class NukeopsRuleSystem : GameRuleSystem
SubscribeLocalEvent(OnComponentInit);
SubscribeLocalEvent(OnComponentRemove);
SubscribeLocalEvent(OnOperativeZombified);
+ SubscribeLocalEvent(OnShuttleCallAttempt);
+ SubscribeLocalEvent(OnShuttleConsoleFTLStart);
+ SubscribeLocalEvent(OnShuttleFTLAttempt);
+ }
+
+ ///
+ /// Returns true when the player with UID opUid is a nuclear operative. Prevents random
+ /// people from using the war declarator outside of the game mode.
+ ///
+ public bool TryGetRuleFromOperative(EntityUid opUid, [NotNullWhen(true)] out (NukeopsRuleComponent, GameRuleComponent)? comps)
+ {
+ comps = null;
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var ruleEnt, out var nukeops, out var gameRule))
+ {
+ if (!GameTicker.IsGameRuleAdded(ruleEnt, gameRule))
+ continue;
+
+ var found = nukeops.OperativePlayers.Values.Any(v => v.AttachedEntity == opUid);
+ if (found)
+ {
+ comps = (nukeops, gameRule);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Search rule components by grid uid
+ ///
+ public bool TryGetRuleFromGrid(EntityUid gridId, [NotNullWhen(true)] out (NukeopsRuleComponent, GameRuleComponent)? comps)
+ {
+ comps = null;
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var ruleEnt, out var nukeops, out var gameRule))
+ {
+ if (!GameTicker.IsGameRuleAdded(ruleEnt, gameRule))
+ continue;
+
+ if (gridId == nukeops.NukieShuttle || gridId == nukeops.NukieOutpost)
+ {
+ comps = (nukeops, gameRule);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Returns conditions for war declaration
+ ///
+ public WarConditionStatus GetWarCondition(NukeopsRuleComponent nukieRule, GameRuleComponent gameRule)
+ {
+ if (!nukieRule.CanEnableWarOps)
+ return WarConditionStatus.NO_WAR_UNKNOWN;
+
+ if (nukieRule.WarDeclaredTime != null && nukieRule.WarNukieArriveDelay != null)
+ {
+ // Nukies must wait some time after declaration of war to get on the station
+ var warTime = _gameTiming.CurTime.Subtract(nukieRule.WarDeclaredTime.Value);
+ if (warTime > nukieRule.WarNukieArriveDelay)
+ {
+ return WarConditionStatus.WAR_READY;
+ }
+ return WarConditionStatus.WAR_DELAY;
+ }
+
+ if (nukieRule.OperativePlayers.Count < nukieRule.WarDeclarationMinOps)
+ return WarConditionStatus.NO_WAR_SMALL_CREW;
+ if (nukieRule.LeftOutpost)
+ return WarConditionStatus.NO_WAR_SHUTTLE_DEPARTED;
+
+ var gameruleTime = _gameTiming.CurTime.Subtract(gameRule.ActivatedAt);
+ if (gameruleTime > nukieRule.WarDeclarationDelay)
+ return WarConditionStatus.NO_WAR_TIMEOUT;
+
+ return WarConditionStatus.YES_WAR;
+ }
+
+ public void DeclareWar(EntityUid opsUid, string msg, string title, SoundSpecifier? announcementSound = null, Color? colorOverride = null)
+ {
+ if (!TryGetRuleFromOperative(opsUid, out var comps))
+ return;
+
+ var nukieRule = comps.Value.Item1;
+ nukieRule.WarDeclaredTime = _gameTiming.CurTime;
+ _chatSystem.DispatchGlobalAnnouncement(msg, title, announcementSound: announcementSound, colorOverride: colorOverride);
+ DistributeExtraTC(nukieRule);
+ _warDeclaratorSystem.RefreshAllUI(comps.Value.Item1, comps.Value.Item2);
+ }
+
+ private void DistributeExtraTC(NukeopsRuleComponent nukieRule)
+ {
+ var enumerator = EntityQueryEnumerator();
+ while (enumerator.MoveNext(out var uid, out var component))
+ {
+ if (!_tag.HasTag(uid, NukeOpsUplinkTagPrototype))
+ continue;
+
+ if (!nukieRule.NukieOutpost.HasValue)
+ continue;
+
+ if (Transform(uid).MapID != Transform(nukieRule.NukieOutpost.Value).MapID) // Will receive bonus TC only on their start outpost
+ continue;
+
+ _storeSystem.TryAddCurrency(new () { { TelecrystalCurrencyPrototype, nukieRule.WarTCAmountPerNukie } }, uid, component);
+
+ var msg = Loc.GetString("store-currency-war-boost-given", ("target", uid));
+ _popupSystem.PopupEntity(msg, uid);
+ }
}
private void OnComponentInit(EntityUid uid, NukeOperativeComponent component, ComponentInit args)
@@ -597,7 +735,7 @@ public sealed class NukeopsRuleSystem : GameRuleSystem
if (!_mindSystem.TryGetMind(uid, out var mindId, out var mind))
return;
- foreach (var nukeops in EntityQuery())
+ foreach (var (nukeops, gameRule) in EntityQuery())
{
if (nukeops.OperativeMindPendingData.TryGetValue(uid, out var role) || !nukeops.SpawnOutpost || !nukeops.EndsRound)
{
@@ -615,6 +753,7 @@ public sealed class NukeopsRuleSystem : GameRuleSystem
var name = MetaData(uid).EntityName;
nukeops.OperativePlayers.Add(name, playerSession);
+ _warDeclaratorSystem.RefreshAllUI(nukeops, gameRule);
if (GameTicker.RunLevel != GameRunLevel.InRound)
return;
@@ -680,6 +819,8 @@ public sealed class NukeopsRuleSystem : GameRuleSystem
_shuttle.TryFTLDock(shuttleId, shuttle, component.NukieOutpost.Value);
}
+ AddComp(shuttleId);
+
component.NukiePlanet = mapId;
component.NukieShuttle = shuttleId;
return true;
@@ -856,6 +997,81 @@ public sealed class NukeopsRuleSystem : GameRuleSystem
}
}
+ private void OnShuttleFTLAttempt(ref ConsoleFTLAttemptEvent ev)
+ {
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var ruleUid, out var nukeops, out var gameRule))
+ {
+ if (!GameTicker.IsGameRuleAdded(ruleUid, gameRule))
+ continue;
+
+ if (nukeops.NukieOutpost == null ||
+ nukeops.WarDeclaredTime == null ||
+ nukeops.WarNukieArriveDelay == null ||
+ ev.Uid != nukeops.NukieShuttle)
+ continue;
+
+ var mapOutpost = Transform(nukeops.NukieOutpost.Value).MapID;
+ var mapShuttle = Transform(ev.Uid).MapID;
+
+ if (mapOutpost == mapShuttle)
+ {
+ var timeAfterDeclaration = _gameTiming.CurTime.Subtract(nukeops.WarDeclaredTime.Value);
+ var timeRemain = nukeops.WarNukieArriveDelay.Value.Subtract(timeAfterDeclaration);
+ if (timeRemain > TimeSpan.Zero)
+ {
+ ev.Cancelled = true;
+ ev.Reason = Loc.GetString("war-ops-infiltrator-unavailable", ("minutes", timeRemain.Minutes), ("seconds", timeRemain.Seconds));
+ }
+ }
+ }
+ }
+
+ private void OnShuttleConsoleFTLStart(ref ShuttleConsoleFTLTravelStartEvent ev)
+ {
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var ruleUid, out var nukeops, out var gameRule))
+ {
+ if (!GameTicker.IsGameRuleAdded(ruleUid, gameRule))
+ continue;
+
+ var gridUid = Transform(ev.Uid).GridUid;
+ if (nukeops.NukieOutpost == null ||
+ gridUid == null ||
+ gridUid.Value != nukeops.NukieShuttle)
+ continue;
+
+ var mapOutpost = Transform(nukeops.NukieOutpost.Value).MapID;
+ var mapShuttle = Transform(ev.Uid).MapID;
+
+ if (mapOutpost == mapShuttle)
+ {
+ nukeops.LeftOutpost = true;
+
+ if (TryGetRuleFromGrid(gridUid.Value, out var comps))
+ _warDeclaratorSystem.RefreshAllUI(comps.Value.Item1, comps.Value.Item2);
+ }
+ }
+ }
+
+ private void OnShuttleCallAttempt(ref CommunicationConsoleCallShuttleAttemptEvent ev)
+ {
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var ruleUid, out var nukeops, out var gameRule))
+ {
+ if (!GameTicker.IsGameRuleAdded(ruleUid, gameRule))
+ continue;
+
+ // Can't call while nukies are preparing to arrive
+ if (GetWarCondition(nukeops, gameRule) == WarConditionStatus.WAR_DELAY)
+ {
+ ev.Cancelled = true;
+ ev.Reason = Loc.GetString("war-ops-shuttle-call-unavailable");
+ return;
+ }
+ }
+ }
+
protected override void Started(EntityUid uid, NukeopsRuleComponent component, GameRuleComponent gameRule, GameRuleStartedEvent args)
{
base.Started(uid, component, gameRule, args);
diff --git a/Content.Server/NukeOps/WarDeclaratorComponent.cs b/Content.Server/NukeOps/WarDeclaratorComponent.cs
new file mode 100644
index 0000000000..1a1f9116c6
--- /dev/null
+++ b/Content.Server/NukeOps/WarDeclaratorComponent.cs
@@ -0,0 +1,48 @@
+using Robust.Shared.Audio;
+
+namespace Content.Server.NukeOps;
+
+///
+/// Used with NukeOps game rule to send war declaration announcement
+///
+[RegisterComponent]
+public sealed partial class WarDeclaratorComponent : Component
+{
+ ///
+ /// Custom war declaration message. If empty, use default.
+ ///
+ [ViewVariables(VVAccess.ReadWrite)]
+ [DataField("message")]
+ public string Message;
+
+ ///
+ /// Permission to customize message text
+ ///
+ [ViewVariables(VVAccess.ReadWrite)]
+ [DataField("allowEditingMessage")]
+ public bool AllowEditingMessage = true;
+
+ [ViewVariables(VVAccess.ReadWrite)]
+ [DataField("maxMessageLength")]
+ public int MaxMessageLength = 512;
+
+ ///
+ /// War declarement text color
+ ///
+ [ViewVariables(VVAccess.ReadWrite)]
+ [DataField("color")]
+ public Color DeclarementColor = Color.Red;
+
+ ///
+ /// War declarement sound file path
+ ///
+ [DataField("sound")]
+ public SoundSpecifier DeclarementSound = new SoundPathSpecifier("/Audio/Announcements/war.ogg");
+
+ ///
+ /// Fluent ID for the declarement title
+ ///
+ [ViewVariables(VVAccess.ReadWrite)]
+ [DataField("title")]
+ public string DeclarementTitle = "comms-console-announcement-title-nukie";
+}
diff --git a/Content.Server/NukeOps/WarDeclaratorSystem.cs b/Content.Server/NukeOps/WarDeclaratorSystem.cs
new file mode 100644
index 0000000000..2df2cb3483
--- /dev/null
+++ b/Content.Server/NukeOps/WarDeclaratorSystem.cs
@@ -0,0 +1,127 @@
+using Content.Server.Administration.Logs;
+using Content.Server.GameTicking.Rules;
+using Content.Server.GameTicking.Rules.Components;
+using Content.Server.Popups;
+using Content.Server.UserInterface;
+using Content.Shared.Database;
+using Content.Shared.NukeOps;
+using Robust.Server.GameObjects;
+
+namespace Content.Server.NukeOps;
+
+///
+/// This handles nukeops special war mode declaration device and directly using nukeops game rule
+///
+public sealed class WarDeclaratorSystem : EntitySystem
+{
+ [Dependency] private readonly UserInterfaceSystem _userInterfaceSystem = default!;
+ [Dependency] private readonly IAdminLogManager _adminLogger = default!;
+ [Dependency] private readonly NukeopsRuleSystem _nukeopsRuleSystem = default!;
+ [Dependency] private readonly PopupSystem _popupSystem = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnActivated);
+ SubscribeLocalEvent(OnAttemptOpenUI);
+ }
+
+ private void OnAttemptOpenUI(EntityUid uid, WarDeclaratorComponent component, ActivatableUIOpenAttemptEvent args)
+ {
+ if (!_nukeopsRuleSystem.TryGetRuleFromOperative(args.User, out var comps))
+ {
+ var msg = Loc.GetString("war-declarator-not-nukeops");
+ _popupSystem.PopupEntity(msg, uid);
+ args.Cancel();
+ return;
+ }
+
+ UpdateUI(uid, comps.Value.Item1, comps.Value.Item2);
+ }
+
+ private void OnActivated(EntityUid uid, WarDeclaratorComponent component, WarDeclaratorActivateMessage args)
+ {
+ if (!args.Session.AttachedEntity.HasValue ||
+ !_nukeopsRuleSystem.TryGetRuleFromOperative(args.Session.AttachedEntity.Value, out var comps))
+ return;
+
+ var condition = _nukeopsRuleSystem.GetWarCondition(comps.Value.Item1, comps.Value.Item2);
+ if (condition != WarConditionStatus.YES_WAR)
+ {
+ UpdateUI(uid, comps.Value.Item1, comps.Value.Item2);
+ return;
+ }
+
+ var text = (args.Message.Length <= component.MaxMessageLength ? args.Message.Trim() : $"{args.Message.Trim().Substring(0, 256)}...").ToCharArray();
+
+ // No more than 2 newlines, other replaced to spaces
+ var newlines = 0;
+ for (var i = 0; i < text.Length; i++)
+ {
+ if (text[i] != '\n')
+ continue;
+
+ if (newlines >= 2)
+ text[i] = ' ';
+
+ newlines++;
+ }
+
+ string message = new string(text);
+ if (component.AllowEditingMessage && message != string.Empty)
+ {
+ component.Message = message;
+ }
+ else
+ {
+ message = Loc.GetString("war-declarator-default-message");
+ }
+ var title = Loc.GetString(component.DeclarementTitle);
+
+ _nukeopsRuleSystem.DeclareWar(args.Session.AttachedEntity.Value, message, title, component.DeclarementSound, component.DeclarementColor);
+
+ if (args.Session.AttachedEntity != null)
+ _adminLogger.Add(LogType.Chat, LogImpact.Low, $"{ToPrettyString(args.Session.AttachedEntity.Value):player} has declared war with this text: {message}");
+ }
+
+ public void RefreshAllUI(NukeopsRuleComponent nukeops, GameRuleComponent gameRule)
+ {
+ var enumerator = EntityQueryEnumerator();
+ while (enumerator.MoveNext(out var uid, out _))
+ {
+ UpdateUI(uid, nukeops, gameRule);
+ }
+ }
+
+ private void UpdateUI(EntityUid declaratorUid, NukeopsRuleComponent nukeops, GameRuleComponent gameRule)
+ {
+ var condition = _nukeopsRuleSystem.GetWarCondition(nukeops, gameRule);
+
+ TimeSpan startTime;
+ TimeSpan delayTime;
+ switch(condition)
+ {
+ case WarConditionStatus.YES_WAR:
+ startTime = gameRule.ActivatedAt;
+ delayTime = nukeops.WarDeclarationDelay;
+ break;
+ case WarConditionStatus.WAR_DELAY:
+ startTime = nukeops.WarDeclaredTime!.Value;
+ delayTime = nukeops.WarNukieArriveDelay!.Value;
+ break;
+ default:
+ startTime = TimeSpan.Zero;
+ delayTime = TimeSpan.Zero;
+ break;
+ }
+
+ _userInterfaceSystem.TrySetUiState(
+ declaratorUid,
+ WarDeclaratorUiKey.Key,
+ new WarDeclaratorBoundUserInterfaceState(
+ condition,
+ nukeops.WarDeclarationMinOps,
+ delayTime,
+ startTime));
+ }
+}
diff --git a/Content.Server/Shuttles/Events/ShuttleConsoleFTLStartEvent.cs b/Content.Server/Shuttles/Events/ShuttleConsoleFTLStartEvent.cs
new file mode 100644
index 0000000000..cfa6d4157d
--- /dev/null
+++ b/Content.Server/Shuttles/Events/ShuttleConsoleFTLStartEvent.cs
@@ -0,0 +1,10 @@
+namespace Content.Server.Shuttles.Events;
+
+///
+/// Raised when shuttle console approved FTL
+///
+[ByRefEvent]
+public record struct ShuttleConsoleFTLTravelStartEvent(EntityUid Uid)
+{
+ public EntityUid Uid = Uid;
+}
diff --git a/Content.Server/Shuttles/Systems/ShuttleConsoleSystem.cs b/Content.Server/Shuttles/Systems/ShuttleConsoleSystem.cs
index 1d8cf56748..fd814530ae 100644
--- a/Content.Server/Shuttles/Systems/ShuttleConsoleSystem.cs
+++ b/Content.Server/Shuttles/Systems/ShuttleConsoleSystem.cs
@@ -122,6 +122,9 @@ public sealed partial class ShuttleConsoleSystem : SharedShuttleConsoleSystem
var tagEv = new FTLTagEvent();
RaiseLocalEvent(xform.GridUid.Value, ref tagEv);
+ var ev = new ShuttleConsoleFTLTravelStartEvent(uid);
+ RaiseLocalEvent(ref ev);
+
_shuttle.FTLTravel(xform.GridUid.Value, shuttle, args.Destination, dock: dock, priorityTag: tagEv.Tag);
}
@@ -211,7 +214,7 @@ public sealed partial class ShuttleConsoleSystem : SharedShuttleConsoleSystem
{
RemovePilot(user, pilotComponent);
- // This feels backwards; is this intended to be a toggle?
+ // This feels backwards; is this intended to be a toggle?
if (console == uid)
return false;
}
diff --git a/Content.Shared/NukeOps/WarDeclaratorEvents.cs b/Content.Shared/NukeOps/WarDeclaratorEvents.cs
new file mode 100644
index 0000000000..3e0c12b7ec
--- /dev/null
+++ b/Content.Shared/NukeOps/WarDeclaratorEvents.cs
@@ -0,0 +1,48 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.NukeOps;
+
+[Serializable, NetSerializable]
+public enum WarDeclaratorUiKey
+{
+ Key,
+}
+
+public enum WarConditionStatus : byte
+{
+ WAR_READY,
+ WAR_DELAY,
+ YES_WAR,
+ NO_WAR_UNKNOWN,
+ NO_WAR_TIMEOUT,
+ NO_WAR_SMALL_CREW,
+ NO_WAR_SHUTTLE_DEPARTED
+}
+
+[Serializable, NetSerializable]
+public sealed class WarDeclaratorBoundUserInterfaceState : BoundUserInterfaceState
+{
+ public WarConditionStatus Status;
+ public int MinCrew;
+ public TimeSpan Delay;
+ public TimeSpan EndTime;
+
+ public WarDeclaratorBoundUserInterfaceState(WarConditionStatus status, int minCrew, TimeSpan delay, TimeSpan endTime)
+ {
+ Status = status;
+ MinCrew = minCrew;
+ Delay = delay;
+ EndTime = endTime;
+ }
+}
+
+[Serializable, NetSerializable]
+public sealed class WarDeclaratorActivateMessage : BoundUserInterfaceMessage
+{
+ public string Message { get; }
+
+ public WarDeclaratorActivateMessage(string msg)
+ {
+ Message = msg;
+ }
+}
diff --git a/Resources/Locale/en-US/communications/communications-console-component.ftl b/Resources/Locale/en-US/communications/communications-console-component.ftl
index a2fe03963d..a3b95940df 100644
--- a/Resources/Locale/en-US/communications/communications-console-component.ftl
+++ b/Resources/Locale/en-US/communications/communications-console-component.ftl
@@ -7,6 +7,7 @@ comms-console-menu-recall-shuttle = Recall emergency shuttle
# Popup
comms-console-permission-denied = Permission denied
+comms-console-shuttle-unavailable = Shuttle is currently unavailable
# Placeholder values
comms-console-announcement-sent-by = Sent by
diff --git a/Resources/Locale/en-US/nukeops/war-declarator.ftl b/Resources/Locale/en-US/nukeops/war-declarator.ftl
new file mode 100644
index 0000000000..96ae953660
--- /dev/null
+++ b/Resources/Locale/en-US/nukeops/war-declarator.ftl
@@ -0,0 +1,16 @@
+war-declarator-not-nukeops = The device makes beeping noises, but nothing happens...
+war-declarator-ui-header = Declaration of War
+war-declarator-ui-war-button = DECLARE WAR!
+war-declarator-conditions-small-crew = Less than { $min } operatives
+war-declarator-conditions-left-outpost = Shuttle left the syndicate outpost
+war-declarator-conditions-time-out = War declaration time passed
+war-declarator-conditions-delay = Shuttle departure temporarily unavailable
+war-declarator-conditions-ready = Shuttle can leave the outpost!
+war-declarator-conditions-unknown = Unknown
+war-declarator-boost-possible = Able to declare war
+war-declarator-boost-impossible = Unable to declare war
+war-declarator-boost-declared = War declared!
+war-declarator-boost-declared-delay = War declared! Shuttle departure temporarily disabled
+war-declarator-boost-timer = Time left: {$minutes} minutes and {$seconds} seconds
+war-declarator-default-message = A syndicate fringe group has declared their intent to utterly destroy station with a nuclear device, and dares the crew to try and stop them.
+war-declarator-message-placeholder = Write a custom declaration of war here...
diff --git a/Resources/Locale/en-US/nukeops/war-ops.ftl b/Resources/Locale/en-US/nukeops/war-ops.ftl
new file mode 100644
index 0000000000..52f1ac3181
--- /dev/null
+++ b/Resources/Locale/en-US/nukeops/war-ops.ftl
@@ -0,0 +1,2 @@
+war-ops-infiltrator-unavailable = ERROR: FTL Travel recalculation in progress. Estimated time: {$minutes} minutes and {$seconds} seconds
+war-ops-shuttle-call-unavailable = Evacuation shuttle is currently unavailable. Please wait
diff --git a/Resources/Locale/en-US/store/currency.ftl b/Resources/Locale/en-US/store/currency.ftl
index d3018a84d1..5d7ed95935 100644
--- a/Resources/Locale/en-US/store/currency.ftl
+++ b/Resources/Locale/en-US/store/currency.ftl
@@ -1,4 +1,5 @@
store-currency-inserted = {CAPITALIZE(THE($used))} is inserted into the {THE($target)}.
+store-currency-war-boost-given = { CAPITALIZE($target) } starts buzzing
store-currency-inserted-implant = {CAPITALIZE(THE($used))} is inserted into your implant.
store-currency-free = Free
diff --git a/Resources/Maps/infiltrator.yml b/Resources/Maps/infiltrator.yml
index f5d95a9d66..03cd166550 100644
--- a/Resources/Maps/infiltrator.yml
+++ b/Resources/Maps/infiltrator.yml
@@ -4737,14 +4737,6 @@ entities:
- pos: -4.5,-17.5
parent: 73
type: Transform
-- proto: SyndicateComputerComms
- entities:
- - uid: 556
- components:
- - rot: -1.5707963267948966 rad
- pos: 1.5,-4.5
- parent: 73
- type: Transform
- proto: SyndieMiniBomb
entities:
- uid: 723
diff --git a/Resources/Prototypes/Entities/Objects/Devices/Syndicate_Gadgets/war_declarator.yml b/Resources/Prototypes/Entities/Objects/Devices/Syndicate_Gadgets/war_declarator.yml
new file mode 100644
index 0000000000..9a1dc0f95e
--- /dev/null
+++ b/Resources/Prototypes/Entities/Objects/Devices/Syndicate_Gadgets/war_declarator.yml
@@ -0,0 +1,25 @@
+- type: entity
+ parent: BaseItem
+ id: NukeOpsDeclarationOfWar
+ name: the declaration of war
+ description: Use to send a declaration of hostilities to the target, delaying your shuttle departure while they prepare for your assault. Such a brazen move will attract the attention of powerful benefactors within the Syndicate, who will supply your team with a massive amount of bonus telecrystals. Must be used at start of mission, or your benefactors will lose interest.
+ components:
+ - type: Sprite
+ sprite: Objects/Devices/declaration_of_war.rsi
+ layers:
+ - state: declarator
+ - type: Item
+ - type: UseDelay
+ delay: 0.5
+ - type: ActivatableUI
+ inHandsOnly: true
+ singleUser: true
+ closeOnHandDeselect: false
+ key: enum.WarDeclaratorUiKey.Key
+ - type: UserInterface
+ interfaces:
+ - key: enum.WarDeclaratorUiKey.Key
+ type: WarDeclaratorBoundUserInterface
+ - type: WarDeclarator
+ message: war-declarator-default-message
+# - type: WarConditionOnExamine
diff --git a/Resources/Prototypes/Roles/Jobs/Fun/misc_startinggear.yml b/Resources/Prototypes/Roles/Jobs/Fun/misc_startinggear.yml
index da494760ab..a1896f135a 100644
--- a/Resources/Prototypes/Roles/Jobs/Fun/misc_startinggear.yml
+++ b/Resources/Prototypes/Roles/Jobs/Fun/misc_startinggear.yml
@@ -128,6 +128,8 @@
pocket1: DoubleEmergencyOxygenTankFilled
pocket2: BaseUplinkRadio40TC
belt: ClothingBeltMilitaryWebbing
+ inhand:
+ right hand: NukeOpsDeclarationOfWar
innerclothingskirt: ClothingUniformJumpskirtOperative
satchel: ClothingBackpackDuffelSyndicateOperative
duffelbag: ClothingBackpackDuffelSyndicateOperative
diff --git a/Resources/Textures/Objects/Devices/declaration_of_war.rsi/declarator.png b/Resources/Textures/Objects/Devices/declaration_of_war.rsi/declarator.png
new file mode 100644
index 0000000000..850edac23b
Binary files /dev/null and b/Resources/Textures/Objects/Devices/declaration_of_war.rsi/declarator.png differ
diff --git a/Resources/Textures/Objects/Devices/declaration_of_war.rsi/meta.json b/Resources/Textures/Objects/Devices/declaration_of_war.rsi/meta.json
new file mode 100644
index 0000000000..a522da13b7
--- /dev/null
+++ b/Resources/Textures/Objects/Devices/declaration_of_war.rsi/meta.json
@@ -0,0 +1,26 @@
+{
+ "version": 1,
+ "license": "CC-BY-SA-3.0",
+ "copyright": "Edit of the door remote sprite by Flareguy for https://github.com/space-wizards/space-station-14",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "declarator",
+ "delays": [
+ [
+ 0.3,
+ 0.3,
+ 0.3,
+ 0.3,
+ 0.3,
+ 0.2,
+ 0.2,
+ 0.2
+ ]
+ ]
+ }
+ ]
+}