using SoulHarvest.Items;
using SoulHarvest.Projectiles;
using System;
using System.Collections.Generic;
using System.IO;
using Terraria;
using Terraria.DataStructures;
using Terraria.ID;
using Terraria.ModLoader;
using Terraria.ModLoader.IO;
namespace SoulHarvest.Common;
public class MyGlobalProjectile : GlobalProjectile
{
public override bool InstancePerEntity => true;
public bool IsSickleProjectile { get; private set; }
public bool IsDeathDomainProjectile { get; private set; }
public int LifeStealLevel { get; private set; }
public int ArmorPenetrationLevel { get; private set; }
public int ProjectileDurationLevel { get; private set; } = 1;
public int HitCooldownFrames { get; private set; }
public int OnHitDebuffType { get; private set; }
public int OnHitDebuffDuration { get; private set; }
public int FatedStacksPerHit { get; private set; }
public int FatedMaximumStacks { get; private set; }
public int FatedDurationFrames { get; private set; }
public bool HasReaperSnapshot { get; private set; }
public SickleCombatSnapshot ReaperSnapshot { get; private set; }
public ReaperHitKind ReaperHitKind { get; private set; }
public int ReaperPhase { get; private set; }
public int ReaperActionId { get; private set; }
public float AttackAngle { get; private set; }
private readonly Dictionary<int, int> reaperRootHitCounts = [];
private readonly HashSet<int> reaperPrimaryEffectRoots = [];
private bool serverAuthorizedReaper;
private int authoritativeDamage;
private int authoritativeOriginalDamage;
private int authoritativeCritChance;
private float authoritativeKnockback;
private readonly float[] authoritativeAI = new float[3];
public override void OnSpawn(Projectile projectile, IEntitySource source)
{
if (source is EntitySource_ItemUse_WithAmmo itemSource && itemSource.Item.ModItem is NormalSickle sickle)
{
LifeStealLevel = sickle.LifeStealLevel;
ArmorPenetrationLevel = sickle.ArmorPenetrationLevel;
ProjectileDurationLevel = sickle.ProjectileDurationLevel;
HitCooldownFrames = sickle.HitCooldownFrames;
OnHitDebuffType = sickle.OnHitDebuffType;
OnHitDebuffDuration = sickle.OnHitDebuffDuration;
FatedStacksPerHit = sickle.FatedStacksPerHit;
FatedMaximumStacks = sickle.FatedMaximumStacks;
FatedDurationFrames = sickle.FatedDurationFrames;
}
else if (source is EntitySource_ItemUse_WithAmmo legacySource && legacySource.Item.ModItem is LegacyDeath)
{
HitCooldownFrames = NormalSickle.BaseHitCooldownFrames;
}
else if (source is EntitySource_Parent parentSource
&& parentSource.Entity is Projectile parentProjectile
&& parentProjectile.GetGlobalProjectile<MyGlobalProjectile>() is { IsSickleProjectile: true } parentGlobal)
{
LifeStealLevel = parentGlobal.LifeStealLevel;
ArmorPenetrationLevel = parentGlobal.ArmorPenetrationLevel;
ProjectileDurationLevel = parentGlobal.ProjectileDurationLevel;
HitCooldownFrames = parentGlobal.HitCooldownFrames;
OnHitDebuffType = parentGlobal.OnHitDebuffType;
OnHitDebuffDuration = parentGlobal.OnHitDebuffDuration;
FatedStacksPerHit = parentGlobal.FatedStacksPerHit;
FatedMaximumStacks = parentGlobal.FatedMaximumStacks;
FatedDurationFrames = parentGlobal.FatedDurationFrames;
}
else
{
return;
}
IsSickleProjectile = true;
projectile.ArmorPenetration = 0;
projectile.usesIDStaticNPCImmunity = false;
projectile.usesLocalNPCImmunity = true;
projectile.localNPCHitCooldown = HitCooldownFrames;
ScaleProjectileLifetime(projectile);
}
public override void SendExtraAI(Projectile projectile, BitWriter bitWriter, BinaryWriter binaryWriter)
{
bitWriter.WriteBit(IsSickleProjectile);
if (!IsSickleProjectile)
return;
bitWriter.WriteBit(HasReaperSnapshot);
binaryWriter.Write((byte)LifeStealLevel);
binaryWriter.Write((byte)ArmorPenetrationLevel);
binaryWriter.Write((byte)ProjectileDurationLevel);
binaryWriter.Write((byte)HitCooldownFrames);
binaryWriter.Write((ushort)OnHitDebuffType);
binaryWriter.Write((ushort)OnHitDebuffDuration);
binaryWriter.Write((byte)FatedStacksPerHit);
binaryWriter.Write((byte)FatedMaximumStacks);
binaryWriter.Write((ushort)FatedDurationFrames);
if (HasReaperSnapshot)
{
ReaperSnapshot.Write(binaryWriter);
binaryWriter.Write((byte)ReaperHitKind);
binaryWriter.Write((byte)Math.Clamp(ReaperPhase, 0, byte.MaxValue));
binaryWriter.Write(ReaperActionId);
binaryWriter.Write(AttackAngle);
}
}
public override void ReceiveExtraAI(Projectile projectile, BitReader bitReader, BinaryReader binaryReader)
{
bool incomingSickleProjectile = bitReader.ReadBit();
if (!incomingSickleProjectile)
{
if (Main.netMode != NetmodeID.Server)
IsSickleProjectile = false;
return;
}
bool incomingReaperSnapshot = bitReader.ReadBit();
int incomingLifeSteal = binaryReader.ReadByte();
int incomingArmorPenetration = binaryReader.ReadByte();
int incomingDuration = Math.Clamp((int)binaryReader.ReadByte(), 1, NormalSickle.MaxProjectileDurationLevel);
int incomingHitCooldown = binaryReader.ReadByte();
int incomingDebuffType = binaryReader.ReadUInt16();
int incomingDebuffDuration = binaryReader.ReadUInt16();
int incomingFatedStacks = binaryReader.ReadByte();
int incomingFatedMaximum = binaryReader.ReadByte();
int incomingFatedDuration = binaryReader.ReadUInt16();
SickleCombatSnapshot incomingSnapshot = default;
ReaperHitKind incomingHitKind = default;
int incomingPhase = 0;
int incomingActionId = 0;
float incomingAttackAngle = 0f;
if (incomingReaperSnapshot)
{
incomingSnapshot = SickleCombatSnapshot.Read(binaryReader);
incomingHitKind = (ReaperHitKind)binaryReader.ReadByte();
incomingPhase = binaryReader.ReadByte();
incomingActionId = binaryReader.ReadInt32();
incomingAttackAngle = binaryReader.ReadSingle();
}
// Clients never get to replace combat snapshots or legacy upgrade values
// on the authoritative simulation. Reading above only consumes the packet.
if (Main.netMode == NetmodeID.Server)
return;
IsSickleProjectile = true;
HasReaperSnapshot = incomingReaperSnapshot;
LifeStealLevel = incomingLifeSteal;
ArmorPenetrationLevel = incomingArmorPenetration;
ProjectileDurationLevel = incomingDuration;
HitCooldownFrames = incomingHitCooldown;
OnHitDebuffType = incomingDebuffType;
OnHitDebuffDuration = incomingDebuffDuration;
FatedStacksPerHit = incomingFatedStacks;
FatedMaximumStacks = incomingFatedMaximum;
FatedDurationFrames = incomingFatedDuration;
if (incomingReaperSnapshot)
{
ReaperSnapshot = incomingSnapshot;
ReaperHitKind = incomingHitKind;
ReaperPhase = incomingPhase;
ReaperActionId = incomingActionId;
AttackAngle = incomingAttackAngle;
}
projectile.ArmorPenetration = 0;
projectile.usesIDStaticNPCImmunity = false;
projectile.usesLocalNPCImmunity = true;
projectile.localNPCHitCooldown = HitCooldownFrames;
}
internal void ConfigureDeathDomainProjectile(Projectile projectile, NormalSickle? sickle)
{
IsDeathDomainProjectile = true;
IsSickleProjectile = true;
if (sickle is not null)
{
LifeStealLevel = sickle.LifeStealLevel;
ArmorPenetrationLevel = sickle.ArmorPenetrationLevel;
ProjectileDurationLevel = sickle.ProjectileDurationLevel;
HitCooldownFrames = sickle.HitCooldownFrames;
OnHitDebuffType = sickle.OnHitDebuffType;
OnHitDebuffDuration = sickle.OnHitDebuffDuration;
FatedStacksPerHit = sickle.FatedStacksPerHit;
FatedMaximumStacks = sickle.FatedMaximumStacks;
FatedDurationFrames = sickle.FatedDurationFrames;
}
else
{
HitCooldownFrames = NormalSickle.BaseHitCooldownFrames;
}
projectile.ArmorPenetration = 0;
projectile.usesIDStaticNPCImmunity = false;
projectile.usesLocalNPCImmunity = true;
projectile.localNPCHitCooldown = HitCooldownFrames;
ScaleProjectileLifetime(projectile);
projectile.netUpdate = true;
}
internal void ConfigureReaperProjectile(Projectile projectile, in SickleCombatSnapshot snapshot,
ReaperHitKind hitKind, int phase, float attackAngle, int actionId = -1)
{
IsSickleProjectile = true;
HasReaperSnapshot = true;
ReaperSnapshot = snapshot;
ReaperHitKind = hitKind;
ReaperPhase = Math.Max(0, phase);
ReaperActionId = actionId >= 0 ? actionId : projectile.identity;
AttackAngle = attackAngle;
LifeStealLevel = 0;
ArmorPenetrationLevel = snapshot.ArmorPenetrationLevel * 4;
ProjectileDurationLevel = 1;
HitCooldownFrames = hitKind == ReaperHitKind.ReturningArc ? 18 : NormalSickle.BaseHitCooldownFrames;
OnHitDebuffType = 0;
OnHitDebuffDuration = 0;
FatedStacksPerHit = 0;
FatedMaximumStacks = 0;
FatedDurationFrames = 0;
reaperRootHitCounts.Clear();
projectile.ArmorPenetration = 0;
projectile.usesIDStaticNPCImmunity = false;
projectile.usesLocalNPCImmunity = true;
projectile.localNPCHitCooldown = HitCooldownFrames;
AuthorizeServerReaperProjectile(projectile);
projectile.netUpdate = true;
}
internal void UpdateReaperAttackAngle(float attackAngle)
{
if (HasReaperSnapshot && float.IsFinite(attackAngle))
AttackAngle = attackAngle;
}
internal void AuthorizeServerReaperProjectile(Projectile projectile)
{
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
serverAuthorizedReaper = true;
authoritativeDamage = Math.Max(0, projectile.damage);
authoritativeOriginalDamage = Math.Max(0, projectile.originalDamage);
authoritativeCritChance = Math.Clamp(projectile.CritChance, 0, 100);
authoritativeKnockback = float.IsFinite(projectile.knockBack) ? Math.Max(0f, projectile.knockBack) : 0f;
for (int index = 0; index < authoritativeAI.Length; index++)
authoritativeAI[index] = float.IsFinite(projectile.ai[index]) ? projectile.ai[index] : 0f;
}
public override void AI(Projectile projectile)
{
if (Main.netMode != NetmodeID.Server)
return;
bool requiresAuthorization = projectile.ModProjectile is ReaperActionControllerProjectile
or ReaperStrikeProjectile
or ReaperFrostShardProjectile
or SoulSickleProjectile
or ReaperSoulFamiliarProjectile
or ReaperSoulShardProjectile
or ReaperVoidRiftProjectile
or ReaperVoidBlackHoleProjectile
or ReaperBloodScarProjectile
or ReaperBloodDrainFieldProjectile
or ReaperBloodBladeProjectile
or BloodCrescentProjectile
or DeathSickleProjectile;
if (requiresAuthorization && !serverAuthorizedReaper)
{
projectile.Kill();
return;
}
if (serverAuthorizedReaper)
{
projectile.damage = authoritativeDamage;
projectile.originalDamage = authoritativeOriginalDamage;
projectile.CritChance = authoritativeCritChance;
projectile.knockBack = authoritativeKnockback;
for (int index = 0; index < authoritativeAI.Length; index++)
projectile.ai[index] = authoritativeAI[index];
}
}
public override bool? CanHitNPC(Projectile projectile, NPC target)
{
if (!HasReaperSnapshot)
return null;
int root = target.realLife >= 0 ? target.realLife : target.whoAmI;
int maximumHits = ReaperHitKind switch
{
ReaperHitKind.DamageOverTime => int.MaxValue,
ReaperHitKind.ReturningArc => 2,
ReaperHitKind.Ultimate when projectile.ModProjectile is ReaperBloodBladeProjectile => 5,
_ => 1
};
return !reaperRootHitCounts.TryGetValue(root, out int hits)
|| hits < maximumHits ? null : false;
}
public override void ModifyHitNPC(Projectile projectile, NPC target, ref NPC.HitModifiers modifiers)
{
bool sickleHit = IsSickleProjectile || projectile.ModProjectile is SickleSwingProjectile;
if (!sickleHit || projectile.owner < 0 || projectile.owner >= Main.maxPlayers)
return;
modifiers.ScalingArmorPenetration += HasReaperSnapshot
? Math.Clamp(ReaperSnapshot.ArmorPenetrationLevel * 0.06f, 0f, 0.3f)
: Math.Clamp(ArmorPenetrationLevel, 0, NormalSickle.MaxArmorPenetrationLevel) / 100f;
if (HasReaperSnapshot)
{
NPC statusTarget = ReaperCombatService.GetStatusTarget(target);
statusTarget.GetGlobalNPC<ReaperCombatGlobalNPC>().ModifyReaperHit(
projectile.owner, ReaperSnapshot, ReaperHitKind, ReaperPhase, ReaperActionId, statusTarget, ref modifiers);
}
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
// Register directly on the authoritative simulation before damage and OnKill resolve.
target.GetGlobalNPC<MyGlobalNPC>().RegisterSickleHit(target, projectile.owner);
}
public override void OnHitNPC(Projectile projectile, NPC target, NPC.HitInfo hit, int damageDone)
{
if ((!IsSickleProjectile && projectile.ModProjectile is not SickleSwingProjectile)
|| projectile.owner < 0
|| projectile.owner >= Main.maxPlayers)
return;
int uniqueHitIndex = 0;
if (HasReaperSnapshot)
{
int root = target.realLife >= 0 ? target.realLife : target.whoAmI;
reaperRootHitCounts.TryGetValue(root, out int previousHits);
reaperRootHitCounts[root] = previousHits + 1;
uniqueHitIndex = reaperRootHitCounts.Count;
if (ReaperHitKind == ReaperHitKind.Primary)
TryApplyPrimaryHitEffects(projectile, target, damageDone,
uniqueHitIndex);
else
ReaperCombatService.OnReaperHit(projectile, target, damageDone,
this, uniqueHitIndex);
}
if (Main.netMode == NetmodeID.MultiplayerClient)
{
if (projectile.owner != Main.myPlayer)
return;
int healedLife = Main.player[projectile.owner].GetModPlayer<MyPlayer>().TryLifeSteal(damageDone, LifeStealLevel);
LifeStealVisuals.Spawn(target.Center, projectile.owner, LifeStealLevel, healedLife);
return;
}
if (Main.netMode == NetmodeID.Server)
{
// Status and soul ownership must be committed by the authoritative
// simulation. The old early return left multiplayer sickle hits with
// client visuals but no Death Mark (and therefore no soul harvest).
ApplyStatusEffects(target, projectile.owner, LifeStealLevel,
OnHitDebuffType, OnHitDebuffDuration, FatedStacksPerHit,
FatedMaximumStacks, FatedDurationFrames);
return;
}
int localHealedLife = Main.player[projectile.owner].GetModPlayer<MyPlayer>().TryLifeSteal(damageDone, LifeStealLevel);
LifeStealVisuals.Spawn(target.Center, projectile.owner, LifeStealLevel, localHealedLife);
ApplyStatusEffects(target, projectile.owner, LifeStealLevel, OnHitDebuffType, OnHitDebuffDuration, FatedStacksPerHit, FatedMaximumStacks, FatedDurationFrames);
}
internal bool TryApplyPrimaryHitEffects(Projectile projectile, NPC target,
int damageDone, int uniqueHitIndex = 1)
{
if (Main.netMode == NetmodeID.MultiplayerClient
|| !HasReaperSnapshot
|| ReaperHitKind != ReaperHitKind.Primary
|| projectile.owner < 0
|| projectile.owner >= Main.maxPlayers
|| !ReaperTargeting.IsValidWeaponTarget(target))
{
return false;
}
int root = target.realLife >= 0 ? target.realLife : target.whoAmI;
if (!reaperPrimaryEffectRoots.Add(root))
return false;
// Target dummies do not consistently raise the dedicated server's
// projectile-hit callback. This path is also used by the swept-geometry
// confirmation, so record the same mark/harvester ownership here before
// running form-specific effects.
target.GetGlobalNPC<MyGlobalNPC>().RegisterSickleHit(target,
projectile.owner);
ReaperCombatService.OnReaperHit(projectile, target,
Math.Max(0, damageDone), this, Math.Max(1, uniqueHitIndex));
return true;
}
internal static void ApplyStatusEffects(NPC target, int playerIndex, int lifeStealLevel, int debuffType, int debuffDuration, int fatedStacks, int fatedMaximumStacks, int fatedDurationFrames)
{
if (Main.netMode == NetmodeID.MultiplayerClient || target.friendly || target.lifeMax <= 5)
return;
target.GetGlobalNPC<MyGlobalNPC>().MarkForReaping(target);
NPC statusTarget = target.realLife >= 0 && target.realLife < Main.maxNPCs && Main.npc[target.realLife].active
? Main.npc[target.realLife]
: target;
if (statusTarget.whoAmI != target.whoAmI)
statusTarget.GetGlobalNPC<MyGlobalNPC>().MarkForReaping(statusTarget);
if (debuffType > 0 && debuffDuration > 0)
statusTarget.AddBuff(debuffType, debuffDuration);
if (fatedStacks > 0 && fatedMaximumStacks > 0 && fatedDurationFrames > 0)
statusTarget.GetGlobalNPC<MyGlobalNPC>().AddFatedStacks(statusTarget, fatedStacks, fatedMaximumStacks, fatedDurationFrames, playerIndex, lifeStealLevel);
MyGlobalNPC.SyncBuffState(statusTarget);
}
private void ScaleProjectileLifetime(Projectile projectile)
{
if (projectile.ModProjectile is SickleSwingProjectile || ProjectileDurationLevel <= 1)
return;
float multiplier = 1f + (ProjectileDurationLevel - 1) * 0.12f;
projectile.timeLeft = Math.Max(1, (int)Math.Round(projectile.timeLeft * multiplier));
}
}
using SoulHarvest.Items;
using SoulHarvest.Projectiles;
using System;
using System.Collections.Generic;
using System.IO;
using Terraria;
using Terraria.DataStructures;
using Terraria.ID;
using Terraria.ModLoader;
using Terraria.ModLoader.IO;
namespace SoulHarvest.Common;
public class MyGlobalProjectile : GlobalProjectile
{
public override bool InstancePerEntity => true;
public bool IsSickleProjectile { get; private set; }
public bool IsDeathDomainProjectile { get; private set; }
public int LifeStealLevel { get; private set; }
public int ArmorPenetrationLevel { get; private set; }
public int ProjectileDurationLevel { get; private set; } = 1;
public int HitCooldownFrames { get; private set; }
public int OnHitDebuffType { get; private set; }
public int OnHitDebuffDuration { get; private set; }
public int FatedStacksPerHit { get; private set; }
public int FatedMaximumStacks { get; private set; }
public int FatedDurationFrames { get; private set; }
public bool HasReaperSnapshot { get; private set; }
public SickleCombatSnapshot ReaperSnapshot { get; private set; }
public ReaperHitKind ReaperHitKind { get; private set; }
public int ReaperPhase { get; private set; }
public int ReaperActionId { get; private set; }
public float AttackAngle { get; private set; }
private readonly Dictionary<int, int> reaperRootHitCounts = [];
private readonly HashSet<int> reaperPrimaryEffectRoots = [];
private bool serverAuthorizedReaper;
private int authoritativeDamage;
private int authoritativeOriginalDamage;
private int authoritativeCritChance;
private float authoritativeKnockback;
private readonly float[] authoritativeAI = new float[3];
public override void OnSpawn(Projectile projectile, IEntitySource source)
{
if (source is EntitySource_ItemUse_WithAmmo itemSource && itemSource.Item.ModItem is NormalSickle sickle)
{
LifeStealLevel = sickle.LifeStealLevel;
ArmorPenetrationLevel = sickle.ArmorPenetrationLevel;
ProjectileDurationLevel = sickle.ProjectileDurationLevel;
HitCooldownFrames = sickle.HitCooldownFrames;
OnHitDebuffType = sickle.OnHitDebuffType;
OnHitDebuffDuration = sickle.OnHitDebuffDuration;
FatedStacksPerHit = sickle.FatedStacksPerHit;
FatedMaximumStacks = sickle.FatedMaximumStacks;
FatedDurationFrames = sickle.FatedDurationFrames;
}
else if (source is EntitySource_ItemUse_WithAmmo legacySource && legacySource.Item.ModItem is LegacyDeath)
{
HitCooldownFrames = NormalSickle.BaseHitCooldownFrames;
}
else if (source is EntitySource_Parent parentSource
&& parentSource.Entity is Projectile parentProjectile
&& parentProjectile.GetGlobalProjectile<MyGlobalProjectile>() is { IsSickleProjectile: true } parentGlobal)
{
LifeStealLevel = parentGlobal.LifeStealLevel;
ArmorPenetrationLevel = parentGlobal.ArmorPenetrationLevel;
ProjectileDurationLevel = parentGlobal.ProjectileDurationLevel;
HitCooldownFrames = parentGlobal.HitCooldownFrames;
OnHitDebuffType = parentGlobal.OnHitDebuffType;
OnHitDebuffDuration = parentGlobal.OnHitDebuffDuration;
FatedStacksPerHit = parentGlobal.FatedStacksPerHit;
FatedMaximumStacks = parentGlobal.FatedMaximumStacks;
FatedDurationFrames = parentGlobal.FatedDurationFrames;
}
else
{
return;
}
IsSickleProjectile = true;
projectile.ArmorPenetration = 0;
projectile.usesIDStaticNPCImmunity = false;
projectile.usesLocalNPCImmunity = true;
projectile.localNPCHitCooldown = HitCooldownFrames;
ScaleProjectileLifetime(projectile);
}
public override void SendExtraAI(Projectile projectile, BitWriter bitWriter, BinaryWriter binaryWriter)
{
bitWriter.WriteBit(IsSickleProjectile);
if (!IsSickleProjectile)
return;
bitWriter.WriteBit(HasReaperSnapshot);
binaryWriter.Write((byte)LifeStealLevel);
binaryWriter.Write((byte)ArmorPenetrationLevel);
binaryWriter.Write((byte)ProjectileDurationLevel);
binaryWriter.Write((byte)HitCooldownFrames);
binaryWriter.Write((ushort)OnHitDebuffType);
binaryWriter.Write((ushort)OnHitDebuffDuration);
binaryWriter.Write((byte)FatedStacksPerHit);
binaryWriter.Write((byte)FatedMaximumStacks);
binaryWriter.Write((ushort)FatedDurationFrames);
if (HasReaperSnapshot)
{
ReaperSnapshot.Write(binaryWriter);
binaryWriter.Write((byte)ReaperHitKind);
binaryWriter.Write((byte)Math.Clamp(ReaperPhase, 0, byte.MaxValue));
binaryWriter.Write(ReaperActionId);
binaryWriter.Write(AttackAngle);
}
}
public override void ReceiveExtraAI(Projectile projectile, BitReader bitReader, BinaryReader binaryReader)
{
bool incomingSickleProjectile = bitReader.ReadBit();
if (!incomingSickleProjectile)
{
if (Main.netMode != NetmodeID.Server)
IsSickleProjectile = false;
return;
}
bool incomingReaperSnapshot = bitReader.ReadBit();
int incomingLifeSteal = binaryReader.ReadByte();
int incomingArmorPenetration = binaryReader.ReadByte();
int incomingDuration = Math.Clamp((int)binaryReader.ReadByte(), 1, NormalSickle.MaxProjectileDurationLevel);
int incomingHitCooldown = binaryReader.ReadByte();
int incomingDebuffType = binaryReader.ReadUInt16();
int incomingDebuffDuration = binaryReader.ReadUInt16();
int incomingFatedStacks = binaryReader.ReadByte();
int incomingFatedMaximum = binaryReader.ReadByte();
int incomingFatedDuration = binaryReader.ReadUInt16();
SickleCombatSnapshot incomingSnapshot = default;
ReaperHitKind incomingHitKind = default;
int incomingPhase = 0;
int incomingActionId = 0;
float incomingAttackAngle = 0f;
if (incomingReaperSnapshot)
{
incomingSnapshot = SickleCombatSnapshot.Read(binaryReader);
incomingHitKind = (ReaperHitKind)binaryReader.ReadByte();
incomingPhase = binaryReader.ReadByte();
incomingActionId = binaryReader.ReadInt32();
incomingAttackAngle = binaryReader.ReadSingle();
}
// Clients never get to replace combat snapshots or legacy upgrade values
// on the authoritative simulation. Reading above only consumes the packet.
if (Main.netMode == NetmodeID.Server)
return;
IsSickleProjectile = true;
HasReaperSnapshot = incomingReaperSnapshot;
LifeStealLevel = incomingLifeSteal;
ArmorPenetrationLevel = incomingArmorPenetration;
ProjectileDurationLevel = incomingDuration;
HitCooldownFrames = incomingHitCooldown;
OnHitDebuffType = incomingDebuffType;
OnHitDebuffDuration = incomingDebuffDuration;
FatedStacksPerHit = incomingFatedStacks;
FatedMaximumStacks = incomingFatedMaximum;
FatedDurationFrames = incomingFatedDuration;
if (incomingReaperSnapshot)
{
ReaperSnapshot = incomingSnapshot;
ReaperHitKind = incomingHitKind;
ReaperPhase = incomingPhase;
ReaperActionId = incomingActionId;
AttackAngle = incomingAttackAngle;
}
projectile.ArmorPenetration = 0;
projectile.usesIDStaticNPCImmunity = false;
projectile.usesLocalNPCImmunity = true;
projectile.localNPCHitCooldown = HitCooldownFrames;
}
internal void ConfigureDeathDomainProjectile(Projectile projectile, NormalSickle? sickle)
{
IsDeathDomainProjectile = true;
IsSickleProjectile = true;
if (sickle is not null)
{
LifeStealLevel = sickle.LifeStealLevel;
ArmorPenetrationLevel = sickle.ArmorPenetrationLevel;
ProjectileDurationLevel = sickle.ProjectileDurationLevel;
HitCooldownFrames = sickle.HitCooldownFrames;
OnHitDebuffType = sickle.OnHitDebuffType;
OnHitDebuffDuration = sickle.OnHitDebuffDuration;
FatedStacksPerHit = sickle.FatedStacksPerHit;
FatedMaximumStacks = sickle.FatedMaximumStacks;
FatedDurationFrames = sickle.FatedDurationFrames;
}
else
{
HitCooldownFrames = NormalSickle.BaseHitCooldownFrames;
}
projectile.ArmorPenetration = 0;
projectile.usesIDStaticNPCImmunity = false;
projectile.usesLocalNPCImmunity = true;
projectile.localNPCHitCooldown = HitCooldownFrames;
ScaleProjectileLifetime(projectile);
projectile.netUpdate = true;
}
internal void ConfigureReaperProjectile(Projectile projectile, in SickleCombatSnapshot snapshot,
ReaperHitKind hitKind, int phase, float attackAngle, int actionId = -1)
{
IsSickleProjectile = true;
HasReaperSnapshot = true;
ReaperSnapshot = snapshot;
ReaperHitKind = hitKind;
ReaperPhase = Math.Max(0, phase);
ReaperActionId = actionId >= 0 ? actionId : projectile.identity;
AttackAngle = attackAngle;
LifeStealLevel = 0;
ArmorPenetrationLevel = snapshot.ArmorPenetrationLevel * 4;
ProjectileDurationLevel = 1;
HitCooldownFrames = hitKind == ReaperHitKind.ReturningArc ? 18 : NormalSickle.BaseHitCooldownFrames;
OnHitDebuffType = 0;
OnHitDebuffDuration = 0;
FatedStacksPerHit = 0;
FatedMaximumStacks = 0;
FatedDurationFrames = 0;
reaperRootHitCounts.Clear();
projectile.ArmorPenetration = 0;
projectile.usesIDStaticNPCImmunity = false;
projectile.usesLocalNPCImmunity = true;
projectile.localNPCHitCooldown = HitCooldownFrames;
AuthorizeServerReaperProjectile(projectile);
projectile.netUpdate = true;
}
internal void UpdateReaperAttackAngle(float attackAngle)
{
if (HasReaperSnapshot && float.IsFinite(attackAngle))
AttackAngle = attackAngle;
}
internal void AuthorizeServerReaperProjectile(Projectile projectile)
{
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
serverAuthorizedReaper = true;
authoritativeDamage = Math.Max(0, projectile.damage);
authoritativeOriginalDamage = Math.Max(0, projectile.originalDamage);
authoritativeCritChance = Math.Clamp(projectile.CritChance, 0, 100);
authoritativeKnockback = float.IsFinite(projectile.knockBack) ? Math.Max(0f, projectile.knockBack) : 0f;
for (int index = 0; index < authoritativeAI.Length; index++)
authoritativeAI[index] = float.IsFinite(projectile.ai[index]) ? projectile.ai[index] : 0f;
}
public override void AI(Projectile projectile)
{
if (Main.netMode != NetmodeID.Server)
return;
bool requiresAuthorization = projectile.ModProjectile is ReaperActionControllerProjectile
or ReaperStrikeProjectile
or ReaperFrostShardProjectile
or SoulSickleProjectile
or ReaperSoulFamiliarProjectile
or ReaperSoulShardProjectile
or ReaperVoidRiftProjectile
or ReaperVoidBlackHoleProjectile
or ReaperBloodScarProjectile
or ReaperBloodDrainFieldProjectile
or ReaperBloodBladeProjectile
or BloodCrescentProjectile
or DeathSickleProjectile;
if (requiresAuthorization && !serverAuthorizedReaper)
{
projectile.Kill();
return;
}
if (serverAuthorizedReaper)
{
projectile.damage = authoritativeDamage;
projectile.originalDamage = authoritativeOriginalDamage;
projectile.CritChance = authoritativeCritChance;
projectile.knockBack = authoritativeKnockback;
for (int index = 0; index < authoritativeAI.Length; index++)
projectile.ai[index] = authoritativeAI[index];
}
}
public override bool? CanHitNPC(Projectile projectile, NPC target)
{
if (!HasReaperSnapshot)
return null;
int root = target.realLife >= 0 ? target.realLife : target.whoAmI;
int maximumHits = ReaperHitKind switch
{
ReaperHitKind.DamageOverTime => int.MaxValue,
ReaperHitKind.ReturningArc => 2,
ReaperHitKind.Ultimate when projectile.ModProjectile is ReaperBloodBladeProjectile => 5,
_ => 1
};
return !reaperRootHitCounts.TryGetValue(root, out int hits)
|| hits < maximumHits ? null : false;
}
public override void ModifyHitNPC(Projectile projectile, NPC target, ref NPC.HitModifiers modifiers)
{
bool sickleHit = IsSickleProjectile || projectile.ModProjectile is SickleSwingProjectile;
if (!sickleHit || projectile.owner < 0 || projectile.owner >= Main.maxPlayers)
return;
modifiers.ScalingArmorPenetration += HasReaperSnapshot
? Math.Clamp(ReaperSnapshot.ArmorPenetrationLevel * 0.06f, 0f, 0.3f)
: Math.Clamp(ArmorPenetrationLevel, 0, NormalSickle.MaxArmorPenetrationLevel) / 100f;
if (HasReaperSnapshot)
{
NPC statusTarget = ReaperCombatService.GetStatusTarget(target);
statusTarget.GetGlobalNPC<ReaperCombatGlobalNPC>().ModifyReaperHit(
projectile.owner, ReaperSnapshot, ReaperHitKind, ReaperPhase, ReaperActionId, statusTarget, ref modifiers);
}
if (Main.netMode == NetmodeID.MultiplayerClient)
return;
// Register directly on the authoritative simulation before damage and OnKill resolve.
target.GetGlobalNPC<MyGlobalNPC>().RegisterSickleHit(target, projectile.owner);
}
public override void OnHitNPC(Projectile projectile, NPC target, NPC.HitInfo hit, int damageDone)
{
if ((!IsSickleProjectile && projectile.ModProjectile is not SickleSwingProjectile)
|| projectile.owner < 0
|| projectile.owner >= Main.maxPlayers)
return;
int uniqueHitIndex = 0;
if (HasReaperSnapshot)
{
int root = target.realLife >= 0 ? target.realLife : target.whoAmI;
reaperRootHitCounts.TryGetValue(root, out int previousHits);
reaperRootHitCounts[root] = previousHits + 1;
uniqueHitIndex = reaperRootHitCounts.Count;
if (ReaperHitKind == ReaperHitKind.Primary)
TryApplyPrimaryHitEffects(projectile, target, damageDone,
uniqueHitIndex);
else
ReaperCombatService.OnReaperHit(projectile, target, damageDone,
this, uniqueHitIndex);
}
if (Main.netMode == NetmodeID.MultiplayerClient)
{
if (projectile.owner != Main.myPlayer)
return;
int healedLife = Main.player[projectile.owner].GetModPlayer<MyPlayer>().TryLifeSteal(damageDone, LifeStealLevel);
LifeStealVisuals.Spawn(target.Center, projectile.owner, LifeStealLevel, healedLife);
return;
}
if (Main.netMode == NetmodeID.Server)
{
// Status and soul ownership must be committed by the authoritative
// simulation. The old early return left multiplayer sickle hits with
// client visuals but no Death Mark (and therefore no soul harvest).
ApplyStatusEffects(target, projectile.owner, LifeStealLevel,
OnHitDebuffType, OnHitDebuffDuration, FatedStacksPerHit,
FatedMaximumStacks, FatedDurationFrames);
return;
}
int localHealedLife = Main.player[projectile.owner].GetModPlayer<MyPlayer>().TryLifeSteal(damageDone, LifeStealLevel);
LifeStealVisuals.Spawn(target.Center, projectile.owner, LifeStealLevel, localHealedLife);
ApplyStatusEffects(target, projectile.owner, LifeStealLevel, OnHitDebuffType, OnHitDebuffDuration, FatedStacksPerHit, FatedMaximumStacks, FatedDurationFrames);
}
internal bool TryApplyPrimaryHitEffects(Projectile projectile, NPC target,
int damageDone, int uniqueHitIndex = 1)
{
if (Main.netMode == NetmodeID.MultiplayerClient
|| !HasReaperSnapshot
|| ReaperHitKind != ReaperHitKind.Primary
|| projectile.owner < 0
|| projectile.owner >= Main.maxPlayers
|| !ReaperTargeting.IsValidWeaponTarget(target))
{
return false;
}
int root = target.realLife >= 0 ? target.realLife : target.whoAmI;
if (!reaperPrimaryEffectRoots.Add(root))
return false;
// Target dummies do not consistently raise the dedicated server's
// projectile-hit callback. This path is also used by the swept-geometry
// confirmation, so record the same mark/harvester ownership here before
// running form-specific effects.
target.GetGlobalNPC<MyGlobalNPC>().RegisterSickleHit(target,
projectile.owner);
ReaperCombatService.OnReaperHit(projectile, target,
Math.Max(0, damageDone), this, Math.Max(1, uniqueHitIndex));
return true;
}
internal static void ApplyStatusEffects(NPC target, int playerIndex, int lifeStealLevel, int debuffType, int debuffDuration, int fatedStacks, int fatedMaximumStacks, int fatedDurationFrames)
{
if (Main.netMode == NetmodeID.MultiplayerClient || target.friendly || target.lifeMax <= 5)
return;
target.GetGlobalNPC<MyGlobalNPC>().MarkForReaping(target);
NPC statusTarget = target.realLife >= 0 && target.realLife < Main.maxNPCs && Main.npc[target.realLife].active
? Main.npc[target.realLife]
: target;
if (statusTarget.whoAmI != target.whoAmI)
statusTarget.GetGlobalNPC<MyGlobalNPC>().MarkForReaping(statusTarget);
if (debuffType > 0 && debuffDuration > 0)
statusTarget.AddBuff(debuffType, debuffDuration);
if (fatedStacks > 0 && fatedMaximumStacks > 0 && fatedDurationFrames > 0)
statusTarget.GetGlobalNPC<MyGlobalNPC>().AddFatedStacks(statusTarget, fatedStacks, fatedMaximumStacks, fatedDurationFrames, playerIndex, lifeStealLevel);
MyGlobalNPC.SyncBuffState(statusTarget);
}
private void ScaleProjectileLifetime(Projectile projectile)
{
if (projectile.ModProjectile is SickleSwingProjectile || ProjectileDurationLevel <= 1)
return;
float multiplier = 1f + (ProjectileDurationLevel - 1) * 0.12f;
projectile.timeLeft = Math.Max(1, (int)Math.Round(projectile.timeLeft * multiplier));
}
}