Convert Round to Survival when Major Antag Fails (#6127)

* Changed up shuttle calls to make the round convert to survival.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Added CosCult state checking.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* typo

* Fix nuke ops test

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Vanessa 2026-07-05 12:53:02 -05:00 committed by GitHub
parent bc1593f79c
commit 8f8f1dd001
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 227 additions and 25 deletions

View File

@ -10,6 +10,7 @@ using Content.Server.Mind;
using Content.Server.Roles;
using Content.Server.RoundEnd;
using Content.Server.Shuttles.Components;
using Content.Server.StationEvents.Components; // DeltaV
using Content.Shared.CCVar;
using Content.Shared.Damage.Components;
using Content.Shared.Damage.Systems;
@ -259,8 +260,12 @@ public sealed class NukeOpsTest : GameTest
// Delete the last nukie and make sure the round ends.
entMan.DeleteEntity(nukies[^1]);
Assert.That(roundEndSys.IsRoundEndRequested,
"All nukies were deleted, but the round didn't end!");
// BEGIN DeltaV - We convert to survival, so only check if the round ended if its ShuttleCall
if (rule.Component.RoundEndBehavior == RoundEndBehavior.ShuttleCall)
Assert.That(roundEndSys.IsRoundEndRequested, "All nukies were deleted, but the round didn't end!");
if (rule.Component.RoundEndBehavior == RoundEndBehavior.BecomeSurvival)
Assert.That(ticker.IsGameRuleAdded<RampingStationEventSchedulerComponent>(), "All nukies were deleted, but the round wasn't converted to survival.");
// END DeltaV
});
ticker.SetGamePreset((GamePresetPrototype?) null);

View File

@ -15,7 +15,7 @@ public sealed partial class NukeopsRuleComponent : Component
/// What will happen if all of the nuclear operatives will die. Used by LoneOpsSpawn event.
/// </summary>
[DataField]
public RoundEndBehavior RoundEndBehavior = RoundEndBehavior.ShuttleCall;
public RoundEndBehavior RoundEndBehavior = RoundEndBehavior.BecomeSurvival; // DeltaV - Change from ShuttlCall To BecomeSurvival
/// <summary>
/// Text for shuttle call if RoundEndBehavior is ShuttleCall.

View File

@ -1,3 +1,4 @@
using Content.Server.RoundEnd;
using Content.Shared.Roles;
using Robust.Shared.Audio;
using Robust.Shared.Prototypes;
@ -25,4 +26,14 @@ public sealed partial class ZombieRuleComponent : Component
/// </summary>
[DataField]
public float ZombieShuttleCallPercentage = 0.7f;
/// <summary>
/// DeltaV - The behavior of the round if all zombies are defeated.
/// </summary>
public RoundEndBehavior ZombieRoundEndBehavior = RoundEndBehavior.BecomeSurvival;
/// <summary>
/// DeltaV - The amount of time before the evac shuttle will arrive if the ZombieRoundEndBehavior is set to ShuttleCall.
/// </summary>
public TimeSpan ZombieShuttleDelay = TimeSpan.FromMinutes(10);
}

View File

@ -12,6 +12,7 @@ using Content.Server.Shuttles.Events;
using Content.Server.Shuttles.Systems;
using Content.Server.Station.Components;
using Content.Server.Store.Systems;
using Content.Shared.Cuffs.Components; // DeltaV
using Content.Shared.GameTicking.Components;
using Content.Shared.Mobs;
using Content.Shared.Mobs.Components;
@ -61,6 +62,7 @@ public sealed class NukeopsRuleSystem : GameRuleSystem<NukeopsRuleComponent>
SubscribeLocalEvent<NukeOperativeComponent, ComponentRemove>(OnComponentRemove);
SubscribeLocalEvent<NukeOperativeComponent, MobStateChangedEvent>(OnMobStateChanged);
SubscribeLocalEvent<NukeOperativeComponent, EntityZombifiedEvent>(OnOperativeZombified);
SubscribeLocalEvent<NukeOperativeComponent, CuffedStateChangeEvent>(OnNukeOpCuffed); // DeltaV
SubscribeLocalEvent<NukeopsRoleComponent, GetBriefingEvent>(OnGetBriefing);
@ -318,6 +320,15 @@ public sealed class NukeopsRuleSystem : GameRuleSystem<NukeopsRuleComponent>
CheckRoundShouldEnd();
}
/// <summary>
/// DeltaV - Check if the round should end if a nuke op is cuffed. CheckRoundShouldEnd checks if theres any more
/// alive and non-cuffed nukies remaining.
/// </summary>
private void OnNukeOpCuffed(EntityUid uid, NukeOperativeComponent component, CuffedStateChangeEvent ev)
{
CheckRoundShouldEnd();
}
private void OnOperativeZombified(EntityUid uid, NukeOperativeComponent component, ref EntityZombifiedEvent args)
{
RemCompDeferred(uid, component);
@ -462,10 +473,16 @@ public sealed class NukeopsRuleSystem : GameRuleSystem<NukeopsRuleComponent>
private void CheckRoundShouldEnd()
{
var query = QueryActiveRules();
// BEGIN DeltaV - Allow round to become survival
// CheckRoundShouldEnd needs to be outside the query, because it might add an active rule
List<Entity<NukeopsRuleComponent>> nukeOpsRules = new();
while (query.MoveNext(out var uid, out _, out var nukeops, out _))
{
CheckRoundShouldEnd((uid, nukeops));
nukeOpsRules.Add((uid, nukeops));
}
nukeOpsRules.ForEach(CheckRoundShouldEnd);
// END DeltaV
}
private void CheckRoundShouldEnd(Entity<NukeopsRuleComponent> ent)
@ -500,12 +517,22 @@ public sealed class NukeopsRuleSystem : GameRuleSystem<NukeopsRuleComponent>
// Check if there are nuke operatives still alive on the same map as the shuttle,
// or on the same map as the station.
// If there are, the round can continue.
var operatives = EntityQuery<NukeOperativeComponent, MobStateComponent, TransformComponent>(true);
// BEGIN DeltaV - Detect Nukie Failure Better
// We need to use a EntityQueryEnumerator instead of a EntityQuery, because we need the uid to check for cuffs
var operatives = new List<Entity<NukeOperativeComponent, MobStateComponent, TransformComponent>>();
var operativesEnumerator = EntityQueryEnumerator<NukeOperativeComponent, MobStateComponent, TransformComponent>();
while (operativesEnumerator.MoveNext(out var uid, out var nukeOp, out var mobState, out var transform))
{
operatives.Add((uid, nukeOp, mobState, transform));
}
var operativesAlive = operatives
.Where(op =>
op.Item3.MapID == shuttleMapId
|| op.Item3.MapID == targetStationMap)
.Any(op => op.Item2.CurrentState == MobState.Alive && op.Item1.Running);
.Where(op => op.Comp3.MapID == shuttleMapId
|| op.Comp3.MapID == targetStationMap)
.Any(op => op.Comp2.CurrentState == MobState.Alive && op.Comp1.Running &&
!TryComp<CuffableComponent>(op, out _)); // in the case the crew keeps one alive
// END DeltaV
if (operativesAlive)
return; // There are living operatives than can access the shuttle, or are still on the station's map.

View File

@ -3,7 +3,7 @@ using Content.Server.Chat.Systems;
using Content.Server.GameTicking.Rules.Components;
using Content.Server.Popups;
using Content.Server.Roles;
using Content.Server.RoundEnd;
using Content.Server.RoundEnd; // DeltaV
using Content.Server.Station.Systems;
using Content.Server.Zombies;
using Content.Shared.GameTicking.Components;
@ -114,11 +114,27 @@ public sealed class ZombieRuleSystem : GameRuleSystem<ZombieRuleComponent>
/// </summary>
private void CheckRoundEnd(ZombieRuleComponent zombieRuleComponent)
{
// BEGIN DeltaV - Change mode to survival if zombies die
// Zombies have failed to launch and the mode has been switched to survival
if (zombieRuleComponent.ZombieRoundEndBehavior == RoundEndBehavior.Nothing)
return;
// END DeltaV
var healthy = GetHealthyHumans();
if (healthy.Count == 1) // Only one human left. spooky
_popup.PopupEntity(Loc.GetString("zombie-alone"), healthy[0], healthy[0]);
if (GetInfectedFraction(false) > zombieRuleComponent.ZombieShuttleCallPercentage && !_roundEnd.IsRoundEndRequested())
// BEGIN DeltaV - Change mode to survival if zombies die
var infectedPercent = GetInfectedFraction(false);
if (Math.Round(infectedPercent, 0) == 0) // All zombies defeated
{
_roundEnd.DoRoundEndBehavior(zombieRuleComponent.ZombieRoundEndBehavior, zombieRuleComponent.ZombieShuttleDelay);
zombieRuleComponent.ZombieRoundEndBehavior = RoundEndBehavior.Nothing; // stop this check in the future
}
// END DeltaV
if (infectedPercent > zombieRuleComponent.ZombieShuttleCallPercentage && !_roundEnd.IsRoundEndRequested()) // DeltaV - Move GetInfectedFraction to var
{
foreach (var station in _station.GetStations())
{

View File

@ -334,6 +334,11 @@ namespace Content.Server.RoundEnd
{
switch (behavior)
{
// BEGIN DeltaV - Convert Round To Survival
case RoundEndBehavior.BecomeSurvival:
_gameTicker.ConvertRoundToSurvival();
break;
// END DeltaV
case RoundEndBehavior.InstantEnd:
EndRound();
break;
@ -419,6 +424,11 @@ namespace Content.Server.RoundEnd
/// <summary>
/// Do nothing
/// </summary>
Nothing
Nothing,
/// <summary>
/// DeltaV - Replace scheduler. Crew should call for evac.
/// </summary>
BecomeSurvival,
}
}

View File

@ -18,7 +18,7 @@ public sealed partial class CosmicCultRuleComponent : Component
/// What happens if all of the cultists die.
/// </summary>
[DataField]
public RoundEndBehavior RoundEndBehavior = RoundEndBehavior.ShuttleCall;
public RoundEndBehavior RoundEndBehavior = RoundEndBehavior.BecomeSurvival;
/// <summary>
/// Sender for shuttle call.
@ -61,6 +61,45 @@ public sealed partial class CosmicCultRuleComponent : Component
[DataField]
public TimeSpan EvacShuttleTime = TimeSpan.FromMinutes(5);
#region Progress Checking
/// <summary>
/// The next time the monument should check the progress of the cult.
/// </summary>
[DataField]
public TimeSpan NextProgressCheck = TimeSpan.Zero;
/// <summary>
/// The amount of time between progress checks.
/// </summary>
[DataField]
public TimeSpan TimeBetweenProgressChecks = TimeSpan.FromMinutes(15);
/// <summary>
/// The progress value of the monument last time it was checked.
/// </summary>
[DataField]
public int LastProgress = 0;
/// <summary>
/// The number of times the the progress check as failed. Resets after some progress.
/// </summary>
[DataField]
public int ConsecutiveProgressFails = 0;
/// <summary>
/// The number of times the progress check has to fail for the round for round-end behavior to happen.
/// </summary>
[DataField]
public int ProgressFailureTolerance = 3;
[DataField]
public LocId ProgressFailTextShuttleCall = "cosmiccult-progress-fail-shuttle-call";
[DataField]
public LocId ProgressFailTextAnnouncement = "cosmiccult-progress-fail-announcement";
#endregion
[DataField]
public HashSet<EntityUid> Cultists = [];

View File

@ -63,11 +63,15 @@ using Robust.Shared.Timing;
using System.Collections.Immutable;
using System.Linq;
using Content.Shared.Body;
using Content.Shared.StatusIcon;
using Content.Shared.SSDIndicator;
namespace Content.Server._DV.CosmicCult;
/// <summary>
/// Where all the main stuff for Cosmic Cultists happens.
///
/// This should probably be broken up into a partial class.
/// </summary>
public sealed class CosmicCultRuleSystem : GameRuleSystem<CosmicCultRuleComponent>
{
@ -172,6 +176,31 @@ public sealed class CosmicCultRuleSystem : GameRuleSystem<CosmicCultRuleComponen
component.StewardVoteTimer = null;
StewardVote();
}
// Progress check. Is the cult trying to progress at all? Have they gotten to T3?
if (component.MonumentPlaced && component.CurrentTier < 3 && _timing.CurTime > component.NextProgressCheck)
{
// We're going to assume one monument.
var monument = EntityQuery<MonumentComponent>().First();
if (component.LastProgress == monument.CurrentProgress)
component.ConsecutiveProgressFails++;
else
component.ConsecutiveProgressFails = 0;
// They haven't progressed enough in X checks, convert to survival. They can still progress BUT
// if they haven't done ANYTHING in a long time, I doubt they will be a threat all of a sudden.
if (component.ConsecutiveProgressFails >= component.ProgressFailureTolerance)
{
SetWinType((uid, component), WinType.CrewMinor);
_roundEnd.DoRoundEndBehavior(component.RoundEndBehavior, component.EvacShuttleTime, component.RoundEndTextSender, component.ProgressFailTextShuttleCall, component.ProgressFailTextAnnouncement);
component.RoundEndBehavior = RoundEndBehavior.Nothing;
}
component.LastProgress = monument.CurrentProgress;
component.NextProgressCheck = _timing.CurTime + component.TimeBetweenProgressChecks;
}
if (component.ExtraRiftTimer is { } riftTimer && _timing.CurTime >= riftTimer && !component.RiftStop)
{
component.ExtraRiftTimer = _timing.CurTime + _rand.Next(TimeSpan.FromSeconds(230), TimeSpan.FromSeconds(360)); //3min50 to 6min between new rifts. Seconds instead of minutes for granularity.
@ -442,17 +471,19 @@ public sealed class CosmicCultRuleSystem : GameRuleSystem<CosmicCultRuleComponen
}
}
private bool CultistsAlive()
private int GetCultistsAlive()
{
var query = EntityQueryEnumerator<CosmicCultComponent, MobStateComponent>();
int cultistsAlive = 0;
while (query.MoveNext(out var ent, out _, out var mobComp))
{
if (TryComp<CuffableComponent>(ent, out var cuffed) && cuffed.CuffedHandCount > 0) continue;
if (TryComp<SSDIndicatorComponent>(ent, out var ssd) && ssd.IsSSD) continue; // TODO: Maybe check how long.
if (mobComp.Running && mobComp.CurrentState != MobState.Dead)
return true;
cultistsAlive++;
}
return false;
return cultistsAlive;
}
private void OnMobStateChanged(Entity<CosmicCultComponent> ent, ref MobStateChangedEvent args)
@ -467,21 +498,26 @@ public sealed class CosmicCultRuleSystem : GameRuleSystem<CosmicCultRuleComponen
private void CheckForActiveCultists()
{
if (CultistsAlive())
// A lone cultist can't do anything, so check for more than one.
if (GetCultistsAlive() > 1)
return;
var query = QueryActiveRules();
List<Entity<CosmicCultRuleComponent>> cosCultRules = new();
// ConfirmWinState needs to be outside the query, because it might add an active rule
while (query.MoveNext(out var ruleUid, out _, out var ruleComp, out _))
{
ConfirmWinState((ruleUid, ruleComp));
cosCultRules.Add((ruleUid, ruleComp));
}
cosCultRules.ForEach(ConfirmWinState);
}
private void ConfirmWinState(Entity<CosmicCultRuleComponent> ent)
{
var tier = ent.Comp.CurrentTier;
var LeaderAtCentcom = false;
var CultistsAtCentcom = 0;
var leaderAtCentcom = false;
var cultistsAtCentcom = 0;
var centcomm = _emergency.GetCentcommMaps();
var wrapup = AllEntityQuery<CosmicCultComponent, TransformComponent>();
while (wrapup.MoveNext(out var cultist, out _, out var cultistLocation))
@ -489,22 +525,22 @@ public sealed class CosmicCultRuleSystem : GameRuleSystem<CosmicCultRuleComponen
if (cultistLocation.MapUid != null && centcomm.Contains(cultistLocation.MapUid.Value))
{
if (TryComp<CuffableComponent>(cultist, out var cuffed) && cuffed.CuffedHandCount > 0) continue; // If they are cuffed, they should be deconverted soon, so we don't count them.
CultistsAtCentcom++;
cultistsAtCentcom++;
if (HasComp<CosmicCultLeadComponent>(cultist))
LeaderAtCentcom = true;
leaderAtCentcom = true;
}
}
if (tier < 3)
SetWinType(ent, WinType.CrewMinor); //The monument didn't even reach tier 3, which means that either cult had a skill issue, or crew evacuated early. Minor win.
else if (LeaderAtCentcom) //If the monument reached tier 3, all cultists have glowing eyes now, so you shouldn't let them evacuate without cuffs on.
else if (leaderAtCentcom) //If the monument reached tier 3, all cultists have glowing eyes now, so you shouldn't let them evacuate without cuffs on.
SetWinType(ent, WinType.CultMajor); //The Monument wasn't completed, but the cult leader's alive and at Midpoint.
else if (CultistsAtCentcom >= 2)
else if (cultistsAtCentcom >= 2)
SetWinType(ent, WinType.CultMinor); //The Monument wasn't completed, but at least two cultists are alive and at Midpoint.
else
SetWinType(ent, WinType.Neutral); //The monument wasn't completed, no cultists escaped to midpoint. Some cultists still remain on the station, though.
if (CultistsAlive()) return; //If there are no cultists alive, ignore all previous checks, crew alreay won.
if (GetCultistsAlive() > 1) return; //If there are one or less cultists alive, ignore all previous checks, crew alreay won.
if (tier <= 1) //Prevent the cult getting cooked by accident before anyone even knows there's a cult.
{

View File

@ -0,0 +1,56 @@
using System.Linq;
using Content.Server.Administration;
using Content.Server.GameTicking.Rules.Components;
using Content.Shared.Administration;
using Content.Shared.Database;
using Content.Shared.GameTicking.Components;
using Content.Shared.Prototypes;
using JetBrains.Annotations;
using Robust.Shared.Console;
using Robust.Shared.Map;
using Robust.Shared.Prototypes;
using Robust.Shared.Localization;
using Content.Shared.Fax;
using Content.Server.StationEvents.Components;
namespace Content.Server.GameTicking;
/// <summary>
/// Extends upstream's <see cref="GameTicker" />.
/// </summary>
public sealed partial class GameTicker
{
private static readonly EntProtoId RampingSchedulerProto = "RampingStationEventScheduler";
private static readonly TimeSpan GracePeriod = TimeSpan.FromMinutes(10);
/// <summary>
/// DeltaV - Removes the basic scheduler and adds a ramping scheduler to the round. Does nothing
/// if there is already a ramping scheduler.
/// </summary>
/// <returns>True if the game rules either contain or added a RampingStationEventScheduler.</returns>
[PublicAPI]
public bool ConvertRoundToSurvival()
{
// Ramping scheduler is already added. Do nothing.
if (IsGameRuleActive(RampingSchedulerProto))
{
_chatManager.SendAdminAlert("RampingStationEventScheduler detected. No rules added.");
return true;
}
// Add a ramping scheduler with a delay.
var rampingScheduler = AddGameRule(RampingSchedulerProto);
var rampingSchedulerStart = _gameTiming.CurTime.Add(GracePeriod);
EnsureComp<DelayedStartRuleComponent>(rampingScheduler).RuleStartTime = rampingSchedulerStart;
_chatManager.SendAdminAlert($"Major antag defeated. Converting to survival at {rampingSchedulerStart}.");
// End Basic Rules
var basicRules = EntityQueryEnumerator<BasicStationEventSchedulerComponent>();
while (basicRules.MoveNext(out var uid, out var rule))
EndGameRule(uid);
return IsGameRuleActive(RampingSchedulerProto);
}
}

View File

@ -65,6 +65,8 @@ cosmiccult-summary-crewcomplete = All cosmic cultists were deconverted!
cosmiccult-elimination-shuttle-call = Based on scans from our long-range sensors, the Λ-CDM anomaly has subsided. We thank you for your prudence. An emergency shuttle has been automatically called to the station for decontamination and debriefing procedures. ETA: {$time} {$units}. Please note, if the psychological impact of the anomaly is negligible, you may recall the shuttle to extend the shift.
cosmiccult-elimination-announcement = Based on scans from our long-range sensors, the Λ-CDM anomaly has subsided. We thank you for your prudence. An emergency shuttle is already inbound. Return to CentComm safely for decontamination and debriefing procedures.
cosmiccult-progress-fail-shuttle-call = Due to sustained Λ-CDM activity on you station, an emergency shuttle has been automatically called so that the station may be decontaminated as soon as possible. We thank you for your cooperation.
cosmiccult-progress-fail-announcement = Due to sustained Λ-CDM activity on you station, an emergency shuttle is already inbound. Please ensure as much of the station is ready and accessible for the decontamination team. We thank you for your cooperation.
## BRIEFINGS