using SoulHarvest.Items; using SoulHarvest.Projectiles; using SoulHarvest.UI; using Microsoft.Xna.Framework; using System; using System.Collections.Generic; using System.IO; using Terraria; using Terraria.Audio; using Terraria.Chat; using Terraria.ID; using Terraria.GameInput; using Terraria.Localization; using Terraria.ModLoader; using Terraria.ModLoader.IO; namespace SoulHarvest.Common; public class MyPlayer : ModPlayer { public const int StartingSouls = 150; public const int SoulsPerEssence = 100; internal const int DeathWingTrailCapacity = 28; private const int DeathWingBodyRiftIdentity = -173870001; private const int DeathWingDomainChunkCapacity = 14; private const int DeathWingDomainChunkOverlapPoints = 4; public int Souls { get; private set; } = StartingSouls; public int SickleAlternateCooldown { get; private set; } public int SickleAlternateCooldownMax { get; private set; } public bool DeathDomainEnabled { get; private set; } public bool DeathDomainDescended { get; private set; } public DeathDomainProgression DeathDomainProgression { get; } = new(); public ReaperProgressionState ReaperProgression { get; } = new(); public int ReaperSpecialCooldown { get; private set; } public int ReaperSpecialCooldownMax { get; private set; } public int ReaperExhaustionCooldown { get; private set; } public ReaperFormId VisibleReaperForm { get; private set; } = ReaperFormId.Base; public ReaperStage VisibleReaperStage { get; private set; } = ReaperStage.StageI; internal DeathNecklace? ActiveDeathNecklace { get; private set; } internal bool DeathWingsActiveThisTick { get; set; } internal bool DeathWingsActiveLastTick { get; private set; } internal Vector2[] DeathWingUpperTrail { get; } = new Vector2[DeathWingTrailCapacity]; internal Vector2[] DeathWingLowerTrail { get; } = new Vector2[DeathWingTrailCapacity]; internal Vector2[] DeathWingBodyTrail { get; } = new Vector2[DeathWingTrailCapacity]; internal int DeathWingTrailCount { get; private set; } internal float DeathWingTrailMastery { get; private set; } internal float DeathWingTrailOpacity { get; private set; } internal float DeathWingRiftStrength { get; private set; } private int lifeStealCooldown; private int swingComboDirection = 1; private bool reaperPrimaryInputWasHeld; private bool rawSpecialInputWasHeld; private bool serverSoulBalanceInitialized; private bool serverReaperProgressionInitialized; private int clientCharacterStateUploadDelay; private int clientCharacterStateUploadAttempts; private bool clientCharacterStateAcknowledged; private bool reaperDeathStateCleared; private bool reaperLegacyMigrationChecked; private int reaperLegacyMigrationVersion; private int pendingLegacyMigrationEssence; private TagCompound? reaperLegacyMigrationSnapshot; private int reaperLegacyMigrationNoticeWeapons; private int reaperLegacyMigrationNoticeRefund; private readonly float[] reaperEnergy = new float[8]; private readonly int[] reaperUltimateCooldowns = new int[8]; private byte deathCycleMask; private ReaperFormId? queuedReaperForm; private ulong lastReaperFormSwitchRequestTick; private ulong deathWingTrailUpdateTick = ulong.MaxValue; private ulong deathWingDomainCombatUpdateTick = ulong.MaxValue; private readonly List deathWingDomainChunkPoints = []; private readonly List deathWingDomainChunkOpacities = []; private readonly ulong[] deathWingDomainNextHarvestTicks = new ulong[Main.maxNPCs]; private float deathWingDomainChunkMastery; private bool deathWingDomainChunkHasNewGeometry; private int deathWingDomainChunkSequence; private int deathWingDomainVolleyCounter; private int deathDomainTimer; private int deathDomainVolleyCounter; private int deathDomainKeyHoldFrames; private bool deathDomainLongPressHandled; private int deathDomainScreenShakeFrames; private int deathDomainSlashSoundCooldown; private float deathDomainScreenShakeStrength; private Vector2 deathDomainScreenShakeDirection = Vector2.UnitY; private readonly Dictionary deathDomainPendingCuts = []; private readonly List pendingDeathDomainStrikes = []; private readonly List pendingReaperDomainHarvests = []; private const int DeathDomainLongPressFrames = 45; private readonly record struct PendingDeathDomainCut(float Rotation, int ProjectileIndex); private readonly record struct PendingDeathDomainStrike( int NpcIndex, int RemainingFrames, int Damage, int LifeStealLevel, int DeathUltimateActionId); private readonly record struct PendingReaperDomainHarvest( int NpcIndex, int RemainingFrames, float Rotation, float VisualMastery, bool Requiem, int DeathUltimateActionId); public override void Initialize() { Souls = StartingSouls; lifeStealCooldown = 0; swingComboDirection = 1; reaperPrimaryInputWasHeld = false; rawSpecialInputWasHeld = false; SickleAlternateCooldown = 0; SickleAlternateCooldownMax = 0; DeathWingsActiveThisTick = false; DeathWingsActiveLastTick = false; ClearDeathWingTrail(); DiscardDeathWingDomainChunk(); deathWingDomainCombatUpdateTick = ulong.MaxValue; Array.Clear(deathWingDomainNextHarvestTicks); deathWingDomainChunkSequence = 0; deathWingDomainVolleyCounter = 0; serverSoulBalanceInitialized = false; serverReaperProgressionInitialized = false; clientCharacterStateUploadDelay = 0; clientCharacterStateUploadAttempts = 0; clientCharacterStateAcknowledged = false; DeathDomainProgression.Reset(); ReaperProgression.Reset(); VisibleReaperForm = ReaperFormId.Base; VisibleReaperStage = ReaperStage.StageI; ClearReaperCombatRuntime(sync: false); reaperDeathStateCleared = false; queuedReaperForm = null; lastReaperFormSwitchRequestTick = 0; reaperLegacyMigrationChecked = false; reaperLegacyMigrationVersion = 0; pendingLegacyMigrationEssence = 0; reaperLegacyMigrationSnapshot = null; reaperLegacyMigrationNoticeWeapons = 0; reaperLegacyMigrationNoticeRefund = 0; DeathDomainEnabled = false; DeathDomainDescended = false; ActiveDeathNecklace = null; deathDomainTimer = 0; deathDomainVolleyCounter = 0; deathDomainKeyHoldFrames = 0; deathDomainLongPressHandled = false; deathDomainScreenShakeFrames = 0; deathDomainSlashSoundCooldown = 0; deathDomainScreenShakeStrength = 0f; deathDomainScreenShakeDirection = Vector2.UnitY; deathDomainPendingCuts.Clear(); pendingDeathDomainStrikes.Clear(); pendingReaperDomainHarvests.Clear(); } public override void ResetEffects() { DeathWingsActiveThisTick = false; ActiveDeathNecklace = null; } public override void SaveData(TagCompound tag) { tag[nameof(Souls)] = Souls; tag[nameof(DeathDomainEnabled)] = DeathDomainEnabled; tag[nameof(DeathDomainDescended)] = DeathDomainDescended; DeathDomainProgression.SaveData(tag); ReaperProgression.SaveData(tag); tag["ReaperLegacyMigrationVersion"] = reaperLegacyMigrationVersion; tag["PendingLegacyMigrationEssence"] = pendingLegacyMigrationEssence; if (reaperLegacyMigrationSnapshot is not null) tag["ReaperLegacyMigrationSnapshot"] = reaperLegacyMigrationSnapshot; } public override void LoadData(TagCompound tag) { Souls = tag.ContainsKey(nameof(Souls)) ? Math.Max(0, tag.GetInt(nameof(Souls))) : StartingSouls; DeathDomainEnabled = tag.GetBool(nameof(DeathDomainEnabled)); DeathDomainDescended = DeathDomainEnabled && tag.GetBool(nameof(DeathDomainDescended)); DeathDomainProgression.LoadData(tag); ReaperProgression.LoadData(tag); reaperLegacyMigrationVersion = tag.ContainsKey("ReaperLegacyMigrationVersion") ? Math.Max(0, tag.GetInt("ReaperLegacyMigrationVersion")) : 0; pendingLegacyMigrationEssence = Math.Max(0, tag.GetInt("PendingLegacyMigrationEssence")); reaperLegacyMigrationSnapshot = tag.ContainsKey("ReaperLegacyMigrationSnapshot") ? tag.GetCompound("ReaperLegacyMigrationSnapshot") : null; // Player inventory and personal banks have already been deserialized when // ModPlayer data is loaded. Migrating here guarantees the authoritative // tree is ready before the first multiplayer SyncPlayer upload. if (reaperLegacyMigrationVersion < ReaperLegacyMigration.MigrationVersion) { ReaperLegacyMigrationResult result = ReaperLegacyMigration.Apply(Player, ReaperProgression); reaperLegacyMigrationVersion = ReaperLegacyMigration.MigrationVersion; pendingLegacyMigrationEssence = Math.Max(0, pendingLegacyMigrationEssence + result.RefundEssence); reaperLegacyMigrationSnapshot = result.Snapshot; reaperLegacyMigrationNoticeWeapons = result.ConvertedWeaponCount; reaperLegacyMigrationNoticeRefund = result.RefundEssence; TryDeliverPendingLegacyMigrationRefund(); } } public override void OnEnterWorld() { ReaperCombatService.ResetPlayerState(Player.whoAmI); ClearReaperCombatRuntime(sync: false); if (Main.netMode == NetmodeID.MultiplayerClient && Player.whoAmI == Main.myPlayer) { // ModPlayer.SyncPlayer is not guaranteed to be invoked client-side // after every join/reload. Explicitly retry the character-owned // progression upload so the server cannot remain permanently in // its uninitialized state and reject combat, forms and the domain. clientCharacterStateUploadDelay = 1; clientCharacterStateUploadAttempts = 0; clientCharacterStateAcknowledged = false; } if (Main.netMode == NetmodeID.Server || Player.whoAmI != Main.myPlayer || reaperLegacyMigrationChecked) return; reaperLegacyMigrationChecked = true; TryDeliverPendingLegacyMigrationRefund(); if (reaperLegacyMigrationNoticeWeapons > 0) { Main.NewText( Language.GetTextValue( "Mods.SoulHarvest.Messages.ReaperMigrationComplete", reaperLegacyMigrationNoticeWeapons, reaperLegacyMigrationNoticeRefund), new Color(205, 90, 235)); reaperLegacyMigrationNoticeWeapons = 0; reaperLegacyMigrationNoticeRefund = 0; } } public override void PostUpdate() { UpdateClientCharacterStateUpload(); if (Main.netMode != NetmodeID.Server && Player.whoAmI == Main.myPlayer && pendingLegacyMigrationEssence > 0) TryDeliverPendingLegacyMigrationRefund(); if (lifeStealCooldown > 0) lifeStealCooldown--; if (SickleAlternateCooldown > 0) SickleAlternateCooldown--; if (ReaperSpecialCooldown > 0) ReaperSpecialCooldown--; if (ReaperExhaustionCooldown > 0) ReaperExhaustionCooldown--; for (int formIndex = 0; formIndex < reaperUltimateCooldowns.Length; formIndex++) { if (reaperUltimateCooldowns[formIndex] > 0) reaperUltimateCooldowns[formIndex]--; } TryApplyQueuedReaperForm(); if (deathDomainSlashSoundCooldown > 0) deathDomainSlashSoundCooldown--; UpdatePendingReaperDomainHarvests(); UpdatePendingDeathDomainStrikes(); UpdateDeathDomain(); if (Player.dead) { if (!reaperDeathStateCleared) { ClearReaperCombatRuntime(sync: true); reaperDeathStateCleared = true; } ClearDeathWingTrail(); DiscardDeathWingDomainChunk(); } else if (!DeathWingsActiveThisTick && DeathWingTrailOpacity > 0f) { DeathWingTrailOpacity *= 0.78f; if (DeathWingTrailOpacity < 0.025f) ClearDeathWingTrail(); } if (!Player.dead && DeathWingTrailOpacity > 0f) SubmitDeathWingDomainRifts(); if (!Player.dead && !DeathWingsActiveThisTick && Main.netMode != NetmodeID.MultiplayerClient) { SpawnDeathWingDomainChunk(keepTail: false); } if (!Player.dead) reaperDeathStateCleared = false; DeathWingsActiveLastTick = DeathWingsActiveThisTick; } private void UpdateClientCharacterStateUpload() { if (Main.netMode != NetmodeID.MultiplayerClient || Player.whoAmI != Main.myPlayer || clientCharacterStateAcknowledged) { return; } if (clientCharacterStateUploadDelay > 0) { clientCharacterStateUploadDelay--; if (clientCharacterStateUploadDelay > 0) return; } SoulHarvest.SendReaperProgression(Player); clientCharacterStateUploadAttempts++; clientCharacterStateUploadDelay = clientCharacterStateUploadAttempts switch { 1 => 30, 2 => 90, 3 => 180, _ => 300 }; } internal void AcknowledgeServerCharacterState() { if (Main.netMode == NetmodeID.MultiplayerClient && Player.whoAmI == Main.myPlayer) { clientCharacterStateAcknowledged = true; clientCharacterStateUploadDelay = 0; } } #if DEBUG internal bool ClientCharacterStateAcknowledged => clientCharacterStateAcknowledged; #endif private void TryDeliverPendingLegacyMigrationRefund() { if (pendingLegacyMigrationEssence <= 0) return; int essenceType = ModContent.ItemType(); int slotCount = Math.Min(58, Player.inventory.Length); for (int slot = 0; slot < slotCount && pendingLegacyMigrationEssence > 0; slot++) { Item item = Player.inventory[slot]; if (item.type != essenceType || item.stack <= 0 || item.stack >= item.maxStack) continue; int inserted = Math.Min(pendingLegacyMigrationEssence, item.maxStack - item.stack); item.stack += inserted; pendingLegacyMigrationEssence -= inserted; SyncMigrationInventorySlot(slot); } for (int slot = 0; slot < slotCount && pendingLegacyMigrationEssence > 0; slot++) { Item item = Player.inventory[slot]; if (!item.IsAir) continue; item.SetDefaults(essenceType); int inserted = Math.Min(pendingLegacyMigrationEssence, item.maxStack); item.stack = inserted; pendingLegacyMigrationEssence -= inserted; SyncMigrationInventorySlot(slot); } } private void SyncMigrationInventorySlot(int slot) { if (Main.netMode != NetmodeID.MultiplayerClient) return; NetMessage.SendData( MessageID.SyncEquipment, -1, -1, null, Player.whoAmI, PlayerItemSlotID.Inventory0 + slot, Player.inventory[slot].prefix); } public override void ModifyScreenPosition() { if (deathDomainScreenShakeFrames > 0 && deathDomainScreenShakeStrength > 0.01f) { float remaining = deathDomainScreenShakeFrames / 6f; float phase = (6 - deathDomainScreenShakeFrames) * 2.45f; Vector2 tangent = deathDomainScreenShakeDirection.RotatedBy(MathHelper.PiOver2); Main.screenPosition += deathDomainScreenShakeDirection * ((float)Math.Sin(phase) * deathDomainScreenShakeStrength * remaining) + tangent * ((float)Math.Sin(phase * 0.63f + 1.1f) * deathDomainScreenShakeStrength * remaining * 0.32f); deathDomainScreenShakeFrames--; if (deathDomainScreenShakeFrames <= 0) deathDomainScreenShakeStrength = 0f; } ReaperVfxDirector.ApplyCameraImpulse(ref Main.screenPosition); } internal void TriggerDeathDomainImpact(Vector2 worldPosition, Vector2 direction, float strength) { if (Main.dedServ || Player.whoAmI != Main.myPlayer) return; float distance = Vector2.Distance(Player.Center, worldPosition); float falloff = 1f - MathHelper.Clamp(distance / 1200f, 0f, 1f); strength *= falloff * falloff; if (strength <= deathDomainScreenShakeStrength) return; deathDomainScreenShakeStrength = Math.Min(5.2f, strength); deathDomainScreenShakeDirection = direction.SafeNormalize(Vector2.UnitY); deathDomainScreenShakeFrames = 6; } internal void PlayDeathDomainSlashSound(Vector2 worldPosition, float mastery, bool requiem) { if (Main.dedServ || Player.whoAmI != Main.myPlayer || deathDomainSlashSoundCooldown > 0 || Vector2.DistanceSquared(Player.Center, worldPosition) > 1600f * 1600f) { return; } deathDomainSlashSoundCooldown = 4; SoundEngine.PlaySound(SoundID.Item71, worldPosition); } internal void RecordDeathWingTrail(Vector2 upperTip, Vector2 lowerTip, float mastery) { if (Main.dedServ || deathWingTrailUpdateTick == Main.GameUpdateCount) return; deathWingTrailUpdateTick = Main.GameUpdateCount; if (DeathWingTrailCount > 0 && (Vector2.DistanceSquared(upperTip, DeathWingUpperTrail[0]) > 120f * 120f || Vector2.DistanceSquared(lowerTip, DeathWingLowerTrail[0]) > 120f * 120f || Vector2.DistanceSquared(Player.Center, DeathWingBodyTrail[0]) > 120f * 120f)) { ClearDeathWingTrail(); } int copyCount = Math.Min(DeathWingTrailCount, DeathWingTrailCapacity - 1); if (copyCount > 0) { Array.Copy(DeathWingUpperTrail, 0, DeathWingUpperTrail, 1, copyCount); Array.Copy(DeathWingLowerTrail, 0, DeathWingLowerTrail, 1, copyCount); Array.Copy(DeathWingBodyTrail, 0, DeathWingBodyTrail, 1, copyCount); } DeathWingUpperTrail[0] = upperTip; DeathWingLowerTrail[0] = lowerTip; DeathWingBodyTrail[0] = Player.Center; DeathWingTrailCount = Math.Min(DeathWingTrailCount + 1, DeathWingTrailCapacity); DeathWingTrailMastery = MathHelper.Clamp(mastery, 0f, 1f); DeathWingTrailOpacity = 1f; float speedProgress = MathHelper.Clamp( (Player.velocity.Length() - 2.5f) / 15.5f, 0f, 1f); DeathWingRiftStrength = speedProgress; } internal void RecordDeathWingDomainTrail(float mastery) { if (Main.netMode == NetmodeID.MultiplayerClient || deathWingDomainCombatUpdateTick == Main.GameUpdateCount) { return; } deathWingDomainCombatUpdateTick = Main.GameUpdateCount; float speedProgress = MathHelper.Clamp( (Player.velocity.Length() - 2.5f) / 15.5f, 0f, 1f); if (speedProgress <= 0.035f) { SpawnDeathWingDomainChunk(keepTail: false); return; } float opacity = GetDeathWingRiftOpacity(speedProgress); Vector2 point = Player.Center; if (deathWingDomainChunkPoints.Count > 0 && Vector2.DistanceSquared(point, deathWingDomainChunkPoints[^1]) > 120f * 120f) { SpawnDeathWingDomainChunk(keepTail: false); } if (deathWingDomainChunkPoints.Count >= 6) { float averageOpacity = 0f; foreach (float sample in deathWingDomainChunkOpacities) averageOpacity += sample; averageOpacity /= deathWingDomainChunkOpacities.Count; if (Math.Abs(opacity - averageOpacity) >= 0.16f) SpawnDeathWingDomainChunk(keepTail: true); } if (deathWingDomainChunkPoints.Count > 0 && Vector2.DistanceSquared(point, deathWingDomainChunkPoints[^1]) < 2.25f * 2.25f) { deathWingDomainChunkPoints[^1] = point; deathWingDomainChunkOpacities[^1] = opacity; deathWingDomainChunkMastery = Math.Max( deathWingDomainChunkMastery, MathHelper.Clamp(mastery, 0f, 1f)); return; } deathWingDomainChunkPoints.Add(point); deathWingDomainChunkOpacities.Add(opacity); deathWingDomainChunkHasNewGeometry = true; deathWingDomainChunkMastery = Math.Max(deathWingDomainChunkMastery, MathHelper.Clamp(mastery, 0f, 1f)); if (deathWingDomainChunkPoints.Count >= DeathWingDomainChunkCapacity) SpawnDeathWingDomainChunk(keepTail: true); } internal bool TrySpawnDeathWingDomainTrailHarvest(NPC target, int seed) { if (Main.netMode == NetmodeID.MultiplayerClient || !ReaperTargeting.IsValidWeaponTarget(target)) { return false; } int npcIndex = target.whoAmI; ulong currentTick = Main.GameUpdateCount; if (deathWingDomainNextHarvestTicks[npcIndex] > currentTick) return false; deathWingDomainNextHarvestTicks[npcIndex] = currentTick + (ulong)Math.Max(1, DeathDomainProgression.SpawnInterval); deathWingDomainVolleyCounter++; uint hash = unchecked((uint)(seed * 16777619 + npcIndex * 486187739 + deathWingDomainVolleyCounter * 97)); hash ^= hash >> 16; float rotation = (hash & 0x00FFFFFFu) / 16777215f * MathHelper.TwoPi; bool requiem = IsDeathDomainDescended(GetEquippedDeathNecklace()) && deathWingDomainVolleyCounter % 4 == 0; SpawnDeathDomainHarvestFromReaperRift(target, requiem, rotation); return true; } private void SpawnDeathWingDomainChunk(bool keepTail) { int count = deathWingDomainChunkPoints.Count; if (count < 2 || !deathWingDomainChunkHasNewGeometry) { if (!keepTail) DiscardDeathWingDomainChunk(); return; } int retainedCount = keepTail ? Math.Min(DeathWingDomainChunkOverlapPoints, count) : 0; Span retainedPoints = stackalloc Vector2[ DeathWingDomainChunkOverlapPoints]; Span retainedOpacities = stackalloc float[ DeathWingDomainChunkOverlapPoints]; for (int index = 0; index < retainedCount; index++) { int sourceIndex = count - retainedCount + index; retainedPoints[index] = deathWingDomainChunkPoints[sourceIndex]; retainedOpacities[index] = deathWingDomainChunkOpacities[sourceIndex]; } float retainedMastery = deathWingDomainChunkMastery; float opacity = 0f; foreach (float sample in deathWingDomainChunkOpacities) opacity += sample; opacity /= deathWingDomainChunkOpacities.Count; float width = 32f + deathWingDomainChunkMastery * 8f; deathWingDomainChunkSequence = unchecked( deathWingDomainChunkSequence + 1); DeathWingDomainTrailProjectile.Spawn( Player.GetSource_Misc("SoulHarvest:DeathWingDomainTrail"), Player.whoAmI, deathWingDomainChunkPoints, width, opacity, deathWingDomainChunkSequence); DiscardDeathWingDomainChunk(); if (!keepTail) return; for (int index = 0; index < retainedCount; index++) { deathWingDomainChunkPoints.Add(retainedPoints[index]); deathWingDomainChunkOpacities.Add(retainedOpacities[index]); } deathWingDomainChunkMastery = retainedMastery; deathWingDomainChunkHasNewGeometry = false; } private void DiscardDeathWingDomainChunk() { deathWingDomainChunkPoints.Clear(); deathWingDomainChunkOpacities.Clear(); deathWingDomainChunkMastery = 0f; deathWingDomainChunkHasNewGeometry = false; } private static float GetDeathWingRiftOpacity(float speedProgress) { speedProgress = MathHelper.Clamp(speedProgress, 0f, 1f); float smoothedSpeed = speedProgress * speedProgress * (3f - 2f * speedProgress); return MathHelper.Lerp(0.08f, 0.92f, smoothedSpeed); } private void SubmitDeathWingDomainRifts() { if (Main.dedServ || DeathWingTrailCount < 4 || DeathWingRiftStrength <= 0.035f) { return; } int pointCount = Math.Min(DeathWingTrailCount, 8 + (int)(DeathWingTrailMastery * 10f)); float width = 32f + DeathWingTrailMastery * 8f; float opacity = DeathWingTrailOpacity * GetDeathWingRiftOpacity(DeathWingRiftStrength); DeathDomainTrailVisualSystem.Record(Player.whoAmI, DeathWingBodyRiftIdentity, new ArraySegment(DeathWingBodyTrail, 0, pointCount), width, opacity, mergeOverlappingRims: true, taperEnds: false, noiseFadeEnds: true); } private void ClearDeathWingTrail() { DeathWingTrailCount = 0; DeathWingTrailMastery = 0f; DeathWingTrailOpacity = 0f; DeathWingRiftStrength = 0f; deathWingTrailUpdateTick = ulong.MaxValue; } public override void SyncPlayer(int toWho, int fromWho, bool newPlayer) { if (Main.netMode == NetmodeID.SinglePlayer || Main.netMode == NetmodeID.MultiplayerClient && Player.whoAmI != Main.myPlayer) { return; } // A joining client uploads souls, domain state and the full Reaper tree in // one packet. Splitting those character-owned values allowed mutations to // occur between two independently accepted initialization packets. if (Main.netMode == NetmodeID.MultiplayerClient) { SoulHarvest.SendReaperProgression(Player, toWho, fromWho); return; } if (!ServerCharacterStateReady) return; ModPacket packet = Mod.GetPacket(); packet.Write((byte)SoulHarvest.MessageType.SyncSoulPlayer); packet.Write((byte)Player.whoAmI); packet.Write(Souls); packet.Write(DeathDomainEnabled); packet.Write(DeathDomainDescended); DeathDomainProgression.NetSend(packet); packet.Send(toWho, fromWho); SoulHarvest.SendReaperProgression(Player, toWho, fromWho); SoulHarvest.SendReaperCombat(Player, toWho, fromWho); } internal void ReceiveSouls(int souls) { Souls = Math.Max(0, souls); } internal void ReceiveDeathDomainState(bool enabled, bool descended) { bool opening = enabled && !DeathDomainEnabled; bool descending = enabled && descended && !DeathDomainDescended; DeathDomainEnabled = enabled; DeathDomainDescended = enabled && descended; if (!enabled) deathDomainTimer = 0; if ((opening || descending) && Main.netMode != NetmodeID.Server && !Main.dedServ) { // Two restrained layers give the expansion both a low spatial rupture // and a sharp soul-metal opening without requiring a custom asset. SoundEngine.PlaySound(SoundID.Roar with { Volume = 0.42f, Pitch = -0.48f, PitchVariance = 0.06f }, Player.Center); SoundEngine.PlaySound(SoundID.Item122 with { Volume = 1.05f, Pitch = -0.28f, PitchVariance = 0.08f }, Player.Center); } } internal bool ServerCharacterStateReady => Main.netMode != NetmodeID.Server || serverSoulBalanceInitialized && serverReaperProgressionInitialized; internal bool TryInitializeServerCharacterState( int souls, bool deathDomainEnabled, bool deathDomainDescended, BinaryReader reader) { if (Main.netMode != NetmodeID.Server) return false; if (serverSoulBalanceInitialized || serverReaperProgressionInitialized) { // The client retries until it receives an acknowledgement. Retried // uploads are not authority, but their full payload must still be // consumed or tModLoader disconnects the sender for an under-read. DeathDomainProgression discardedDomain = new(); ReaperProgressionState discardedProgression = new(); discardedDomain.NetReceive(reader); discardedProgression.NetReceive(reader); return false; } Souls = Math.Max(0, souls); DeathNecklace? necklace = FindEquippedDeathNecklace(); DeathDomainEnabled = deathDomainEnabled && necklace is not null; DeathDomainDescended = DeathDomainEnabled && deathDomainDescended && necklace?.DeathDescentUnlocked == true; DeathDomainProgression.NetReceive(reader); ReaperProgression.NetReceive(reader); VisibleReaperForm = ReaperCombatRegistry.ResolveUsableForm(ReaperProgression); VisibleReaperStage = ReaperProgression.GetStage(VisibleReaperForm); serverSoulBalanceInitialized = true; serverReaperProgressionInitialized = true; return true; } internal bool TrySpendSouls(int amount, bool sync = true) { if (amount < 0 || Souls < amount || Main.netMode == NetmodeID.MultiplayerClient || !ServerCharacterStateReady) return false; Souls -= amount; if (sync) SyncSoulBalance(); return true; } public void AddSouls(int amount, bool announce = false) { if (amount <= 0 || Main.netMode == NetmodeID.MultiplayerClient || !ServerCharacterStateReady) return; amount = DeathDomainProgression.ApplySoulRefinement(amount); Souls = (int)Math.Min(int.MaxValue, (long)Souls + amount); SyncSoulBalance(); if (!announce) return; if (Main.netMode == NetmodeID.Server) { ChatHelper.SendChatMessageToClient( NetworkText.FromKey("Mods.SoulHarvest.Messages.BossSoulReward", amount), new Color(90, 235, 255), Player.whoAmI); } else if (Player.whoAmI == Main.myPlayer) { Main.NewText(Language.GetTextValue("Mods.SoulHarvest.Messages.BossSoulReward", amount), new Color(90, 235, 255)); } } public bool TryExtractEssence() { if (Main.netMode == NetmodeID.MultiplayerClient || !ServerCharacterStateReady) return false; if (Souls < SoulsPerEssence) { SoulHarvest.SendOperationResult(Player, SoulHarvest.OperationResult.NotEnoughSouls); return false; } int essenceType = ModContent.ItemType(); if (!TryFindInventoryInsertionSlot(essenceType, out int changedSlot)) { SoulHarvest.SendOperationResult(Player, SoulHarvest.OperationResult.InventoryFull); return false; } Souls -= SoulsPerEssence; Item destination = Player.inventory[changedSlot]; if (destination.type == essenceType && destination.stack > 0) destination.stack++; else { destination.SetDefaults(essenceType); destination.stack = 1; } SoulHarvest.SendReaperProgressionTransaction(Player, new[] { changedSlot }); SoulHarvest.SendOperationResult(Player, SoulHarvest.OperationResult.EssenceExtracted); return true; } public bool TryAbsorbEssence() { if (Main.netMode == NetmodeID.MultiplayerClient || !ServerCharacterStateReady) return false; int essenceType = ModContent.ItemType(); int changedSlot = -1; for (int slot = 0; slot < 58; slot++) { Item item = Player.inventory[slot]; if (item.type != essenceType || item.stack <= 0) continue; item.stack--; if (item.stack <= 0) item.TurnToAir(); changedSlot = slot; break; } if (changedSlot < 0) { SoulHarvest.SendOperationResult(Player, SoulHarvest.OperationResult.NotEnoughEssence); return false; } Souls = (int)Math.Min(int.MaxValue, (long)Souls + SoulsPerEssence); SoulHarvest.SendReaperProgressionTransaction(Player, new[] { changedSlot }); SoulHarvest.SendOperationResult(Player, SoulHarvest.OperationResult.EssenceAbsorbed); return true; } private bool TryFindInventoryInsertionSlot(int itemType, out int slot) { slot = -1; int emptySlot = -1; int slotCount = Math.Min(58, Player.inventory.Length); for (int index = 0; index < slotCount; index++) { Item item = Player.inventory[index]; if (item.type == itemType && item.stack > 0 && item.stack < item.maxStack) { slot = index; return true; } if (emptySlot < 0 && item.IsAir) emptySlot = index; } slot = emptySlot; return slot >= 0; } public int TryLifeSteal(int damageDone, int lifeStealLevel) { if (lifeStealLevel <= 0 || damageDone <= 0 || lifeStealCooldown > 0 || Player.dead || Main.netMode == NetmodeID.MultiplayerClient && Player.whoAmI != Main.myPlayer) { return 0; } int missingLife = Player.statLifeMax2 - Player.statLife; if (missingLife <= 0) return 0; int amount = Math.Min(missingLife, Math.Max(1, (int)Math.Ceiling(damageDone * lifeStealLevel * 0.01f))); Player.Heal(amount); lifeStealCooldown = 12; if (Main.netMode != NetmodeID.SinglePlayer) NetMessage.SendData(MessageID.PlayerLifeMana, -1, -1, null, Player.whoAmI); return amount; } internal int ApplyDeathReaperLifeSteal(int damageDone, Vector2 visualSource, int requestedLevel = 0) { if (damageDone <= 0 || Player.dead || Main.netMode == NetmodeID.MultiplayerClient) return 0; int lifeStealLevel = Math.Clamp(Math.Max(requestedLevel, DeathDomainProgression.LifeStealLevel), 0, DeathDomainProgression.MaxLifeStealLevel); if (lifeStealLevel <= 0) return 0; int missingLife = Math.Max(0, Player.statLifeMax2 - Player.statLife); int perHitCap = Math.Max(1, (int)Math.Ceiling(Player.statLifeMax2 * 0.025f)); int amount = Math.Min(missingLife, Math.Min(perHitCap, Math.Max(1, (int)Math.Ceiling(damageDone * lifeStealLevel * 0.01f)))); if (amount > 0) { Player.Heal(amount); if (Main.netMode == NetmodeID.Server) NetMessage.SendData(MessageID.PlayerLifeMana, -1, -1, null, Player.whoAmI); } // Death Reaper attacks always show the return stream, including at full life. // This makes every successful slash readable without granting phantom healing. LifeStealVisuals.Spawn(visualSource, Player.whoAmI, lifeStealLevel, amount, forceFeedback: true); return amount; } internal int TakeNextSwingDirection(int facingDirection) { int direction = swingComboDirection * facingDirection; swingComboDirection *= -1; return direction; } internal void StartSickleAlternateCooldown(int frames) { if (frames <= 0 || SickleAlternateCooldown > 0) return; SickleAlternateCooldown = frames; SickleAlternateCooldownMax = frames; } public float GetReaperEnergy(ReaperFormId form) { int index = (int)form; return index > (int)ReaperFormId.Base && index < reaperEnergy.Length ? MathHelper.Clamp(reaperEnergy[index], 0f, 100f) : 0f; } public int GetReaperUltimateCooldown(ReaperFormId form) { int index = (int)form; return index >= 0 && index < reaperUltimateCooldowns.Length ? Math.Max(0, reaperUltimateCooldowns[index]) : 0; } internal bool TryGainReaperEnergy(ReaperFormId form, float amount) { if (amount <= 0f || Main.netMode == NetmodeID.MultiplayerClient) return false; bool unlocked = form == ReaperFormId.Death ? ReaperProgression.DeathFormUnlocked : ReaperDefinitions.IsBranchForm(form) && ReaperProgression.GetStage(form) == ReaperStage.StageIII; int index = (int)form; if (!unlocked || index <= (int)ReaperFormId.Base || index >= reaperEnergy.Length) return false; float gainMultiplier = 1f + ReaperProgression.GetCommonNodeLevel(ReaperCommonNode.EnergyGain) * 0.2f; float previous = reaperEnergy[index]; reaperEnergy[index] = MathHelper.Clamp(previous + amount * gainMultiplier, 0f, 100f); if ((int)previous != (int)reaperEnergy[index] || reaperEnergy[index] >= 100f) SyncReaperCombat(); return reaperEnergy[index] > previous; } #if DEBUG internal void DebugSetReaperEnergy(ReaperFormId form, float amount) { if (Main.netMode == NetmodeID.MultiplayerClient) return; int index = (int)form; if (index <= (int)ReaperFormId.Base || index >= reaperEnergy.Length) return; reaperEnergy[index] = MathHelper.Clamp(amount, 0f, 100f); reaperUltimateCooldowns[index] = 0; ReaperExhaustionCooldown = 0; SyncReaperCombat(); } #endif internal void RecordReaperAttackHit(ReaperFormId form, ReaperHitKind kind) { // Ultimate charge has one universal source: an attack actually connecting // with an enemy. Damage-over-time ticks and the ultimate itself never feed // the meter, while primary, derived, returning and special hits all count. if (kind is ReaperHitKind.Ultimate or ReaperHitKind.UltimateDerived or ReaperHitKind.DamageOverTime) return; TryGainReaperEnergy(form, 4f); } internal bool TryStartReaperSpecialCooldown(int frames) { if (frames <= 0 || ReaperSpecialCooldown > 0) return false; float reduction = Math.Clamp( ReaperProgression.GetCommonNodeLevel(ReaperCommonNode.SpecialCooldown) * 0.1f, 0f, 0.5f); int adjustedFrames = Math.Max(30, (int)Math.Ceiling(frames * (1f - reduction))); ReaperSpecialCooldown = adjustedFrames; ReaperSpecialCooldownMax = adjustedFrames; SyncReaperCombat(); return true; } internal void ReduceReaperSpecialCooldown(int frames) { if (frames <= 0 || Main.netMode == NetmodeID.MultiplayerClient || ReaperSpecialCooldown <= 0) return; int previous = ReaperSpecialCooldown; ReaperSpecialCooldown = Math.Max(0, ReaperSpecialCooldown - frames); if (ReaperSpecialCooldown != previous) SyncReaperCombat(); } internal void RefundFailedReaperSpecial() { if (Main.netMode == NetmodeID.MultiplayerClient) return; ReaperSpecialCooldown = 0; ReaperSpecialCooldownMax = 0; SyncReaperCombat(); } internal bool TrySwitchOrQueueReaperForm(ReaperFormId form) { if (Main.netMode == NetmodeID.MultiplayerClient) return false; ulong now = Main.GameUpdateCount; bool assemblyActive = ReaperFormAssemblyProjectile.TryGetActive( Player, out ReaperFormAssemblyProjectile? activeAssembly); if (lastReaperFormSwitchRequestTick != 0 && now - lastReaperFormSwitchRequestTick < 10UL) { // Wheel input can commit twice while closing. Treat an identical // request as an idempotent acknowledgement instead of reporting a // misleading progression/prerequisite failure to the client. if (queuedReaperForm == form || activeAssembly?.TargetForm == form || ReaperProgression.CurrentForm == form) return true; } lastReaperFormSwitchRequestTick = now; if (!assemblyActive && ReaperProgression.CurrentForm == form) return true; if (IsReaperCombatActionBusy() || assemblyActive) { queuedReaperForm = form; return true; } queuedReaperForm = null; return TryStartReaperFormAssembly(form); } private void TryApplyQueuedReaperForm() { if (Main.netMode == NetmodeID.MultiplayerClient || queuedReaperForm is not ReaperFormId form || IsReaperCombatActionBusy() || ReaperFormAssemblyProjectile.TryGetActive(Player, out _)) return; queuedReaperForm = null; if (ReaperProgression.CurrentForm != form) TryStartReaperFormAssembly(form); } private bool IsReaperCombatActionBusy() { return Player.itemAnimation > 0 || Player.itemTime > 0 || Player.ownedProjectileCounts[ModContent.ProjectileType()] > 0 || Player.ownedProjectileCounts[ModContent.ProjectileType()] > 0; } internal bool IsReaperFormAssemblyActive() => ReaperFormAssemblyProjectile.TryGetActive(Player, out _); internal bool TryCompleteReaperFormAssembly(int projectileIndex, ReaperFormId form) { if (Main.netMode == NetmodeID.MultiplayerClient || projectileIndex < 0 || projectileIndex >= Main.maxProjectiles) { return false; } Projectile projectile = Main.projectile[projectileIndex]; if (!projectile.active || projectile.owner != Player.whoAmI || projectile.ModProjectile is not ReaperFormAssemblyProjectile assembly || assembly.TargetForm != form || !assembly.AssemblyComplete || !ReaperProgression.TrySetCurrentForm(form)) { return false; } VisibleReaperForm = ReaperCombatRegistry.ResolveUsableForm( ReaperProgression); VisibleReaperStage = ReaperProgression.GetStage(VisibleReaperForm); SyncReaperProgression(); return true; } private bool TryStartReaperFormAssembly(ReaperFormId form) { ReaperStage stage = form switch { ReaperFormId.Base => ReaperStage.StageI, ReaperFormId.Death => ReaperStage.StageIII, _ => ReaperProgression.GetStage(form) }; int level = ReaperProgression.GetCommonNodeLevel( ReaperCommonNode.AssemblySpeed); int duration = ReaperDefinitions.GetAssemblyFrames(level); int rangeLevel = ReaperProgression.GetCommonNodeLevel( ReaperCommonNode.DeathRange); bool started = ReaperFormAssemblyProjectile.Spawn(Player, form, stage, duration, rangeLevel) >= 0; #if DEBUG Mod.Logger.Info( $"SOULHARVEST_FORM_ASSEMBLY: start player={Player.whoAmI} from={ReaperProgression.CurrentForm} target={form} duration={duration} started={started}"); #endif return started; } internal bool TryConsumeReaperUltimate(ReaperFormId form, int cooldownFrames) { if (Main.netMode == NetmodeID.MultiplayerClient || form != ReaperProgression.CurrentForm || ReaperExhaustionCooldown > 0 || GetReaperUltimateCooldown(form) > 0 || GetReaperEnergy(form) < 100f) { return false; } bool unlocked = form == ReaperFormId.Death ? ReaperProgression.DeathFormUnlocked : ReaperDefinitions.IsBranchForm(form) && ReaperProgression.GetStage(form) == ReaperStage.StageIII; if (!unlocked) return false; int index = (int)form; reaperEnergy[index] = 0f; reaperUltimateCooldowns[index] = Math.Max(1, cooldownFrames); ReaperExhaustionCooldown = 60 * 8; SyncReaperCombat(); return true; } internal void RefundFailedReaperUltimate(ReaperFormId form) { if (Main.netMode == NetmodeID.MultiplayerClient) return; int index = (int)form; if (index <= (int)ReaperFormId.Base || index >= reaperEnergy.Length) return; reaperEnergy[index] = 100f; reaperUltimateCooldowns[index] = 0; ReaperExhaustionCooldown = 0; SyncReaperCombat(); } internal void RecordDeathCycleHit(int phase) { if (phase < 0 || phase >= 6 || ReaperProgression.CurrentForm != ReaperFormId.Death) return; byte bit = (byte)(1 << phase); if ((deathCycleMask & bit) != 0) return; deathCycleMask |= bit; if (deathCycleMask == 0x3F) deathCycleMask = 0; } internal void ClearReaperCombatRuntime(bool sync) { Array.Clear(reaperEnergy, 0, reaperEnergy.Length); Array.Clear(reaperUltimateCooldowns, 0, reaperUltimateCooldowns.Length); ReaperSpecialCooldown = 0; ReaperSpecialCooldownMax = 0; ReaperExhaustionCooldown = 0; deathCycleMask = 0; queuedReaperForm = null; pendingReaperDomainHarvests.Clear(); Player.GetModPlayer().ClearRuntime(); if (sync) SyncReaperCombat(); } internal void ResetReaperTreeForDebug() { ReaperProgression.ResetTreeForDebug(); VisibleReaperForm = ReaperFormId.Base; VisibleReaperStage = ReaperStage.StageI; ClearReaperCombatRuntime(sync: true); if (Main.netMode == NetmodeID.Server) SoulHarvest.SendReaperProgressionTransaction(Player, Array.Empty()); } internal void WriteReaperProgression(BinaryWriter writer) => ReaperProgression.NetSend(writer); internal void WriteDeathDomainProgression(BinaryWriter writer) => DeathDomainProgression.NetSend(writer); internal void ReceiveDeathDomainProgression(BinaryReader reader) => DeathDomainProgression.NetReceive(reader); internal void ReceiveReaperProgression(BinaryReader reader) { ReaperProgression.NetReceive(reader); VisibleReaperForm = ReaperCombatRegistry.ResolveUsableForm(ReaperProgression); VisibleReaperStage = ReaperProgression.GetStage(VisibleReaperForm); } internal void ReceiveReaperPublicState(ReaperFormId form, ReaperStage stage) { VisibleReaperForm = Enum.IsDefined(form) ? form : ReaperFormId.Base; VisibleReaperStage = stage is >= ReaperStage.Locked and <= ReaperStage.StageIII ? stage : ReaperStage.Locked; } internal void WriteReaperCombat(BinaryWriter writer) { writer.Write((byte)ReaperProgression.CurrentForm); for (int index = 0; index < reaperEnergy.Length; index++) writer.Write((ushort)Math.Clamp((int)Math.Round(reaperEnergy[index] * 10f), 0, 1000)); writer.Write((ushort)Math.Clamp(ReaperSpecialCooldown, 0, ushort.MaxValue)); writer.Write((ushort)Math.Clamp(ReaperSpecialCooldownMax, 0, ushort.MaxValue)); writer.Write((ushort)Math.Clamp(ReaperExhaustionCooldown, 0, ushort.MaxValue)); for (int index = 0; index < reaperUltimateCooldowns.Length; index++) writer.Write((ushort)Math.Clamp(reaperUltimateCooldowns[index], 0, ushort.MaxValue)); writer.Write(deathCycleMask); Player.GetModPlayer().WriteOwnerRuntime(writer); } internal void ReceiveReaperCombat(BinaryReader reader) { ReaperProgression.TrySetCurrentForm((ReaperFormId)reader.ReadByte()); VisibleReaperForm = ReaperCombatRegistry.ResolveUsableForm(ReaperProgression); VisibleReaperStage = ReaperProgression.GetStage(VisibleReaperForm); for (int index = 0; index < reaperEnergy.Length; index++) reaperEnergy[index] = MathHelper.Clamp(reader.ReadUInt16() / 10f, 0f, 100f); ReaperSpecialCooldown = reader.ReadUInt16(); ReaperSpecialCooldownMax = reader.ReadUInt16(); ReaperExhaustionCooldown = reader.ReadUInt16(); for (int index = 0; index < reaperUltimateCooldowns.Length; index++) reaperUltimateCooldowns[index] = reader.ReadUInt16(); deathCycleMask = reader.ReadByte(); Player.GetModPlayer().ReceiveOwnerRuntime(reader); } public void SyncReaperProgression() => SoulHarvest.SendReaperProgression(Player); private void SyncReaperCombat() => SoulHarvest.SendReaperCombat(Player); internal void SyncReaperCombatRuntime() => SyncReaperCombat(); public override void ProcessTriggers(TriggersSet triggersSet) { bool reaperInputBlocked = Main.playerInventory || Main.drawingPlayerChat || Main.editSign || Main.editChest || Main.mapFullscreen || Player.talkNPC >= 0 || Player.mouseInterface || Main.InGameUI.CurrentState is not null || ModContent.GetInstance().IsVisible || ReaperFormWheelUISystem.IsWheelOpen; bool holdingReaperSickle = Player.HeldItem.ModItem is NormalSickle; bool deathFormSelected = holdingReaperSickle && ReaperCombatRegistry.ResolveUsableForm(ReaperProgression) == ReaperFormId.Death; bool primaryInputHeld = deathFormSelected && Player.controlUseItem; if (reaperPrimaryInputWasHeld && !primaryInputHeld) { ReaperCombatService.ReleaseDeathPrimaryHold(Player); if (Main.netMode == NetmodeID.MultiplayerClient) SoulHarvest.SendReaperPrimaryRelease(Player); } reaperPrimaryInputWasHeld = primaryInputHeld; // Mouse2 is Death's documented input. Keep the configurable keybind, but // also read the physical button so a stale/missing keybind profile cannot // silently disable the whole right-click request chain. bool rawSpecialHeld = Main.mouseRight; bool specialHeld = SoulHarvest.SpecialAttackKeybind?.Current == true || rawSpecialHeld; bool specialPressed = SoulHarvest.SpecialAttackKeybind?.JustPressed == true || rawSpecialHeld && !rawSpecialInputWasHeld; bool specialReleased = SoulHarvest.SpecialAttackKeybind?.JustReleased == true || !specialHeld && rawSpecialInputWasHeld; // A release must still reach the server if an inventory or another UI was // opened during the charge. The controller also auto-releases at its cap. if (holdingReaperSickle && specialReleased) { if (Main.netMode == NetmodeID.MultiplayerClient) { // Release the replicated owner controller immediately as well as // notifying the server. A quick tap can end before the server's // spawn packet arrives; the controller also has an owner-input // fallback for that late-arrival case. ReaperActionControllerProjectile.RequestLocalOwnerRelease(Player); SoulHarvest.SendReaperSpecialRelease(Player); } else ReaperCombatService.TryReleaseSpecial(Player); } if (holdingReaperSickle && !reaperInputBlocked && !Player.dead && !Player.CCed && !Player.noItems) { if (specialHeld) Player.controlUseTile = false; Vector2 cursorOffset = Main.MouseWorld - Player.MountedCenter; Vector2 aim = cursorOffset.SafeNormalize(Vector2.UnitX * Player.direction); bool deathInvocationHeld = specialHeld && ReaperCombatRegistry.ResolveUsableForm(ReaperProgression) == ReaperFormId.Death; if (specialPressed) { if (Main.netMode == NetmodeID.MultiplayerClient) SoulHarvest.SendReaperAction(Player, ultimate: false, cursorOffset); else ReaperCombatService.TryStartSpecial(Player, cursorOffset); } else if (deathInvocationHeld && Main.GameUpdateCount % 5UL == (ulong)(Player.whoAmI % 5)) { int controllerType = ModContent.ProjectileType(); bool invocationExists = Player.ownedProjectileCounts[controllerType] > 0; if (Main.netMode == NetmodeID.MultiplayerClient) { // A press can reach the server during the last primary-swing // frame and be rejected legitimately. While Death's invocation // key remains held, retry the start at a bounded cadence until // its controller is replicated; afterwards packets only update // the cursor target. if (invocationExists) SoulHarvest.SendReaperSpecialAim(Player, cursorOffset); else SoulHarvest.SendReaperAction(Player, ultimate: false, cursorOffset); } else if (invocationExists) { ReaperCombatService.TryUpdateSpecialTarget(Player, cursorOffset); } else { ReaperCombatService.TryStartSpecial(Player, cursorOffset); } } if (SoulHarvest.UltimateKeybind?.JustPressed == true) { if (Main.netMode == NetmodeID.MultiplayerClient) SoulHarvest.SendReaperAction(Player, ultimate: true, cursorOffset); else ReaperCombatService.TryStartUltimate(Player, cursorOffset); } } rawSpecialInputWasHeld = rawSpecialHeld; ProcessDeathDomainKeybind(); } private void ProcessDeathDomainKeybind() { ModKeybind? keybind = SoulHarvest.ToggleDeathDomainKeybind; if (keybind is null) return; if (keybind.JustPressed) { deathDomainKeyHoldFrames = 0; deathDomainLongPressHandled = false; } if (keybind.Current) { deathDomainKeyHoldFrames++; if (!deathDomainLongPressHandled && deathDomainKeyHoldFrames >= DeathDomainLongPressFrames) { deathDomainLongPressHandled = true; TryToggleDeathDomainDescent(); } return; } if (keybind.JustReleased && !deathDomainLongPressHandled) TryToggleFiniteDeathDomain(); if (keybind.JustReleased || deathDomainKeyHoldFrames > 0) { deathDomainKeyHoldFrames = 0; deathDomainLongPressHandled = false; } } private void TryToggleFiniteDeathDomain() { DeathNecklace? necklace = ActiveDeathNecklace ?? FindEquippedDeathNecklace(); if (necklace is null) return; bool enabled = !DeathDomainEnabled; ReceiveDeathDomainState(enabled, descended: false); SoulHarvest.SendDeathDomainState(Player, DeathDomainEnabled, DeathDomainDescended); } private void TryToggleDeathDomainDescent() { DeathNecklace? necklace = ActiveDeathNecklace ?? FindEquippedDeathNecklace(); if (necklace is null) return; if (!necklace.DeathDescentUnlocked) return; bool descended = !DeathDomainDescended; ReceiveDeathDomainState(enabled: true, descended: descended); SoulHarvest.SendDeathDomainState(Player, DeathDomainEnabled, DeathDomainDescended); } internal void SetDeathNecklace(DeathNecklace necklace) { if (ActiveDeathNecklace is null || necklace.MasteryScore > ActiveDeathNecklace.MasteryScore) ActiveDeathNecklace = necklace; } private DeathNecklace? FindEquippedDeathNecklace() { DeathNecklace? best = null; foreach (Item item in Player.armor) { if (item.ModItem is DeathNecklace candidate && (best is null || candidate.MasteryScore > best.MasteryScore)) best = candidate; } return best; } internal void MergeLegacyDeathNecklaceProgression(DeathNecklace necklace) { bool changed = DeathDomainProgression.MergeLegacyNecklaceLevels( necklace.LegacyFrequencyLevel, necklace.LegacyDamageLevel, necklace.LegacyVolleyLevel, necklace.LegacyLifeStealLevel); if (!changed) return; if (Main.netMode == NetmodeID.Server) SyncPlayer(Player.whoAmI, -1, false); else if (Main.netMode == NetmodeID.MultiplayerClient && Player.whoAmI == Main.myPlayer) SoulHarvest.SendReaperProgression(Player); } internal DeathNecklace? GetEquippedDeathNecklace() => ActiveDeathNecklace ?? FindEquippedDeathNecklace(); internal bool IsDeathDomainDescended(DeathNecklace? necklace) => DeathDomainEnabled && DeathDomainDescended && necklace?.DeathDescentUnlocked == true; internal void SpawnDeathDomainHarvestFromReaperRift(NPC target, bool requiem, float rotation) { if (Main.netMode == NetmodeID.MultiplayerClient || !target.active) return; SpawnDeathDomainHarvestSlash(target, DeathDomainProgression.VisualMastery, requiem, rotation); } internal void QueueDeathDomainHarvestFromReaperHit(NPC target, float rotation, int deathUltimateActionId = -1) { if (Main.netMode == NetmodeID.MultiplayerClient || !target.active) return; const int telegraphFrames = 12; float visualMastery = DeathDomainProgression.VisualMastery; float scale = MathHelper.Clamp(Math.Max(target.width, target.height) / 52f, 0.85f, 2.4f); deathDomainVolleyCounter++; bool requiem = IsDeathDomainDescended(GetEquippedDeathNecklace()) && deathDomainVolleyCounter % 4 == 0; if (Main.netMode == NetmodeID.Server) { SoulHarvest.BroadcastDeathDomainTelegraph(Player, target, telegraphFrames, DeathDomainProgression.SlashCount, visualMastery, rotation, scale); } else { int projectileIndex = Projectile.NewProjectile( Player.GetSource_Misc("SoulHarvest:ReaperNecklaceTelegraph"), target.Center, Vector2.Zero, ModContent.ProjectileType(), 0, 0f, Player.whoAmI, target.whoAmI, telegraphFrames, DeathDomainProgression.SlashCount + visualMastery * 0.1f); if (projectileIndex >= 0 && projectileIndex < Main.maxProjectiles) { Projectile telegraph = Main.projectile[projectileIndex]; telegraph.rotation = rotation; telegraph.scale = scale; } } pendingReaperDomainHarvests.Add(new PendingReaperDomainHarvest( target.whoAmI, telegraphFrames, rotation, visualMastery, requiem, deathUltimateActionId)); } #if DEBUG internal bool HasPendingReaperDomainHarvest(int npcIndex) => pendingReaperDomainHarvests.Exists(pending => pending.NpcIndex == npcIndex); internal bool HasPendingDeathDomainStrike(int npcIndex) => pendingDeathDomainStrikes.Exists(pending => pending.NpcIndex == npcIndex); #endif private void UpdatePendingReaperDomainHarvests() { if (Main.netMode == NetmodeID.MultiplayerClient || pendingReaperDomainHarvests.Count == 0) { return; } for (int index = pendingReaperDomainHarvests.Count - 1; index >= 0; index--) { PendingReaperDomainHarvest pending = pendingReaperDomainHarvests[index]; int remainingFrames = pending.RemainingFrames - 1; if (remainingFrames > 0) { pendingReaperDomainHarvests[index] = pending with { RemainingFrames = remainingFrames }; continue; } pendingReaperDomainHarvests.RemoveAt(index); if (pending.NpcIndex < 0 || pending.NpcIndex >= Main.maxNPCs) continue; NPC target = Main.npc[pending.NpcIndex]; if (!ReaperTargeting.IsValidWeaponTarget(target)) { continue; } SpawnDeathDomainHarvestSlash(target, pending.VisualMastery, pending.Requiem, pending.Rotation, pending.DeathUltimateActionId); } } private void UpdateDeathDomain() { // UpdateAccessory normally supplies this reference. The direct equipment scan // keeps dedicated-server attacks stable on ticks where accessory recalculation // did not expose the ModItem instance before PostUpdate. DeathNecklace? necklace = ActiveDeathNecklace ?? FindEquippedDeathNecklace(); if (DeathDomainEnabled && necklace is null) { ReceiveDeathDomainState(enabled: false, descended: false); if (Main.netMode == NetmodeID.Server) SoulHarvest.BroadcastDeathDomainState(Player.whoAmI, enabled: false, descended: false); else SoulHarvest.SendDeathDomainState(Player, enabled: false, descended: false); } if (!DeathDomainEnabled || necklace is null || Player.dead) { deathDomainTimer = 0; ClearDeathDomainTelegraphs(); return; } // Percentage damage is authoritative. Clients receive synchronized NPC strikes // and the dedicated visual projectile spawned by the server. if (Main.netMode == NetmodeID.MultiplayerClient) return; bool descended = IsDeathDomainDescended(necklace); ApplyDeathDomainForces(necklace, descended); ApplyDeathDomainItemAttraction(); List targets = FindDeathDomainTargets(necklace, descended); float visualMastery = DeathDomainProgression.VisualMastery; UpdateDeathDomainTelegraphs(targets, visualMastery); deathDomainTimer++; if (deathDomainTimer < DeathDomainProgression.SpawnInterval) return; deathDomainTimer = 0; if (targets.Count == 0) { ClearDeathDomainTelegraphs(); return; } deathDomainVolleyCounter++; bool requiem = descended && deathDomainVolleyCounter % 4 == 0; foreach (NPC target in targets) { float rotation = deathDomainPendingCuts.TryGetValue(target.whoAmI, out PendingDeathDomainCut pending) ? pending.Rotation : Main.rand.NextFloat(MathHelper.TwoPi); SpawnDeathDomainHarvestSlash(target, visualMastery, requiem, rotation); } ClearDeathDomainTelegraphs(); } private void UpdateDeathDomainTelegraphs(List targets, float visualMastery) { HashSet activeTargets = []; foreach (NPC target in targets) activeTargets.Add(target.whoAmI); List staleTargets = []; foreach (int npcIndex in deathDomainPendingCuts.Keys) if (!activeTargets.Contains(npcIndex)) staleTargets.Add(npcIndex); foreach (int npcIndex in staleTargets) RemoveDeathDomainTelegraph(npcIndex); int remainingFrames = Math.Max(1, DeathDomainProgression.SpawnInterval - deathDomainTimer); foreach (NPC target in targets) { if (deathDomainPendingCuts.ContainsKey(target.whoAmI)) continue; float rotation = Main.rand.NextFloat(MathHelper.TwoPi); float scale = MathHelper.Clamp(Math.Max(target.width, target.height) / 52f, 0.85f, 2.4f); int projectileIndex = -1; if (Main.netMode == NetmodeID.Server) { SoulHarvest.BroadcastDeathDomainTelegraph( Player, target, remainingFrames, DeathDomainProgression.SlashCount, visualMastery, rotation, scale); } else { projectileIndex = Projectile.NewProjectile( Player.GetSource_Misc("SoulHarvest:DeathDomainTelegraph"), target.Center, Vector2.Zero, ModContent.ProjectileType(), 0, 0f, Player.whoAmI, target.whoAmI, remainingFrames, DeathDomainProgression.SlashCount + visualMastery * 0.1f); if (projectileIndex >= 0 && projectileIndex < Main.maxProjectiles) { Projectile telegraph = Main.projectile[projectileIndex]; telegraph.rotation = rotation; telegraph.scale = scale; } } deathDomainPendingCuts[target.whoAmI] = new PendingDeathDomainCut(rotation, projectileIndex); } } private void SpawnDeathDomainHarvestSlash(NPC target, float visualMastery, bool requiem, float rotation, int deathUltimateActionId = -1) { int slashDamage = CalculateDeathDomainSlashDamage(target, DeathDomainProgression); float scale = MathHelper.Clamp(Math.Max(target.width, target.height) / 52f, 0.85f, 2.4f); float visualState = visualMastery + (requiem ? 2f : 0f); if (Main.netMode == NetmodeID.Server) { for (int slash = 0; slash < DeathDomainProgression.SlashCount; slash++) { pendingDeathDomainStrikes.Add(new PendingDeathDomainStrike( target.whoAmI, DeathDomainHarvestSlashProjectile.SlashImpactFrame + slash * DeathDomainHarvestSlashProjectile.SlashDelayFrames, slashDamage, DeathDomainProgression.LifeStealLevel, deathUltimateActionId)); } SoulHarvest.BroadcastDeathDomainHarvestSlash( Player, target, DeathDomainProgression.SlashCount, visualMastery, requiem, rotation, scale); return; } int projectileIndex = Projectile.NewProjectile( Player.GetSource_Misc("SoulHarvest:DeathDomain"), target.Center, Vector2.Zero, ModContent.ProjectileType(), slashDamage, 0f, Player.whoAmI, target.whoAmI, DeathDomainProgression.SlashCount, visualState); if (projectileIndex < 0 || projectileIndex >= Main.maxProjectiles) return; Projectile visual = Main.projectile[projectileIndex]; visual.rotation = rotation; visual.scale = scale; visual.localAI[1] = DeathDomainProgression.LifeStealLevel; if (visual.ModProjectile is DeathDomainHarvestSlashProjectile harvest) harvest.SetDeathUltimateActionId(deathUltimateActionId); } private void UpdatePendingDeathDomainStrikes() { if (Main.netMode == NetmodeID.MultiplayerClient || pendingDeathDomainStrikes.Count == 0) return; for (int index = pendingDeathDomainStrikes.Count - 1; index >= 0; index--) { PendingDeathDomainStrike pending = pendingDeathDomainStrikes[index]; int remainingFrames = pending.RemainingFrames - 1; if (remainingFrames > 0) { pendingDeathDomainStrikes[index] = pending with { RemainingFrames = remainingFrames }; continue; } pendingDeathDomainStrikes.RemoveAt(index); ApplyDeathDomainStrike(pending.NpcIndex, pending.Damage, pending.LifeStealLevel, pending.DeathUltimateActionId); } } internal void ApplyDeathDomainStrike(int npcIndex, int damage, int lifeStealLevel, int deathUltimateActionId = -1) { if (npcIndex < 0 || npcIndex >= Main.maxNPCs) return; NPC target = Main.npc[npcIndex]; if (!ReaperTargeting.IsValidWeaponTarget(target)) return; bool bossOrBossPart = target.boss || NPCID.Sets.ShouldBeCountedAsBoss[target.type] || target.realLife >= 0 && target.realLife < Main.maxNPCs && Main.npc[target.realLife].boss; target.GetGlobalNPC().ApplyDeathDomainHitStop(target, bossOrBossPart ? 1 : 3); if (Main.netMode == NetmodeID.MultiplayerClient || damage <= 0) return; target.GetGlobalNPC().RegisterSickleHit(target, Player.whoAmI); target.lastInteraction = Player.whoAmI; NPC.HitInfo harvestHit = new() { Damage = damage, SourceDamage = damage, HitDirection = 0, Knockback = 0f, DamageType = DamageClass.Generic, Crit = false }; int lifeBeforeHit = target.life; Vector2 lifeStealSource = target.Center; NPC accumulationTarget = target.realLife >= 0 && target.realLife < Main.maxNPCs && Main.npc[target.realLife].active ? Main.npc[target.realLife] : target; target.StrikeNPC(harvestHit, fromNet: false, noPlayerInteraction: false); int actualDamage = Math.Max(0, lifeBeforeHit - Math.Max(0, target.life)); if (deathUltimateActionId >= 0 && actualDamage > 0) { accumulationTarget.GetGlobalNPC() .RecordDeathUltimateCut(accumulationTarget, Player.whoAmI, deathUltimateActionId, actualDamage); } ApplyDeathReaperLifeSteal(actualDamage, lifeStealSource, lifeStealLevel); if (Main.netMode == NetmodeID.Server) { NetMessage.SendStrikeNPC(target, in harvestHit); target.netUpdate = true; } } private void ClearDeathDomainTelegraphs() { if (deathDomainPendingCuts.Count == 0) return; foreach ((int npcIndex, PendingDeathDomainCut pending) in deathDomainPendingCuts) { KillDeathDomainTelegraph(pending.ProjectileIndex); SoulHarvest.BroadcastDeathDomainTelegraphClear(Player, npcIndex); } deathDomainPendingCuts.Clear(); } private void RemoveDeathDomainTelegraph(int npcIndex) { if (!deathDomainPendingCuts.Remove(npcIndex, out PendingDeathDomainCut pending)) return; KillDeathDomainTelegraph(pending.ProjectileIndex); SoulHarvest.BroadcastDeathDomainTelegraphClear(Player, npcIndex); } private void KillDeathDomainTelegraph(int projectileIndex) { if (projectileIndex < 0 || projectileIndex >= Main.maxProjectiles) return; Projectile projectile = Main.projectile[projectileIndex]; if (projectile.active && projectile.owner == Player.whoAmI && projectile.ModProjectile is DeathDomainHarvestTelegraphProjectile) { projectile.Kill(); } } private List FindDeathDomainTargets(DeathNecklace necklace, bool descended) { List targets = []; HashSet selectedTargets = []; bool harvestEntireWorld = descended; float radiusSquared = necklace.DomainRadius * necklace.DomainRadius; foreach (NPC npc in Main.ActiveNPCs) { // chaseable is intentionally not used: vanilla NPCs toggle it during // movement and transition states, which made dedicated-server harvests // appear to skip otherwise valid enemies from one cycle to the next. // Training dummies are excluded from the passive necklace domain even // though weapon-created Death cuts can explicitly strike them. if (ReaperTargeting.IsTrainingDummy(npc) || npc.friendly || npc.immortal || npc.dontTakeDamage || npc.lifeMax <= 5 || npc.life <= 0) continue; NPC target = npc.realLife >= 0 && npc.realLife < Main.maxNPCs && Main.npc[npc.realLife].active ? Main.npc[npc.realLife] : npc; if (ReaperTargeting.IsTrainingDummy(target) || target.friendly || target.immortal || target.dontTakeDamage || target.lifeMax <= 5 || target.life <= 0 || selectedTargets.Contains(target.whoAmI) || !harvestEntireWorld && Vector2.DistanceSquared(Player.Center, target.Center) > radiusSquared) { continue; } selectedTargets.Add(target.whoAmI); targets.Add(target); } return targets; } private void ApplyDeathDomainForces(DeathNecklace necklace, bool descended) { float innerRadius = descended ? 3200f : necklace.DomainRadius; float outerRadius = descended ? innerRadius : necklace.DomainAttractionRadius; float innerRadiusSquared = innerRadius * innerRadius; float outerRadiusSquared = outerRadius * outerRadius; float attractionWidth = Math.Max(1f, outerRadius - innerRadius); HashSet affectedTargets = []; foreach (NPC npc in Main.ActiveNPCs) { if (ReaperTargeting.IsTrainingDummy(npc) || npc.friendly || npc.immortal || npc.dontTakeDamage || npc.lifeMax <= 5 || npc.life <= 0) continue; NPC target = npc.realLife >= 0 && npc.realLife < Main.maxNPCs && Main.npc[npc.realLife].active ? Main.npc[npc.realLife] : npc; if (ReaperTargeting.IsTrainingDummy(target) || target.friendly || target.immortal || target.dontTakeDamage || target.lifeMax <= 5 || target.life <= 0 || !affectedTargets.Add(target.whoAmI)) { continue; } Vector2 toDomain = Player.Center - target.Center; float distanceSquared = toDomain.LengthSquared(); if (distanceSquared > outerRadiusSquared) continue; bool bossOrBossPart = target.boss || NPCID.Sets.ShouldBeCountedAsBoss[target.type] || target.realLife >= 0 && target.realLife < Main.maxNPCs && Main.npc[target.realLife].boss; float bossScale = bossOrBossPart ? 0.18f : 1f; float distance = (float)Math.Sqrt(distanceSquared); bool forceApplied; if (distanceSquared <= innerRadiusSquared) { // Inside the event horizon the flow reverses: enemies are expelled // from the pale-crimson core. The force reaches zero at the border // and rises quadratically toward the centre. Vector2 outward = distance > 0.001f ? -toDomain / distance : (target.whoAmI * 2.39996323f).ToRotationVector2(); float centralInfluence = 1f - MathHelper.Clamp(distance / innerRadius, 0f, 1f); centralInfluence *= centralInfluence; float force = necklace.DomainRepulsionStrength * centralInfluence * bossScale; float maximumOutwardSpeed = necklace.DomainRepulsionMaximumSpeed * (bossOrBossPart ? 0.35f : 1f); float currentOutwardSpeed = Vector2.Dot(target.velocity, outward); float allowedForce = Math.Max(0f, maximumOutwardSpeed - currentOutwardSpeed); float appliedForce = Math.Min(force, allowedForce); target.velocity += outward * appliedForce; forceApplied = appliedForce > 0f; } else { // Outside the border the black-hole streams draw enemies inward. float normalizedDistance = MathHelper.Clamp((distance - innerRadius) / attractionWidth, 0f, 1f); float edgeInfluence = 1f - normalizedDistance; edgeInfluence = edgeInfluence * edgeInfluence * (3f - 2f * edgeInfluence); float force = necklace.DomainAttractionStrength * MathHelper.Lerp(0.22f, 1f, edgeInfluence) * bossScale; float maximumInwardSpeed = necklace.DomainAttractionMaximumSpeed * (bossOrBossPart ? 0.35f : 1f); Vector2 inward = toDomain / distance; float currentInwardSpeed = Vector2.Dot(target.velocity, inward); float allowedForce = Math.Max(0f, maximumInwardSpeed - currentInwardSpeed); float appliedForce = Math.Min(force, allowedForce); target.velocity += inward * appliedForce; forceApplied = appliedForce > 0f; } // Periodic authoritative velocity updates are enough for this gentle // force while avoiding an NPC sync packet on every tick. if (forceApplied && (Main.GameUpdateCount + (ulong)target.whoAmI) % 10UL == 0UL) target.netUpdate = true; } } private void ApplyDeathDomainItemAttraction() { // The domain owns the whole world while active. Move eligible drops in // world space on the authority instead of relying on vanilla pickup range; // this also lets coins and loot travel through terrain rather than becoming // trapped against a distant wall. Eligible drops are reserved to the // domain owner while the attraction is active. Vector2 destination = Player.MountedCenter; for (int itemIndex = 0; itemIndex < Main.maxItems; itemIndex++) { Item item = Main.item[itemIndex]; if (!item.active || item.IsAir || item.stack <= 0 || !ItemLoader.CanPickup(item, Player) || !Player.CanPullItem(item, Player.ItemSpace(item))) { continue; } // Item.Update continuously chooses a reservation owner. Without // claiming the drop here, that vanilla pass can immediately hand a // full-world attraction target to another nearby player and undo the // domain movement before its owner can collect it. item.playerIndexTheItemIsReservedFor = Player.whoAmI; Vector2 toPlayer = destination - item.Center; float distance = toPlayer.Length(); if (distance <= 1f) continue; Vector2 direction = toPlayer / distance; float step = Math.Min(distance, MathHelper.Clamp(distance * 0.085f, 18f, 180f)); item.position += direction * step; item.velocity = direction * Math.Min(24f, 8f + distance * 0.004f); item.noGrabDelay = 0; if (distance < 54f) { item.Center = destination; item.velocity = Vector2.Zero; } if (Main.netMode == NetmodeID.Server && (Main.GameUpdateCount + (ulong)itemIndex) % 6UL == 0UL) { NetMessage.SendData(MessageID.SyncItem, -1, -1, null, itemIndex); } } } private static int CalculateDeathDomainSlashDamage(NPC target, DeathDomainProgression progression) { bool bossOrBossPart = target.boss || NPCID.Sets.ShouldBeCountedAsBoss[target.type] || target.realLife >= 0 && target.realLife < Main.maxNPCs && Main.npc[target.realLife].boss; float lifeRatio = bossOrBossPart ? progression.BossSlashLifeRatio : progression.NormalSlashLifeRatio; return Math.Max(1, (int)Math.Ceiling(target.lifeMax * lifeRatio)); } private void SyncSoulBalance() { if (Main.netMode != NetmodeID.Server || Player.whoAmI < 0 || Player.whoAmI >= Netplay.Clients.Length || !Netplay.Clients[Player.whoAmI].IsActive) { return; } SyncPlayer(Player.whoAmI, -1, false); } }