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 reaperRootHitCounts = []; private readonly HashSet 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() 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().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().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().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().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().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().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().MarkForReaping(statusTarget); if (debuffType > 0 && debuffDuration > 0) statusTarget.AddBuff(debuffType, debuffDuration); if (fatedStacks > 0 && fatedMaximumStacks > 0 && fatedDurationFrames > 0) statusTarget.GetGlobalNPC().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)); } }