Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions Content.Server/Anomaly/Effects/PolymorphAnomalySystem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
using System.Linq;
using Content.Server.Polymorph.Systems;
using Content.Shared.Anomaly.Components;
using Content.Shared.Anomaly.Effects.Components;
using Content.Shared.Anomaly.Prototypes;
using Content.Shared.FixedPoint;
using Content.Shared.Inventory;
using Content.Shared.Mobs;
using Content.Shared.Mobs.Components;
using Content.Shared.Mobs.Systems;
using Content.Shared.Polymorph;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Map;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;

namespace Content.Server.Anomaly.Effects;

/// <summary>
/// This handles <see cref="PolymorphAnomalyComponent"/>, forcibly polymorphing everything
/// alive in range into a random entity for a random duration whenever the anomaly pulses.
/// </summary>
public sealed class PolymorphAnomalySystem : EntitySystem
{
[Dependency] private readonly IPrototypeManager _proto = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly EntityLookupSystem _lookup = default!;
[Dependency] private readonly MobStateSystem _mobState = default!;
[Dependency] private readonly MobThresholdSystem _mobThreshold = default!;
[Dependency] private readonly PolymorphSystem _polymorph = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly SharedTransformSystem _transform = default!;

/// <summary>
/// Fallback thresholds applied to any polymorph destination that turns out to have no
/// MobState/MobThresholds configured at all (a content gap on that specific prototype).
/// Without this, such a creature is structurally incapable of ever reaching Critical/Dead,
/// no matter how much damage it takes, and revert-on-crit/death would never fire for it.
/// These are rough generic values, not tuned per-species - tune the actual prototype's own
/// MobThresholds later and this fallback will simply stop triggering for it.
/// </summary>
private const float FallbackCriticalThreshold = 45f;
private const float FallbackDeadThreshold = 65f;

/// <summary> Pre-allocated and re-used collection.</summary>
private readonly HashSet<Entity<MobStateComponent>> _targets = new();

public override void Initialize()
{
base.Initialize();

SubscribeLocalEvent<PolymorphAnomalyComponent, AnomalyPulseEvent>(OnPulse);
SubscribeLocalEvent<PolymorphAnomalyComponent, AnomalySupercriticalEvent>(OnSupercritical);
}

private void OnPulse(Entity<PolymorphAnomalyComponent> ent, ref AnomalyPulseEvent args)
{
if (!_proto.TryIndex(ent.Comp.PolymorphTable, out var table))
return;

var coordinates = _transform.GetMapCoordinates(ent);
PolymorphInRange(ent, table, coordinates, ent.Comp.Range, args.Severity, permanentChance: 0f);
}

private void OnSupercritical(Entity<PolymorphAnomalyComponent> ent, ref AnomalySupercriticalEvent args)
{
if (!_proto.TryIndex(ent.Comp.PolymorphTable, out var table))
return;

var coordinates = _transform.GetMapCoordinates(ent);

PolymorphInRange(ent, table, coordinates, ent.Comp.SupercriticalRange, severity: 1f, ent.Comp.SupercriticalPermanentChance);
}

/// <summary>
/// Grabs everything alive in range and forcibly polymorphs each of them independently,
/// rolling a separate outcome and duration per victim.
/// </summary>
private void PolymorphInRange(
Entity<PolymorphAnomalyComponent> ent,
PolymorphAnomalyTablePrototype table,
MapCoordinates coordinates,
float range,
float severity,
float permanentChance)
{
if (table.Options.Count == 0)
return;

_targets.Clear();
_lookup.GetEntitiesInRange(coordinates, range, _targets);

foreach (var target in _targets)
{
if (!_mobState.IsAlive(target))
continue;

var option = PickOption(table);
if (!_proto.TryIndex(option.Polymorph, out var polymorphProto))
continue;

var permanent = permanentChance > 0f && _random.Prob(permanentChance);

// TransferEntityInventories requires BOTH the source (the thing being polymorphed)
// AND the destination (the new form it's spawning into) to have InventoryComponent
var sourceHasInventory = HasComp<InventoryComponent>(target);
var destHasInventory = _proto.Index<EntityPrototype>(polymorphProto.Configuration.Entity)
.TryGetComponent<InventoryComponent>(out _, EntityManager.ComponentFactory);
var hasInventory = sourceHasInventory && destHasInventory;

var config = polymorphProto.Configuration with
{
Forced = true,
Inventory = hasInventory ? PolymorphInventoryChange.Transfer : PolymorphInventoryChange.None,
RevertOnCrit = true,
RevertOnDeath = true,
AllowRepeatedMorphs = true,
Duration = permanent ? null : (int)RollDuration(option, severity).TotalSeconds,
};

var child = _polymorph.PolymorphEntity(target, config);
if (child != null)
EnsureCanDieOrCrit(child.Value);
}

if (ent.Comp.PolymorphSound != null)
_audio.PlayPvs(ent.Comp.PolymorphSound, ent);
}

/// <summary>
/// Safety net for polymorph destinations that are missing MobState/MobThresholds entirely -
/// without this, RevertOnCrit/RevertOnDeath can never fire for them since there's no data
/// telling the game at what damage value to change state. Only fills in fallback values if
/// the entity has none configured at all; a prototype with its own real MobThresholds is
/// left completely untouched.
/// </summary>
private void EnsureCanDieOrCrit(EntityUid child)
{
if (_mobThreshold.TryGetDeadThreshold(child, out _))
return;

EnsureComp<MobStateComponent>(child);
EnsureComp<MobThresholdsComponent>(child);

_mobThreshold.SetMobStateThreshold(child, FixedPoint2.Zero, MobState.Alive);
_mobThreshold.SetMobStateThreshold(child, FallbackCriticalThreshold, MobState.Critical);
_mobThreshold.SetMobStateThreshold(child, FallbackDeadThreshold, MobState.Dead);
}

/// <summary>
/// Picks a random option from the table, weighted by <see cref="PolymorphAnomalyOption.Weight"/>.
/// </summary>
private PolymorphAnomalyOption PickOption(PolymorphAnomalyTablePrototype table)
{
var totalWeight = table.Options.Sum(o => o.Weight);
var roll = _random.NextFloat() * totalWeight;

var cumulative = 0f;
foreach (var option in table.Options)
{
cumulative += option.Weight;
if (roll <= cumulative)
return option;
}

// Floating point rounding fallback.
return table.Options[^1];
}

/// <summary>
/// Rolls a duration between an option's min and max, biased towards the max end
/// as anomaly severity increases.
/// </summary>
private TimeSpan RollDuration(PolymorphAnomalyOption option, float severity)
{
var t = _random.NextFloat();

var exponent = MathF.Max(0.1f, 1f - Math.Clamp(severity, 0f, 1f));
var biasedT = MathF.Pow(t, exponent);

var seconds = MathHelper.Lerp((float)option.MinDuration.TotalSeconds, (float)option.MaxDuration.TotalSeconds, biasedT);
return TimeSpan.FromSeconds(seconds);
}
}
95 changes: 82 additions & 13 deletions Content.Server/Polymorph/Systems/PolymorphSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,27 @@ public sealed partial class PolymorphSystem : EntitySystem

private const string RevertPolymorphId = "ActionRevertPolymorph";

/// <summary>
/// Tracks every currently-polymorphed entity ourselves, since EntityQueryEnumerator silently
/// skips paused entities and we don't want to force them to stay awake just to be checked -
/// that would undo the whole point of NPCs going to sleep when idle. Added/removed via the
/// component's own lifecycle, so it stays accurate regardless of pause state.
/// </summary>
private readonly HashSet<EntityUid> _polymorphed = new();

/// <summary>
/// Reusable snapshot buffer. Revert() can remove entries from _polymorphed mid-iteration
/// (deleting the old entity fires ComponentShutdown), so iterating the live set directly
/// isn't safe - we copy it first each tick.
/// </summary>
private readonly List<EntityUid> _updateBuffer = new();

public override void Initialize()
{
SubscribeLocalEvent<PolymorphableComponent, ComponentStartup>(OnComponentStartup);
SubscribeLocalEvent<PolymorphedEntityComponent, MapInitEvent>(OnMapInit);
SubscribeLocalEvent<PolymorphedEntityComponent, ComponentStartup>(OnPolymorphedStartup);
SubscribeLocalEvent<PolymorphedEntityComponent, ComponentShutdown>(OnPolymorphedShutdown);

SubscribeLocalEvent<PolymorphableComponent, PolymorphActionEvent>(OnPolymorphActionEvent);
SubscribeLocalEvent<PolymorphedEntityComponent, RevertPolymorphActionEvent>(OnRevertPolymorphActionEvent);
Expand All @@ -61,13 +78,31 @@ public override void Initialize()
InitializeMap();
}

private void OnPolymorphedStartup(Entity<PolymorphedEntityComponent> ent, ref ComponentStartup args)
{
_polymorphed.Add(ent);
}

private void OnPolymorphedShutdown(Entity<PolymorphedEntityComponent> ent, ref ComponentShutdown args)
{
_polymorphed.Remove(ent);
}

public override void Update(float frameTime)
{
base.Update(frameTime);
if (_polymorphed.Count == 0)
return;

// Snapshot first - Revert() can remove entries from _polymorphed mid-loop.
_updateBuffer.Clear();
_updateBuffer.AddRange(_polymorphed);

var query = EntityQueryEnumerator<PolymorphedEntityComponent>();
while (query.MoveNext(out var uid, out var comp))
foreach (var uid in _updateBuffer)
{
if (!TryComp<PolymorphedEntityComponent>(uid, out var comp) || comp.Reverted)
continue;

comp.Time += frameTime;

if (comp.Configuration.Duration != null && comp.Time >= comp.Configuration.Duration)
Expand All @@ -82,7 +117,7 @@ public override void Update(float frameTime)
if (comp.Configuration.RevertOnDeath && _mobState.IsDead(uid, mob) ||
comp.Configuration.RevertOnCrit && _mobState.IsIncapacitated(uid, mob))
{
Revert((uid, comp));
var result = Revert((uid, comp));
}
}
}
Expand Down Expand Up @@ -145,7 +180,7 @@ private void OnDestruction(Entity<PolymorphedEntityComponent> ent, ref Destructi
if (ent.Comp.Reverted || !ent.Comp.Configuration.RevertOnDeath)
return;

Revert((ent, ent));
var result = Revert((ent, ent));
}

private void OnPolymorphedTerminating(Entity<PolymorphedEntityComponent> ent, ref EntityTerminatingEvent args)
Expand All @@ -154,7 +189,9 @@ private void OnPolymorphedTerminating(Entity<PolymorphedEntityComponent> ent, re
return;

if (ent.Comp.Configuration.RevertOnDelete)
Revert(ent.AsNullable());
{
var result = Revert(ent.AsNullable());
}

// Remove our original entity too
// Note that Revert will set Parent to null, so reverted entities will not be deleted
Expand All @@ -181,9 +218,12 @@ private void OnPolymorphedTerminating(Entity<PolymorphedEntityComponent> ent, re
public EntityUid? PolymorphEntity(EntityUid uid, PolymorphConfiguration configuration)
{
// If they're morphed, check their current config to see if they can be
// morphed again
// morphed again. TryComp is called unconditionally (rather than inline in the &&
// chain) so currentPoly is always definitely-assigned for later use tracking chained
// re-polymorphs, regardless of whether IgnoreAllowRepeatedMorphs short-circuits this.
TryComp<PolymorphedEntityComponent>(uid, out var currentPoly);
if (!configuration.IgnoreAllowRepeatedMorphs
&& TryComp<PolymorphedEntityComponent>(uid, out var currentPoly)
&& currentPoly != null
&& !currentPoly.Configuration.AllowRepeatedMorphs)
return null;

Expand Down Expand Up @@ -212,8 +252,16 @@ private void OnPolymorphedTerminating(Entity<PolymorphedEntityComponent> ent, re

_mindSystem.MakeSentient(child);

// If uid is itself already a polymorphed form (a chained re-polymorph), the new form's Parent must point at the
// TRUE original entity, not this intermediate form - otherwise each further re-polymorph
// only remembers one link back, and Revert() eventually unwinds to some intermediate
// creature instead of all the way back to what the victim actually started as.
var trueOriginal = uid;
if (currentPoly != null && currentPoly.Parent != null)
trueOriginal = currentPoly.Parent.Value;

var polymorphedComp = Factory.GetComponent<PolymorphedEntityComponent>();
polymorphedComp.Parent = uid;
polymorphedComp.Parent = trueOriginal;
polymorphedComp.Configuration = configuration;
AddComp(child, polymorphedComp);

Expand Down Expand Up @@ -268,10 +316,22 @@ private void OnPolymorphedTerminating(Entity<PolymorphedEntityComponent> ent, re
if (_mindSystem.TryGetMind(uid, out var mindId, out var mind))
_mindSystem.TransferTo(mindId, child, mind: mind);

//Ensures a map to banish the entity to
EnsurePausedMap();
if (PausedMap != null)
_transform.SetParent(uid, targetTransformComp, PausedMap.Value);
if (currentPoly != null)
{
// uid was itself an intermediate polymorph form - the true original is already
// safely parked from the very first polymorph in this chain, so this husk serves
// no purpose anymore. Mark it Reverted first so OnPolymorphedTerminating doesn't
// try to revert it a second time
currentPoly.Reverted = true;
QueueDel(uid);
}
else
{
//Ensures a map to banish the entity to
EnsurePausedMap();
if (PausedMap != null)
_transform.SetParent(uid, targetTransformComp, PausedMap.Value);
}

// Raise an event to inform anything that wants to know about the entity swap
var ev = new PolymorphedEvent(uid, child, false);
Expand All @@ -293,23 +353,32 @@ private void OnPolymorphedTerminating(Entity<PolymorphedEntityComponent> ent, re
{
var (uid, component) = ent;
if (!Resolve(ent, ref component))
{
return null;
}

if (Deleted(uid))
{
return null;
}

if (component.Parent is not { } parent)
{
return null;
}

if (Deleted(parent))
{
return null;
}

var uidXform = Transform(uid);
var parentXform = Transform(parent);

// Don't swap back onto a terminating grid
if (TerminatingOrDeleted(uidXform.ParentUid))
{
return null;
}

if (component.Configuration.ExitPolymorphSound != null)
_audio.PlayPvs(component.Configuration.ExitPolymorphSound, uidXform.Coordinates);
Expand Down
Loading
Loading