#if DEBUG using SoulHarvest.Items; using SoulHarvest.Projectiles; using Microsoft.Xna.Framework; using System; using System.Collections.Generic; using System.IO; using Terraria; using Terraria.ID; using Terraria.ModLoader; namespace SoulHarvest.Common; /// /// Opt-in dedicated-server integration checks. This code is excluded from the /// release package and only runs when SOULHARVEST_SERVER_SELFTEST=1. /// public sealed class ReaperDedicatedServerSelfTest : ModCommand { internal const string PassResult = "SOULHARVEST_SERVER_SELFTEST: PASS packet-upload=2/2 assembly-speed=7/7 max-parallel=30f right-click-direct=1f form-switch=8/8 right-click=8/8 energy=7/7 soul-mark-harvest=2/2 death-prelude=12/12 dummy-hit-domain=4/4 rift=65-points"; public override string Command => "soulharvestselftest"; public override CommandType Type => CommandType.Console; public override string Usage => "soulharvestselftest"; public override string Description => "Runs the opt-in Soul Harvest dedicated-server integration checks."; public override void Action(CommandCaller caller, string input, string[] args) { if (Main.netMode != NetmodeID.Server || Environment.GetEnvironmentVariable( "SOULHARVEST_SERVER_SELFTEST") != "1") { caller.Reply( "SOULHARVEST_SERVER_SELFTEST is disabled.", Color.OrangeRed); return; } // Console commands arrive on the server input thread. Projectile and // player state must only be exercised on the main thread; otherwise the // normal world tick can advance or recycle a test controller midway // through an assertion loop. Main.QueueMainThreadAction(() => { Mod mod = ModContent.GetInstance(); try { Run(mod); mod.Logger.Info(PassResult); caller.Reply(PassResult, Color.LightGreen); } catch (Exception exception) { mod.Logger.Error( "SOULHARVEST_SERVER_SELFTEST: FAIL", exception); caller.Reply( $"SOULHARVEST_SERVER_SELFTEST: FAIL {exception}", Color.OrangeRed); } }); } internal static void Run(Mod mod) { int playerIndex = -1; foreach (Player candidate in Main.ActivePlayers) { // Inactive Player instances are created before mod content finishes // resizing their per-player arrays. Using one here can therefore // index ownedProjectileCounts or the ModPlayer table with a modded // type that the placeholder never received. A fully joined player // is the only server slot guaranteed to have both tables initialized. if (candidate.GetModPlayer().ServerCharacterStateReady) { playerIndex = candidate.whoAmI; break; } } if (playerIndex < 0) throw new InvalidOperationException( "Self-test requires one fully initialized joined player."); Player player = Main.player[playerIndex]; bool oldActive = player.active; bool oldDead = player.dead; bool oldNoItems = player.noItems; int oldSelectedItem = player.selectedItem; int oldHeldProjectile = player.heldProj; Vector2 oldPosition = player.position; Vector2 oldVelocity = player.velocity; Item oldItem = player.inventory[0].Clone(); int testNpcIndex = -1; try { player.whoAmI = playerIndex; player.active = true; player.dead = false; player.noItems = false; player.selectedItem = 0; player.heldProj = -1; player.position = new Vector2(Main.spawnTileX * 16f, Math.Max(10, Main.spawnTileY - 3) * 16f); player.velocity = Vector2.Zero; player.inventory[0].SetDefaults( ModContent.ItemType()); player.ownedProjectileCounts[ModContent.ProjectileType< ReaperActionControllerProjectile>()] = 0; MyPlayer modPlayer = player.GetModPlayer(); modPlayer.Initialize(); ReaperProgressionState uploadedProgression = new(); uploadedProgression.UnlockDeathMasteryForMigration(); uploadedProgression.SetCommonNodeLevel( ReaperCommonNode.DeathTempoCap, ReaperDefinitions.GetCommonNodeMaxLevel( ReaperCommonNode.DeathTempoCap)); RunUploadPacket(mod, playerIndex, uploadedProgression); Require(modPlayer.ServerCharacterStateReady, "server character state was not initialized"); Require(modPlayer.ReaperProgression.DeathFormUnlocked, "uploaded progression did not unlock Death"); // A real client retries until the acknowledgement arrives. The retry // must be consumed completely without replacing server authority. RunUploadPacket(mod, playerIndex, uploadedProgression); int maximumAssemblyLevel = ReaperDefinitions.GetCommonNodeMaxLevel( ReaperCommonNode.AssemblySpeed); int[] expectedAssemblyFrames = [414, 382, 366, 334, 318, 30]; for (int level = 0; level <= maximumAssemblyLevel; level++) { Require(ReaperDefinitions.GetAssemblyFrames(level) == expectedAssemblyFrames[level], $"assembly level {level} resolved to {ReaperDefinitions.GetAssemblyFrames(level)} frames instead of {expectedAssemblyFrames[level]}"); } Require(ReaperDefinitions.GetAssemblyFrames(maximumAssemblyLevel + 50) == ReaperDefinitions.AssemblyMaximumLevelFrames, "assembly speed did not clamp above its maximum level"); ReaperFormId[] assemblyTargets = [ ReaperFormId.Bone, ReaperFormId.Blood, ReaperFormId.Infernal, ReaperFormId.Frost, ReaperFormId.Soul, ReaperFormId.Void, ReaperFormId.Death, ReaperFormId.Base ]; Require(modPlayer.ReaperProgression.TrySetCurrentForm( ReaperFormId.Base), "could not select Base before form-assembly tests"); foreach (ReaperFormId targetForm in assemblyTargets) { player.itemAnimation = 0; player.itemTime = 0; ReaperFormId originalForm = modPlayer.ReaperProgression.CurrentForm; Require(ReaperProgressionService.TrySwitchForm(player, targetForm, out ReaperProgressionFailure switchFailure), $"server rejected assembly switch to {targetForm}: {switchFailure}"); int assemblyIndex = FindOwnedProjectile(playerIndex, ModContent.ProjectileType()); Require(assemblyIndex >= 0, $"switch to {targetForm} did not create an assembly controller"); ReaperFormAssemblyProjectile? formAssembly = Main.projectile[assemblyIndex].ModProjectile as ReaperFormAssemblyProjectile; Require(formAssembly is not null && formAssembly.TargetForm == targetForm && formAssembly.Duration == expectedAssemblyFrames[0], $"switch to {targetForm} did not preserve its target and 0-level duration"); for (int frame = 0; frame < expectedAssemblyFrames[0]; frame++) { player.active = true; formAssembly!.AI(); Require(modPlayer.ReaperProgression.CurrentForm == originalForm, $"switch to {targetForm} committed during assembly frame {frame + 1}"); Require(formAssembly.CanDamage() == false, $"switch to {targetForm} became damaging during assembly"); } Require(formAssembly!.AssemblyComplete, $"switch to {targetForm} did not finish its {expectedAssemblyFrames[0]}-frame assembly clock"); player.active = true; formAssembly.AI(); Require(modPlayer.ReaperProgression.CurrentForm == targetForm, $"switch to {targetForm} was not committed after assembly completed"); Require(!Main.projectile[assemblyIndex].active, $"switch controller for {targetForm} remained active after commit"); player.heldProj = -1; } modPlayer.ReaperProgression.SetCommonNodeLevel( ReaperCommonNode.AssemblySpeed, maximumAssemblyLevel); player.itemAnimation = 0; player.itemTime = 0; Require(ReaperProgressionService.TrySwitchForm(player, ReaperFormId.Bone, out ReaperProgressionFailure maxFailure), $"server rejected max-level parallel assembly: {maxFailure}"); int maxAssemblyIndex = FindOwnedProjectile(playerIndex, ModContent.ProjectileType()); Require(maxAssemblyIndex >= 0 && Main.projectile[maxAssemblyIndex].ModProjectile is ReaperFormAssemblyProjectile maxAssembly && maxAssembly.Duration == ReaperDefinitions.AssemblyMaximumLevelFrames, "max-level form switch did not create a 30-frame controller"); ReaperFormAssemblyProjectile maximumAssembly = (ReaperFormAssemblyProjectile)Main.projectile[ maxAssemblyIndex].ModProjectile!; for (int frame = 0; frame < ReaperDefinitions.AssemblyMaximumLevelFrames; frame++) { player.active = true; maximumAssembly.AI(); Require(modPlayer.ReaperProgression.CurrentForm == ReaperFormId.Base, $"max-level form assembly committed during frame {frame + 1}"); Require(maximumAssembly.CanDamage() == false, "max-level form assembly became damaging"); } Require(maximumAssembly.AssemblyComplete, "max-level form assembly did not complete at 30 frames"); player.active = true; maximumAssembly.AI(); Require(modPlayer.ReaperProgression.CurrentForm == ReaperFormId.Bone, "max-level form assembly did not commit after its visual clock"); modPlayer.ReaperProgression.SetCommonNodeLevel( ReaperCommonNode.AssemblySpeed, 0); Require(modPlayer.ReaperProgression.TrySetCurrentForm( ReaperFormId.Base), "could not restore Base after max assembly test"); player.heldProj = -1; ReaperFormId[] actionForms = [ ReaperFormId.Base, ReaperFormId.Bone, ReaperFormId.Blood, ReaperFormId.Infernal, ReaperFormId.Frost, ReaperFormId.Soul, ReaperFormId.Void, ReaperFormId.Death ]; foreach (ReaperFormId form in actionForms) { player.active = true; Require(modPlayer.ReaperProgression.TrySetCurrentForm(form), $"could not select {form}"); modPlayer.ClearReaperCombatRuntime(sync: false); RunSpecialStartPacket(mod, playerIndex); int controllerIndex = FindOwnedProjectile(playerIndex, ModContent.ProjectileType()); Require(controllerIndex >= 0, $"server rejected right-click for {form}"); player.active = true; Main.projectile[controllerIndex].ModProjectile?.AI(); Require(Main.projectile[controllerIndex].active, $"right-click controller for {form} failed server authorization"); Main.projectile[controllerIndex].Kill(); player.heldProj = -1; } ReaperFormId[] energyForms = [ ReaperFormId.Bone, ReaperFormId.Blood, ReaperFormId.Infernal, ReaperFormId.Frost, ReaperFormId.Soul, ReaperFormId.Void, ReaperFormId.Death ]; modPlayer.ClearReaperCombatRuntime(sync: false); foreach (ReaperFormId form in energyForms) { float before = modPlayer.GetReaperEnergy(form); modPlayer.RecordReaperAttackHit(form, ReaperHitKind.Primary); Require(modPlayer.GetReaperEnergy(form) > before, $"a server hit did not charge {form} energy"); } testNpcIndex = FindInactiveNpc(); Require(testNpcIndex >= 0, "no free NPC slot was available for the training dummy"); // Exercise the complete authoritative Harvest Sickle ownership path: // a server hit must apply Death Mark, then the marked kill must award // souls to the registered player. NPC harvestTarget = Main.npc[testNpcIndex]; harvestTarget.SetDefaults(NPCID.BlueSlime); harvestTarget.whoAmI = testNpcIndex; harvestTarget.active = true; harvestTarget.life = Math.Max(1, harvestTarget.lifeMax); harvestTarget.Center = player.Center + new Vector2(150f, 0f); Require(modPlayer.ReaperProgression.TrySetCurrentForm( ReaperFormId.Base), "could not select Harvest Sickle for soul test"); SickleCombatSnapshot harvestSnapshot = SickleCombatSnapshot.Capture( player, Math.Max(1, player.GetWeaponDamage(player.HeldItem)), player.GetWeaponKnockback(player.HeldItem, player.HeldItem.knockBack)); int harvestSwingIndex = Projectile.NewProjectile( player.GetSource_Misc("SoulHarvest:ServerSelfTestSoulHarvest"), player.MountedCenter, Vector2.UnitX, ModContent.ProjectileType(), harvestSnapshot.Damage, harvestSnapshot.Knockback, playerIndex); Require(harvestSwingIndex >= 0 && harvestSwingIndex < Main.maxProjectiles, "server did not create the Harvest Sickle test projectile"); Projectile harvestSwing = Main.projectile[harvestSwingIndex]; ((SickleSwingProjectile)harvestSwing.ModProjectile!).Configure( harvestSnapshot, 0, authorizeServer: true); harvestSwing.GetGlobalProjectile().OnHitNPC( harvestSwing, harvestTarget, new NPC.HitInfo(), damageDone: 1); Require(harvestTarget.HasBuff( ModContent.BuffType()), "authoritative Harvest Sickle hit did not apply Death Mark"); MyGlobalNPC harvestStatus = harvestTarget.GetGlobalNPC(); Require(harvestStatus.HasSickleHarvester(playerIndex), "authoritative Harvest Sickle hit did not register its harvester"); player.active = true; Require(modPlayer.ServerCharacterStateReady, "harvest player character state was not authoritative"); int soulsBeforeHarvest = modPlayer.Souls; harvestStatus.OnKill(harvestTarget); Require(modPlayer.Souls > soulsBeforeHarvest, $"marked server kill did not award harvested souls before={soulsBeforeHarvest} after={modPlayer.Souls} lifeMax={harvestTarget.lifeMax} friendly={harvestTarget.friendly}"); harvestSwing.Kill(); harvestTarget.active = false; foreach (Projectile projectile in Main.ActiveProjectiles) { if (projectile.owner == playerIndex && projectile.type == ModContent.ProjectileType< ReaperDeathEchoScytheProjectile>()) { projectile.Kill(); } } // Death Mouse2 invokes its remote echo directly. Even an immediate // release must receive one authoritative attack tick, and it must // never enter or reverse the primary weapon's assembly timeline. Require(modPlayer.ReaperProgression.TrySetCurrentForm( ReaperFormId.Death), "could not select Death for direct right-click test"); modPlayer.ClearReaperCombatRuntime(sync: false); player.active = true; RunSpecialStartPacket(mod, playerIndex); int deathControllerIndex = FindOwnedProjectile(playerIndex, ModContent.ProjectileType()); Require(deathControllerIndex >= 0, "server did not create the direct Death right-click controller"); ReaperActionControllerProjectile deathController = (ReaperActionControllerProjectile)Main.projectile[ deathControllerIndex].ModProjectile!; Require(deathController.DeathAssemblyComplete && deathController.DeathAssemblyElapsed == 0, "Death right-click incorrectly entered an assembly prelude"); Require(ReaperCombatService.TryReleaseSpecial(player), "server rejected immediate Death right-click release"); player.active = true; deathController.AI(); Require(CountOwnedProjectiles(playerIndex, ModContent.ProjectileType()) == 1, "Death right-click did not attack on its first authoritative tick"); Require(!deathController.DeathAssemblyCancelling && deathController.DeathAssemblyElapsed == 0, "Death right-click created hidden assembly or cancellation state"); for (int frame = 0; frame < 9; frame++) { player.active = true; if (Main.projectile[deathControllerIndex].active) deathController.AI(); } Require(!Main.projectile[deathControllerIndex].active, "released Death right-click controller did not clean up"); Require(CountOwnedProjectiles(playerIndex, ModContent.ProjectileType()) == 1, "immediate Death right-click release created duplicate attacks"); foreach (Projectile projectile in Main.ActiveProjectiles) { if (projectile.owner == playerIndex && projectile.type == ModContent.ProjectileType< ReaperDeathEchoScytheProjectile>()) { projectile.Kill(); } } NPC trainingDummy = Main.npc[testNpcIndex]; trainingDummy.SetDefaults(NPCID.TargetDummy); trainingDummy.whoAmI = testNpcIndex; trainingDummy.active = true; trainingDummy.life = Math.Max(1, trainingDummy.lifeMax); trainingDummy.Center = player.Center + new Vector2(180f, 0f); Require(ReaperTargeting.IsValidWeaponTarget(trainingDummy), "the server rejected the training dummy as a weapon target"); Require(modPlayer.ReaperProgression.TrySetCurrentForm( ReaperFormId.Death), "could not select Death for the dummy hit"); SickleCombatSnapshot deathHitSnapshot = SickleCombatSnapshot.Capture( player, Math.Max(1, player.GetWeaponDamage(player.HeldItem)), player.GetWeaponKnockback(player.HeldItem, player.HeldItem.knockBack)); int swingIndex = Projectile.NewProjectile( player.GetSource_Misc("SoulHarvest:ServerSelfTestDummyHit"), player.MountedCenter, Vector2.UnitX, ModContent.ProjectileType(), deathHitSnapshot.Damage, deathHitSnapshot.Knockback, playerIndex); Require(swingIndex >= 0 && swingIndex < Main.maxProjectiles, "server did not create the Death primary test projectile"); Projectile deathSwing = Main.projectile[swingIndex]; Require(deathSwing.ModProjectile is SickleSwingProjectile, "Death primary test projectile had the wrong ModProjectile"); SickleSwingProjectile swing = (SickleSwingProjectile)deathSwing.ModProjectile!; swing.Configure(deathHitSnapshot, 0, authorizeServer: true); for (int frame = 0; frame < deathHitSnapshot.AssemblyFrames - 1; frame++) { player.active = true; swing.AI(); } Require(!swing.DeathAssemblyComplete, "Death left-click assembly completed one frame early"); Require(swing.CanDamage() == false, "Death left-click could damage during its assembly prelude"); player.active = true; swing.AI(); Require(swing.DeathAssemblyComplete, $"Death left-click assembly did not finish after {deathHitSnapshot.AssemblyFrames} frames"); Require(swing.CanDamage() == false, "Death left-click damaged on the final assembly frame"); player.active = true; swing.AI(); Require(swing.CanDamage() is null, "Death left-click did not enter its damaging swing after assembly"); MyGlobalProjectile hitData = deathSwing.GetGlobalProjectile< MyGlobalProjectile>(); float deathEnergyBeforeHit = modPlayer.GetReaperEnergy( ReaperFormId.Death); hitData.OnHitNPC(deathSwing, trainingDummy, new NPC.HitInfo(), damageDone: 1); Require(modPlayer.GetReaperEnergy(ReaperFormId.Death) > deathEnergyBeforeHit, "the authoritative Death primary dummy hit did not charge energy"); Require(modPlayer.HasPendingReaperDomainHarvest(testNpcIndex), "the authoritative Death primary dummy hit did not queue its Domain slash"); for (int frame = 0; frame < 12; frame++) modPlayer.PostUpdate(); Require(!modPlayer.HasPendingReaperDomainHarvest(testNpcIndex), "the training-dummy Domain telegraph did not resolve after 12 frames"); Require(modPlayer.HasPendingDeathDomainStrike(testNpcIndex), "the training-dummy Domain telegraph did not create its slash strike"); deathSwing.Kill(); Require(!ReaperCombatService.RequiresDeathPrimaryAssembly(player), "completed Death left-click did not latch assembly while held"); int heldSwingIndex = Projectile.NewProjectile( player.GetSource_Misc("SoulHarvest:ServerSelfTestHeldDeath"), player.MountedCenter, Vector2.UnitX, ModContent.ProjectileType(), deathHitSnapshot.Damage, deathHitSnapshot.Knockback, playerIndex); Require(heldSwingIndex >= 0 && heldSwingIndex < Main.maxProjectiles, "server did not create the held Death follow-up swing"); SickleSwingProjectile heldSwing = (SickleSwingProjectile) Main.projectile[heldSwingIndex].ModProjectile!; heldSwing.Configure(deathHitSnapshot, 1, authorizeServer: true, requireDeathAssembly: ReaperCombatService .RequiresDeathPrimaryAssembly(player)); Require(!heldSwing.DeathAssemblyRequired, "held Death follow-up incorrectly requested another assembly"); player.active = true; heldSwing.AI(); Require(heldSwing.CanDamage() is null, "held Death follow-up did not attack immediately"); Main.projectile[heldSwingIndex].Kill(); ReaperCombatService.ReleaseDeathPrimaryHold(player); Require(ReaperCombatService.RequiresDeathPrimaryAssembly(player), "Death left-click release did not reset its assembly latch"); int cancelledSwingIndex = Projectile.NewProjectile( player.GetSource_Misc("SoulHarvest:ServerSelfTestCancelledDeath"), player.MountedCenter, Vector2.UnitX, ModContent.ProjectileType(), deathHitSnapshot.Damage, deathHitSnapshot.Knockback, playerIndex); Require(cancelledSwingIndex >= 0 && cancelledSwingIndex < Main.maxProjectiles, "server did not create the cancellable Death left-click swing"); SickleSwingProjectile cancelledSwing = (SickleSwingProjectile) Main.projectile[cancelledSwingIndex].ModProjectile!; cancelledSwing.Configure(deathHitSnapshot, 2, authorizeServer: true, requireDeathAssembly: true); for (int frame = 0; frame < 90; frame++) { player.active = true; cancelledSwing.AI(); } ReaperCombatService.ReleaseDeathPrimaryHold(player); Require(cancelledSwing.DeathAssemblyCancelling, "Death left-click release did not enter reverse assembly"); Require(cancelledSwing.CanDamage() == false, "cancelled Death left-click became damaging"); for (int frame = 0; frame < ReaperDefinitions.AssemblyCancelFrames; frame++) { if (!Main.projectile[cancelledSwingIndex].active) break; player.active = true; cancelledSwing.AI(); Require(cancelledSwing.CanDamage() == false, "reverse Death left-click became damaging during cleanup"); } Require(!Main.projectile[cancelledSwingIndex].active, "cancelled Death left-click survived reverse assembly cleanup"); for (int frame = 0; frame < 120; frame++) modPlayer.PostUpdate(); Require(modPlayer.ReaperProgression.TrySetCurrentForm( ReaperFormId.Death), "could not restore Death form"); SickleCombatSnapshot snapshot = SickleCombatSnapshot.Capture( player, Math.Max(1, player.GetWeaponDamage(player.HeldItem)), player.GetWeaponKnockback(player.HeldItem, player.HeldItem.knockBack)); Vector2 shatterVisualCenter = player.Center + new Vector2(73f, -41f); int shatterIndex = ReaperDeathShatterProjectile.Spawn( player.GetSource_Misc("SoulHarvest:ServerSelfTestShatterCenter"), playerIndex, snapshot, trainingDummy, 1, 731, shatterVisualCenter, drawCenterVisual: true); Require(shatterIndex >= 0 && shatterIndex < Main.maxProjectiles, "server did not create the Death shatter center test projectile"); Require(Main.projectile[shatterIndex].ModProjectile is ReaperDeathShatterProjectile shatter && shatter.DrawsCenterVisual && Vector2.DistanceSquared(shatter.VisualCenter, shatterVisualCenter) < 0.01f, "Death shatter did not preserve the shared cut center"); Require(Vector2.DistanceSquared(Main.projectile[shatterIndex].Center, trainingDummy.Center) < 0.01f, "Death shatter visual center replaced its damage target center"); Main.projectile[shatterIndex].Kill(); Vector2 grip = player.MountedCenter; Vector2[] path = SickleSwingProjectile.BuildDeathDomainRiftPath( grip, 0.37f, -1, snapshot, 1f); Require(path.Length == 65, $"rift path contained {path.Length} points instead of 65"); float expectedReach = ReaperCombatRegistry .GetDeathPrimaryBladeReach(snapshot); for (int index = 0; index < path.Length; index++) { Require(float.IsFinite(path[index].X) && float.IsFinite(path[index].Y), $"rift point {index} was not finite"); Require(Math.Abs(Vector2.Distance(grip, path[index]) - expectedReach) <= 0.05f, $"rift point {index} left the blade-tip locus"); if (index > 0) Require(Vector2.Distance(path[index - 1], path[index]) >= 5f, $"rift segment {index} collapsed"); } float actualPathLength = 0f; for (int index = 1; index < path.Length; index++) actualPathLength += Vector2.Distance(path[index - 1], path[index]); float expectedPathLength = expectedReach * ReaperCombatRegistry.DeathPrimaryHalfSweep * 2f; Require(Math.Abs(actualPathLength - expectedPathLength) <= expectedPathLength * 0.02f, $"rift path length {actualPathLength:0.0} did not represent the full {expectedPathLength:0.0} blade arc"); int riftIndex = ReaperDeathDomainRiftProjectile.Spawn( player.GetSource_Misc("SoulHarvest:ServerSelfTest"), playerIndex, snapshot, path, 0, 900001); Require(riftIndex >= 0 && Main.projectile[riftIndex].active, "server did not spawn the max-tempo lingering rift"); Main.projectile[riftIndex].ModProjectile?.AI(); Require(Main.projectile[riftIndex].active, "lingering rift failed server authorization"); Main.projectile[riftIndex].Kill(); } finally { KillOwnedTestProjectiles(playerIndex); if (testNpcIndex >= 0 && testNpcIndex < Main.maxNPCs) Main.npc[testNpcIndex].active = false; player.GetModPlayer().Initialize(); player.inventory[0] = oldItem; player.selectedItem = oldSelectedItem; player.heldProj = oldHeldProjectile; player.position = oldPosition; player.velocity = oldVelocity; player.noItems = oldNoItems; player.dead = oldDead; player.active = oldActive; } } private static void RunUploadPacket(Mod mod, int sender, ReaperProgressionState progression) { using MemoryStream stream = new(); using (BinaryWriter writer = new(stream, System.Text.Encoding.UTF8, leaveOpen: true)) { writer.Write((byte)SoulHarvest.MessageType .UploadReaperCharacterState); writer.Write(4321); writer.Write(false); writer.Write(false); new DeathDomainProgression().NetSend(writer); progression.NetSend(writer); } stream.Position = 0; using BinaryReader reader = new(stream); mod.HandlePacket(reader, sender); Require(stream.Position == stream.Length, $"character upload left {stream.Length - stream.Position} unread bytes"); } private static void RunSpecialStartPacket(Mod mod, int sender) { using MemoryStream stream = new(); using (BinaryWriter writer = new(stream, System.Text.Encoding.UTF8, leaveOpen: true)) { writer.Write((byte)SoulHarvest.MessageType.RequestReaperAction); writer.Write((byte)SoulHarvest.ReaperActionRequest.SpecialStart); writer.Write(320f); writer.Write(-48f); } stream.Position = 0; using BinaryReader reader = new(stream); mod.HandlePacket(reader, sender); Require(stream.Position == stream.Length, "right-click request was not completely consumed"); } private static int FindOwnedProjectile(int owner, int type) { for (int index = 0; index < Main.maxProjectiles; index++) { Projectile projectile = Main.projectile[index]; if (projectile.active && projectile.owner == owner && projectile.type == type) { return index; } } return -1; } private static int FindInactiveNpc() { for (int index = 0; index < Main.maxNPCs; index++) { if (!Main.npc[index].active) return index; } return -1; } private static int CountOwnedProjectiles(int owner, int type) { int count = 0; foreach (Projectile projectile in Main.ActiveProjectiles) { if (projectile.owner == owner && projectile.type == type) count++; } return count; } private static void KillOwnedTestProjectiles(int owner) { int controllerType = ModContent.ProjectileType< ReaperActionControllerProjectile>(); int riftType = ModContent.ProjectileType< ReaperDeathDomainRiftProjectile>(); int swingType = ModContent.ProjectileType(); int deathEchoType = ModContent.ProjectileType< ReaperDeathEchoScytheProjectile>(); for (int index = 0; index < Main.maxProjectiles; index++) { Projectile projectile = Main.projectile[index]; if (projectile.active && projectile.owner == owner && projectile.type is var type && (type == controllerType || type == riftType || type == swingType || type == deathEchoType)) { projectile.Kill(); } } } private static void Require(bool condition, string message) { if (!condition) throw new InvalidOperationException(message); } } /// /// CI-friendly entry point for the same empty-server suite. Console input lives /// on a different thread on some hosts, so explicit autorun executes from the /// normal world update and avoids testing Terraria entities off-thread. /// public sealed class ReaperDedicatedServerSelfTestAutoRunner : ModSystem { private bool enabled; private bool finished; public override void OnWorldLoad() { enabled = Main.netMode == NetmodeID.Server && Environment.GetEnvironmentVariable( "SOULHARVEST_SERVER_SELFTEST") == "1" && Environment.GetEnvironmentVariable( "SOULHARVEST_SERVER_SELFTEST_AUTORUN") == "1"; finished = false; } public override void PostUpdateEverything() { if (!enabled || finished) return; bool hasFullyJoinedPlayer = false; foreach (Player player in Main.ActivePlayers) { if (player.GetModPlayer().ServerCharacterStateReady) { hasFullyJoinedPlayer = true; break; } } if (!hasFullyJoinedPlayer) return; RunOnce(); } private void RunOnce() { finished = true; try { ReaperDedicatedServerSelfTest.Run(Mod); Mod.Logger.Info(ReaperDedicatedServerSelfTest.PassResult); } catch (Exception exception) { Mod.Logger.Error("SOULHARVEST_SERVER_SELFTEST: FAIL", exception); } } } internal static class ReaperMultiplayerCombatTelemetry { internal static readonly HashSet DeathUltimateCutPhases = []; internal static int RightClickHits { get; private set; } internal static int RightClickDamage { get; private set; } internal static int DeathUltimateCutDamage { get; private set; } internal static int DeathUltimateShatterHits { get; private set; } internal static int DeathUltimateShatterDamage { get; private set; } internal static void Reset() { DeathUltimateCutPhases.Clear(); RightClickHits = 0; RightClickDamage = 0; DeathUltimateCutDamage = 0; DeathUltimateShatterHits = 0; DeathUltimateShatterDamage = 0; } internal static void Record(Projectile projectile, int damageDone) { if (projectile.ModProjectile is not ReaperStrikeProjectile and not ReaperDeathShatterProjectile) { return; } MyGlobalProjectile data = projectile.GetGlobalProjectile< MyGlobalProjectile>(); if (!data.HasReaperSnapshot) return; if (projectile.ModProjectile is ReaperStrikeProjectile && ReaperDefinitions.IsBranchForm(data.ReaperSnapshot.Form) && data.ReaperHitKind == ReaperHitKind.Primary) { RightClickHits++; RightClickDamage += Math.Max(0, damageDone); ModContent.GetInstance().Logger.Info( $"SOULHARVEST_SERVER_LIVE_SELFTEST: damage-event kind=right form={data.ReaperSnapshot.Form} phase={data.ReaperPhase} damage={damageDone}"); } else if (data.ReaperSnapshot.Form == ReaperFormId.Death && data.ReaperHitKind == ReaperHitKind.Ultimate && data.ReaperPhase < ReaperDeathUltimateGeometry.CutCount) { DeathUltimateCutPhases.Add(data.ReaperPhase); DeathUltimateCutDamage += Math.Max(0, damageDone); ModContent.GetInstance().Logger.Info( $"SOULHARVEST_SERVER_LIVE_SELFTEST: damage-event kind=ultimate-cut phase={data.ReaperPhase} damage={damageDone}"); } else if (projectile.ModProjectile is ReaperDeathShatterProjectile && data.ReaperSnapshot.Form == ReaperFormId.Death && data.ReaperHitKind == ReaperHitKind.UltimateDerived) { DeathUltimateShatterHits++; DeathUltimateShatterDamage += Math.Max(0, damageDone); ModContent.GetInstance().Logger.Info( $"SOULHARVEST_SERVER_LIVE_SELFTEST: damage-event kind=ultimate-shatter damage={damageDone}"); } } } /// /// A real hostile, damageable target used only by the opt-in Debug multiplayer /// test. Unlike Target Dummy it participates in Terraria's normal projectile /// damage pipeline, so a visual-only slash cannot satisfy the test. /// public sealed class ReaperMultiplayerDamageTarget : ModNPC { public override string Texture => "Terraria/Images/NPC_1"; public override void SetDefaults() { NPC.width = 70; NPC.height = 100; NPC.damage = 0; // Keep the target alive through every cut plus the queued Domain harvests // while still taking at least Terraria's minimum one point per hit. NPC.defense = 100_000_000; // Domain harvest damage intentionally escalates into the millions and // is applied through a precomputed HitInfo, so defense alone cannot keep // a normal test NPC alive for all eighteen cuts and the final shatter. NPC.lifeMax = 2_000_000_000; NPC.knockBackResist = 0f; NPC.aiStyle = -1; NPC.noGravity = true; NPC.noTileCollide = true; NPC.friendly = false; NPC.dontTakeDamage = false; NPC.immortal = false; NPC.chaseable = true; NPC.value = 0f; NPC.timeLeft = 1_000_000; } public override bool CheckActive() => false; public override bool CanHitPlayer(Player target, ref int cooldownSlot) => false; public override bool? DrawHealthBar(byte hbPosition, ref float scale, ref Vector2 position) { scale = 1.35f; return true; } public override void AI() { int owner = (int)NPC.ai[0] - 1; if (owner < 0 || owner >= Main.maxPlayers || !Main.player[owner].active) { NPC.active = false; return; } NPC.Center = Main.player[owner].MountedCenter + new Vector2(240f, -32f); NPC.velocity = Vector2.Zero; NPC.life = Math.Max(1, NPC.life); NPC.timeLeft = 1_000_000; } public override void OnHitByProjectile(Projectile projectile, NPC.HitInfo hit, int damageDone) { ReaperMultiplayerCombatTelemetry.Record(projectile, damageDone); } } /// /// Opt-in clean manual multiplayer arena. It keeps exactly one ordinary, /// health-bearing hostile NPC beside the first connected player and removes /// ambient NPCs so Target Dummy behavior cannot contaminate manual acceptance. /// public sealed class ReaperManualMultiplayerTargetSystem : ModSystem { private bool enabled; private int spawnDelay; public override void OnWorldLoad() { enabled = Environment.GetEnvironmentVariable( "SOULHARVEST_MANUAL_MULTIPLAYER_TARGET") == "1"; spawnDelay = 0; if (enabled) { Mod.Logger.Info( "SOULHARVEST_MANUAL_MULTIPLAYER_TARGET: enabled"); } } public override void OnWorldUnload() { enabled = false; spawnDelay = 0; } public override void PostUpdateEverything() { if (!enabled || Main.netMode != NetmodeID.Server) return; Player? owner = null; foreach (Player player in Main.ActivePlayers) { owner = player; break; } if (owner is null) return; int targetType = ModContent.NPCType(); int existingTarget = -1; foreach (NPC npc in Main.ActiveNPCs) { if (npc.type == targetType && (int)npc.ai[0] - 1 == owner.whoAmI) { existingTarget = npc.whoAmI; continue; } npc.active = false; NetMessage.SendData(MessageID.SyncNPC, -1, -1, null, npc.whoAmI); } if (existingTarget >= 0 || ++spawnDelay < 45) return; spawnDelay = 0; Vector2 center = owner.MountedCenter + new Vector2(240f, -32f); int index = NPC.NewNPC(owner.GetSource_Misc( "SoulHarvest:ManualMultiplayerTarget"), (int)center.X, (int)center.Y, targetType, 0, owner.whoAmI + 1f); if (index < 0 || index >= Main.maxNPCs) return; NPC target = Main.npc[index]; target.defense = 0; target.lifeMax = 5_000_000; target.life = target.lifeMax; target.netUpdate = true; NetMessage.SendData(MessageID.SyncNPC, -1, -1, null, index); Mod.Logger.Info( $"SOULHARVEST_MANUAL_MULTIPLAYER_TARGET: spawned npc={index} owner={owner.whoAmI} life={target.lifeMax}"); } } /// /// Opt-in real-client handshake and right-click round-trip. The test uses a /// disposable player copy, equips a Reaper through the normal inventory packet, /// sends the same action packet as Mouse2, and waits for the server-owned /// controller to replicate back before closing the client. /// public sealed class ReaperMultiplayerClientSelfTest : ModSystem { internal static bool SimulatedSpecialHeld { get; private set; } internal static bool SimulatedPrimaryHeld { get; private set; } private int phase; private int timer; private bool enabled; private string? result; private ReaperFormId testForm; private readonly ReaperFormId[] liveEnergyForms = [ ReaperFormId.Bone, ReaperFormId.Blood, ReaperFormId.Infernal, ReaperFormId.Frost, ReaperFormId.Soul, ReaperFormId.Void ]; private int liveEnergyFormIndex; private float energyBeforePrimary; private bool sawRightClickController; private bool sawRightClickEcho; private bool sawRightClickStrike; private bool sawRightClickDirect; private bool sawDomainTelegraph; private bool sawDomainSlash; private bool sawHeldPrimaryAssemblySkip; private bool sawHeldPrimaryDomainSlash; private readonly HashSet heldPrimaryOldSlashIdentities = []; private bool sawCurrentFormProjectile; private int currentFormPrimaryAttempts; private int liveProjectileFormsPassed; private bool sawMaxTempoRift; private bool sawWingTrailInitialTiming; private bool sawWingTrailAdvancedTiming; private bool sawDeathUltimateShatter; private bool deathUltimateCenterAligned; private Vector2 deathUltimateCutCenter; private bool hasDeathUltimateCutCenter; private readonly HashSet deathUltimateVisualPhases = []; private readonly List assemblySwitchTargets = []; private int assemblySwitchIndex; private int assemblySwitchesPassed; private ReaperFormId assemblyRequestedForm; private ReaperFormId assemblyPreviousForm; private bool sawAssemblyController; private int observedAssemblyDuration; public override void OnWorldLoad() { enabled = Environment.GetEnvironmentVariable( "SOULHARVEST_CLIENT_SELFTEST") == "1"; phase = 0; timer = 0; result = null; testForm = ReaperFormId.Base; liveEnergyFormIndex = 0; energyBeforePrimary = 0f; sawRightClickController = false; sawRightClickEcho = false; sawRightClickStrike = false; sawRightClickDirect = false; sawDomainTelegraph = false; sawDomainSlash = false; sawHeldPrimaryAssemblySkip = false; sawHeldPrimaryDomainSlash = false; heldPrimaryOldSlashIdentities.Clear(); sawCurrentFormProjectile = false; currentFormPrimaryAttempts = 0; liveProjectileFormsPassed = 0; sawMaxTempoRift = false; sawWingTrailInitialTiming = false; sawWingTrailAdvancedTiming = false; sawDeathUltimateShatter = false; deathUltimateCenterAligned = false; deathUltimateCutCenter = Vector2.Zero; hasDeathUltimateCutCenter = false; deathUltimateVisualPhases.Clear(); assemblySwitchTargets.Clear(); assemblySwitchIndex = 0; assemblySwitchesPassed = 0; assemblyRequestedForm = ReaperFormId.Base; assemblyPreviousForm = ReaperFormId.Base; sawAssemblyController = false; observedAssemblyDuration = 0; DeathSickleAssemblyTextureSystem.ResetDebugMetrics(); ReaperActionControllerProjectile.ResetDebugMetrics(); SimulatedSpecialHeld = false; SimulatedPrimaryHeld = false; } public override void PostUpdateEverything() { if (!enabled || Main.netMode != NetmodeID.MultiplayerClient || Main.myPlayer < 0 || Main.myPlayer >= Main.maxPlayers) { return; } Player player = Main.LocalPlayer; if (!player.active) return; timer++; if (phase == 0) { if (!player.GetModPlayer() .ClientCharacterStateAcknowledged) { if (timer >= 600) Finish("FAIL handshake-timeout"); return; } player.selectedItem = 0; player.inventory[0].SetDefaults( ModContent.ItemType()); NetMessage.SendData(MessageID.SyncEquipment, -1, -1, null, player.whoAmI, PlayerItemSlotID.Inventory0, player.inventory[0].prefix); ReaperProgressionState progression = player.GetModPlayer() .ReaperProgression; if (progression.DeathFormUnlocked && progression.GetCommonNodeLevel( ReaperCommonNode.AssemblySpeed) < ReaperDefinitions.GetCommonNodeMaxLevel( ReaperCommonNode.AssemblySpeed)) { // The live server raises this disposable character to the cap and // syncs it back. Do not start a 0-level form switch in the frame // before that authoritative progression packet arrives. if (timer >= 600) Finish("FAIL max-assembly-progression-sync-timeout"); return; } testForm = progression.DeathFormUnlocked ? ReaperFormId.Death : progression.GetStage(ReaperFormId.Blood) > ReaperStage.Locked ? ReaperFormId.Blood : progression.GetStage(ReaperFormId.Frost) > ReaperStage.Locked ? ReaperFormId.Frost : progression.GetStage(ReaperFormId.Soul) > ReaperStage.Locked ? ReaperFormId.Soul : ReaperFormId.Base; if (progression.DeathFormUnlocked) { ReaperFormId originalForm = progression.CurrentForm; foreach (ReaperFormId form in Enum.GetValues()) { if (form != originalForm) assemblySwitchTargets.Add(form); } assemblySwitchTargets.Add(originalForm); assemblySwitchIndex = 0; DeathSickleAssemblyTextureSystem.ResetDebugMetrics(); BeginAssemblySwitch(player); phase = 12; } else { SoulHarvest.RequestReaperFormSwitch(testForm); phase = 1; timer = 0; } return; } if (phase == 12) { foreach (Projectile projectile in Main.ActiveProjectiles) { if (projectile.owner == player.whoAmI && projectile.ModProjectile is ReaperFormAssemblyProjectile assembly && assembly.TargetForm == assemblyRequestedForm) { sawAssemblyController = true; observedAssemblyDuration = assembly.Duration; } } ReaperFormId current = player.GetModPlayer() .ReaperProgression.CurrentForm; if (current != assemblyPreviousForm && current != assemblyRequestedForm) { Finish($"FAIL live-form-assembly-invalid-intermediate requested={assemblyRequestedForm} current={current}"); return; } if (current == assemblyRequestedForm) { int expectedDuration = ReaperDefinitions.GetAssemblyFrames( player.GetModPlayer().ReaperProgression .GetCommonNodeLevel(ReaperCommonNode.AssemblySpeed)); if (!sawAssemblyController || observedAssemblyDuration != expectedDuration) { Finish($"FAIL live-form-assembly-sync form={assemblyRequestedForm} controller={sawAssemblyController} duration={observedAssemblyDuration}/{expectedDuration}"); return; } assemblySwitchesPassed++; assemblySwitchIndex++; if (assemblySwitchIndex < assemblySwitchTargets.Count) { BeginAssemblySwitch(player); return; } if (DeathSickleAssemblyTextureSystem.CachedSourceCount < assemblySwitchTargets.Count) { Finish($"FAIL live-form-assembly-textures cached={DeathSickleAssemblyTextureSystem.CachedSourceCount}/{assemblySwitchTargets.Count}"); return; } if (DeathSickleAssemblyTextureSystem.LastDrawFragmentCount <= 0 || DeathSickleAssemblyTextureSystem.LastDrawPortalCount != DeathSickleAssemblyTextureSystem.LastDrawFragmentCount) { Finish($"FAIL live-form-assembly-portals portals={DeathSickleAssemblyTextureSystem.LastDrawPortalCount} fragments={DeathSickleAssemblyTextureSystem.LastDrawFragmentCount}"); return; } if (!DeathSickleAssemblyTextureSystem .SawSimultaneousPortalBatch || DeathSickleAssemblyTextureSystem .SawStaggeredPortalBatch) { Finish($"FAIL live-max-form-assembly-portal-timing simultaneous={DeathSickleAssemblyTextureSystem.SawSimultaneousPortalBatch} staggered={DeathSickleAssemblyTextureSystem.SawStaggeredPortalBatch} visible={DeathSickleAssemblyTextureSystem.LastVisiblePortalCount}/{DeathSickleAssemblyTextureSystem.LastDrawPortalCount}"); return; } if (DeathSickleAssemblyTextureSystem.MaxMovingFragmentCount != DeathSickleAssemblyTextureSystem .LastDrawFragmentCount || DeathSickleAssemblyTextureSystem.MaxVisiblePortalCount != DeathSickleAssemblyTextureSystem.LastDrawPortalCount || !DeathSickleAssemblyTextureSystem.SawFinalSeamFlash || !DeathSickleAssemblyTextureSystem.SawPartialPortalAperture) { Finish($"FAIL live-max-form-assembly-not-simultaneous moving={DeathSickleAssemblyTextureSystem.MaxMovingFragmentCount}/{DeathSickleAssemblyTextureSystem.LastDrawFragmentCount} visible-portals={DeathSickleAssemblyTextureSystem.MaxVisiblePortalCount}"); return; } Mod.Logger.Info( $"SOULHARVEST_CLIENT_SELFTEST: max-form-assembly-pass switches={assemblySwitchesPassed}/{assemblySwitchTargets.Count} textures={DeathSickleAssemblyTextureSystem.CachedSourceCount}/{assemblySwitchTargets.Count} portals={DeathSickleAssemblyTextureSystem.LastDrawPortalCount}/{DeathSickleAssemblyTextureSystem.LastDrawFragmentCount} max-moving={DeathSickleAssemblyTextureSystem.MaxMovingFragmentCount} max-visible-portals={DeathSickleAssemblyTextureSystem.MaxVisiblePortalCount} simultaneous={DeathSickleAssemblyTextureSystem.SawSimultaneousPortalBatch} expanded={DeathSickleAssemblyTextureSystem.SawPartialPortalAperture} seam-flash={DeathSickleAssemblyTextureSystem.SawFinalSeamFlash} duration={expectedDuration}"); if (current != testForm) SoulHarvest.RequestReaperFormSwitch(testForm); phase = 1; timer = 0; return; } if (timer >= ReaperDefinitions.GetAssemblyFrames(0) + 120) { string activeState = "none"; if (ReaperFormAssemblyProjectile.TryGetActive(player, out ReaperFormAssemblyProjectile? activeAssembly) && activeAssembly is not null) { activeState = $"{activeAssembly.TargetForm}:{activeAssembly.Elapsed}/{activeAssembly.Duration}"; } Finish($"FAIL live-form-assembly-timeout form={assemblyRequestedForm} controller={sawAssemblyController} observedDuration={observedAssemblyDuration} active={activeState} current={current}"); } return; } if (phase == 1) { if (player.GetModPlayer().ReaperProgression.CurrentForm != testForm || timer < 60) { if (timer >= ReaperDefinitions.GetAssemblyFrames(0) + 120) Finish("FAIL test-form-or-inventory-sync-timeout"); return; } SimulatedSpecialHeld = true; ReaperActionControllerProjectile.ResetDebugMetrics(); SoulHarvest.SendReaperAction(player, ultimate: false, new Vector2(240f, -32f)); Mod.Logger.Info( $"SOULHARVEST_CLIENT_SELFTEST: sent live right-click packet form={testForm}"); phase = 2; timer = 0; return; } if (phase == 2) { int controllerType = ModContent.ProjectileType< ReaperActionControllerProjectile>(); int echoType = ModContent.ProjectileType< ReaperDeathEchoScytheProjectile>(); int strikeType = ModContent.ProjectileType< ReaperStrikeProjectile>(); foreach (Projectile projectile in Main.ActiveProjectiles) { if (projectile.owner != player.whoAmI) continue; if (projectile.type == controllerType) { sawRightClickController = true; if (projectile.ModProjectile is ReaperActionControllerProjectile controller) { sawRightClickDirect |= controller.DeathAssemblyComplete && controller.DeathAssemblyElapsed == 0 && !controller.DeathAssemblyCancelling; } } sawRightClickEcho |= projectile.type == echoType; if (projectile.type == strikeType) { MyGlobalProjectile data = projectile.GetGlobalProjectile< MyGlobalProjectile>(); sawRightClickStrike |= data.HasReaperSnapshot && ReaperDefinitions.IsBranchForm( data.ReaperSnapshot.Form) && data.ReaperHitKind == ReaperHitKind.Primary; } } if (sawRightClickController && sawRightClickEcho && sawRightClickStrike && sawRightClickDirect && ReaperActionControllerProjectile .SawDeathRightHeldWeaponSuppressed) { Mod.Logger.Info( $"SOULHARVEST_CLIENT_SELFTEST: direct-right-click-pass timer={timer} assembly=0 held-weapon-hidden=1"); SimulatedSpecialHeld = false; SoulHarvest.SendReaperSpecialRelease(player); phase = 3; timer = 0; return; } if (timer >= 180) Finish($"FAIL live-direct-right-click-timeout controller={sawRightClickController} echo={sawRightClickEcho} strike={sawRightClickStrike} direct={sawRightClickDirect} heldWeaponHidden={ReaperActionControllerProjectile.SawDeathRightHeldWeaponSuppressed}"); return; } if (phase == 3) { int controllerType = ModContent.ProjectileType< ReaperActionControllerProjectile>(); bool controllerActive = false; foreach (Projectile projectile in Main.ActiveProjectiles) { if (projectile.owner == player.whoAmI && projectile.type == controllerType) { controllerActive = true; break; } } if (controllerActive || timer < 30) { if (timer >= ReaperDefinitions.GetAssemblyFrames(0) + 120) Finish("FAIL live-right-click-release-timeout"); return; } int dummyIndex = FindTargetDummy(); if (dummyIndex < 0) { if (timer >= 360) Finish("FAIL live-training-dummy-timeout"); return; } NPC dummy = Main.npc[dummyIndex]; SimulatedPrimaryHeld = true; Vector2 aim = (dummy.Center - player.MountedCenter) .SafeNormalize(Vector2.UnitX * player.direction); SickleCombatSnapshot snapshot = SickleCombatSnapshot.Capture( player, Math.Max(1, player.GetWeaponDamage(player.HeldItem)), player.GetWeaponKnockback(player.HeldItem, player.HeldItem.knockBack)); int attackPhase = ReaperCombatService.TakeNextPrimaryPhase( player, snapshot, out float deathTempo); int swingDirection = player.GetModPlayer() .TakeNextSwingDirection(player.direction); int swingIndex = Projectile.NewProjectile( player.GetSource_Misc("SoulHarvest:LiveClientPrimarySelfTest"), player.MountedCenter, aim, ModContent.ProjectileType(), snapshot.Damage, snapshot.Knockback, player.whoAmI, swingDirection, 0f, player.HeldItem.type); if (swingIndex < 0 || swingIndex >= Main.maxProjectiles || Main.projectile[swingIndex].ModProjectile is not SickleSwingProjectile swing) { Finish("FAIL live-primary-spawn"); return; } swing.Configure(snapshot, attackPhase, deathTempo, requireDeathAssembly: snapshot.Form == ReaperFormId.Death && ReaperCombatService.RequiresDeathPrimaryAssembly( player)); energyBeforePrimary = player.GetModPlayer() .GetReaperEnergy(ReaperFormId.Death); Mod.Logger.Info( $"SOULHARVEST_CLIENT_SELFTEST: sent live primary projectile identity={Main.projectile[swingIndex].identity} dummy={dummyIndex}"); phase = 4; timer = 0; return; } if (phase == 4) { int telegraphType = ModContent.ProjectileType< DeathDomainHarvestTelegraphProjectile>(); int slashType = ModContent.ProjectileType< DeathDomainHarvestSlashProjectile>(); foreach (Projectile projectile in Main.ActiveProjectiles) { if (projectile.owner != player.whoAmI) continue; sawDomainTelegraph |= projectile.type == telegraphType; sawDomainSlash |= projectile.type == slashType; } float energy = player.GetModPlayer() .GetReaperEnergy(ReaperFormId.Death); if (energy > energyBeforePrimary && sawDomainSlash) { heldPrimaryOldSlashIdentities.Clear(); foreach (Projectile projectile in Main.ActiveProjectiles) { if (projectile.owner == player.whoAmI && projectile.type == slashType) { heldPrimaryOldSlashIdentities.Add( projectile.identity); } } int dummyIndex = FindTargetDummy(); if (dummyIndex < 0 || !TrySpawnPrimary(player, Main.npc[dummyIndex])) { Finish("FAIL live-held-primary-followup-spawn"); return; } foreach (Projectile projectile in Main.ActiveProjectiles) { if (projectile.owner == player.whoAmI && projectile.ModProjectile is SickleSwingProjectile heldSwing && !heldSwing.DeathAssemblyRequired) { sawHeldPrimaryAssemblySkip = true; } } phase = 15; timer = 0; return; } if (timer >= 600) { Finish($"FAIL live-primary-result-timeout energy={energy:0.0}/{energyBeforePrimary:0.0} telegraph={sawDomainTelegraph} slash={sawDomainSlash}"); } return; } if (phase == 15) { int slashType = ModContent.ProjectileType< DeathDomainHarvestSlashProjectile>(); bool swingActive = false; foreach (Projectile projectile in Main.ActiveProjectiles) { if (projectile.owner != player.whoAmI) continue; if (projectile.ModProjectile is SickleSwingProjectile heldSwing) { swingActive = true; sawHeldPrimaryAssemblySkip |= !heldSwing .DeathAssemblyRequired; } if (projectile.type == slashType && !heldPrimaryOldSlashIdentities.Contains( projectile.identity)) { sawHeldPrimaryDomainSlash = true; } } if (timer >= 190 && sawHeldPrimaryAssemblySkip && sawHeldPrimaryDomainSlash) { SimulatedPrimaryHeld = false; ReaperCombatService.ReleaseDeathPrimaryHold(player); SoulHarvest.SendReaperPrimaryRelease(player); Mod.Logger.Info( "SOULHARVEST_CLIENT_SELFTEST: held-primary-pass assembly-skipped=1 immediate-domain-slash=1"); liveEnergyFormIndex = 0; SoulHarvest.RequestReaperFormSwitch( liveEnergyForms[liveEnergyFormIndex]); phase = 6; timer = 0; return; } if (timer >= 240) { Finish($"FAIL live-held-primary-timeout skipped={sawHeldPrimaryAssemblySkip} slash={sawHeldPrimaryDomainSlash} active={swingActive}"); } return; } if (phase == 6) { ReaperFormId energyForm = liveEnergyForms[liveEnergyFormIndex]; MyPlayer modPlayer = player.GetModPlayer(); if (modPlayer.ReaperProgression.CurrentForm != energyForm || timer < 30) { if (timer >= ReaperDefinitions.GetAssemblyFrames(0) + 120) { string assemblyState = "none"; if (ReaperFormAssemblyProjectile.TryGetActive(player, out ReaperFormAssemblyProjectile? assembly) && assembly is not null) { assemblyState = $"{assembly.TargetForm}:{assembly.Elapsed}/{assembly.Duration}"; } Finish($"FAIL live-energy-form-switch requested={energyForm} current={modPlayer.ReaperProgression.CurrentForm} assembly={assemblyState} item={player.itemTime}/{player.itemAnimation} swing={player.ownedProjectileCounts[ModContent.ProjectileType()]} controller={player.ownedProjectileCounts[ModContent.ProjectileType()]}"); } return; } int dummyIndex = FindTargetDummy(); if (dummyIndex < 0) { if (timer >= 360) Finish($"FAIL live-energy-dummy-timeout form={energyForm}"); return; } energyBeforePrimary = modPlayer.GetReaperEnergy(energyForm); sawCurrentFormProjectile = false; currentFormPrimaryAttempts = 1; if (!TrySpawnPrimary(player, Main.npc[dummyIndex])) { Finish($"FAIL live-energy-primary-spawn form={energyForm}"); return; } phase = 7; timer = 0; return; } if (phase == 7) { ReaperFormId energyForm = liveEnergyForms[liveEnergyFormIndex]; sawCurrentFormProjectile |= HasReplicatedFormProjectile( player.whoAmI, energyForm); float energy = player.GetModPlayer() .GetReaperEnergy(energyForm); if (energy > energyBeforePrimary && sawCurrentFormProjectile) { liveProjectileFormsPassed++; liveEnergyFormIndex++; if (liveEnergyFormIndex >= liveEnergyForms.Length) { SoulHarvest.RequestReaperFormSwitch(ReaperFormId.Death); phase = 8; timer = 0; return; } SoulHarvest.RequestReaperFormSwitch( liveEnergyForms[liveEnergyFormIndex]); phase = 6; timer = 0; return; } if (!sawCurrentFormProjectile && timer >= 45 && timer % 45 == 0 && currentFormPrimaryAttempts < 8) { int dummyIndex = FindTargetDummy(); if (dummyIndex >= 0 && TrySpawnPrimary(player, Main.npc[dummyIndex])) { currentFormPrimaryAttempts++; } } if (timer >= 720) { Finish($"FAIL live-form-projectile-timeout form={energyForm} energy={energy:0.0}/{energyBeforePrimary:0.0} projectile={sawCurrentFormProjectile} attempts={currentFormPrimaryAttempts}"); } return; } if (phase == 8) { MyPlayer modPlayer = player.GetModPlayer(); int damageTargetIndex = FindDamageTarget(); if (modPlayer.ReaperProgression.CurrentForm != ReaperFormId.Death || modPlayer.GetReaperEnergy(ReaperFormId.Death) < 100f || damageTargetIndex < 0 || timer < 30) { if (timer >= 600) { Finish($"FAIL live-death-setup form={modPlayer.ReaperProgression.CurrentForm} energy={modPlayer.GetReaperEnergy(ReaperFormId.Death):0.0} target={damageTargetIndex}"); } return; } ReaperCombatSystem.DeathPrimaryChain[player.whoAmI] = (byte)( ReaperCombatService.DeathPrimaryMaximumChain - 1); ReaperCombatSystem.LastDeathPrimaryTick[player.whoAmI] = Main.GameUpdateCount; SimulatedPrimaryHeld = true; if (!TrySpawnPrimary(player, Main.npc[damageTargetIndex])) { Finish("FAIL live-max-tempo-primary-spawn"); return; } phase = 9; timer = 0; return; } if (phase == 9) { ObservePersistentDeathTrails(player.whoAmI); if (sawMaxTempoRift && sawWingTrailAdvancedTiming) { SimulatedPrimaryHeld = false; ReaperCombatService.ReleaseDeathPrimaryHold(player); SoulHarvest.SendReaperPrimaryRelease(player); phase = 10; timer = 0; return; } if (timer >= ReaperDefinitions.GetAssemblyFrames(0) + 600) { Finish($"FAIL live-persistent-trail-timeout rift={sawMaxTempoRift} wingInitial={sawWingTrailInitialTiming} wingAdvanced={sawWingTrailAdvancedTiming}"); } return; } if (phase == 10) { if (timer < 30 || HasOwnedCombatAction(player.whoAmI)) { if (timer >= 300) Finish("FAIL live-ultimate-action-busy-timeout"); return; } int damageTargetIndex = FindDamageTarget(); if (damageTargetIndex < 0) { Finish("FAIL live-ultimate-target-missing"); return; } SoulHarvest.SendReaperAction(player, ultimate: true, Main.npc[damageTargetIndex].Center - player.Center); phase = 11; timer = 0; return; } if (phase == 11) { int shatterType = ModContent.ProjectileType< ReaperDeathShatterProjectile>(); foreach (Projectile projectile in Main.ActiveProjectiles) { if (projectile.owner != player.whoAmI) continue; if (projectile.type == shatterType && projectile.ModProjectile is ReaperDeathShatterProjectile shatter) { sawDeathUltimateShatter = true; if (shatter.DrawsCenterVisual && hasDeathUltimateCutCenter && Vector2.DistanceSquared(shatter.VisualCenter, deathUltimateCutCenter) <= 4f) { deathUltimateCenterAligned = true; } } if (projectile.ModProjectile is not ReaperStrikeProjectile) continue; MyGlobalProjectile data = projectile.GetGlobalProjectile< MyGlobalProjectile>(); if (data.HasReaperSnapshot && data.ReaperSnapshot.Form == ReaperFormId.Death && data.ReaperHitKind == ReaperHitKind.Ultimate && data.ReaperPhase < ReaperDeathUltimateGeometry.CutCount) { deathUltimateVisualPhases.Add(data.ReaperPhase); Vector2 center = projectile.Center + projectile.velocity.SafeNormalize(Vector2.UnitX) * (ReaperDeathUltimateGeometry.CutLength * 0.5f); if (!hasDeathUltimateCutCenter) { deathUltimateCutCenter = center; hasDeathUltimateCutCenter = true; } } } if (deathUltimateVisualPhases.Count == ReaperDeathUltimateGeometry.CutCount && sawDeathUltimateShatter && deathUltimateCenterAligned) { Finish($"PASS live-handshake=1/1 live-max-parallel-assembly={assemblySwitchesPassed}/{assemblySwitchTargets.Count} live-form-textures={DeathSickleAssemblyTextureSystem.CachedSourceCount}/{assemblySwitchTargets.Count} live-right-click-direct=1/1 live-right-click-held-weapon-hidden=1/1 live-right-click-controller=1/1 live-right-click-echo=1/1 live-right-click-strike=1/1 live-primary-dummy-hit=1/1 live-held-primary-no-reassembly=1/1 live-energy-forms=7/7 live-form-projectiles={liveProjectileFormsPassed}/6 live-domain-slash=1/1 live-domain-telegraph-observed={sawDomainTelegraph} live-max-tempo-rift=65/full live-wing-timing=300/300 live-death-ultimate-cuts={deathUltimateVisualPhases.Count}/{ReaperDeathUltimateGeometry.CutCount} live-death-ultimate-shatter=1/1 live-death-shatter-center=1/1 form=Death"); return; } if (timer >= 900) { Finish($"FAIL live-death-ultimate-timeout cuts={deathUltimateVisualPhases.Count}/{ReaperDeathUltimateGeometry.CutCount} shatter={sawDeathUltimateShatter} centerAligned={deathUltimateCenterAligned}"); } return; } if (phase == 5 && timer >= 30) Main.instance.Exit(); } private static bool HasReplicatedFormProjectile(int owner, ReaperFormId form) { foreach (Projectile projectile in Main.ActiveProjectiles) { if (form == ReaperFormId.Void && projectile.ModProjectile is ReaperVoidHitCueProjectile) { return true; } if (projectile.owner != owner || projectile.ModProjectile is SickleSwingProjectile) { continue; } MyGlobalProjectile data = projectile.GetGlobalProjectile< MyGlobalProjectile>(); if (data.HasReaperSnapshot && data.ReaperSnapshot.Form == form) { return true; } } return false; } private void BeginAssemblySwitch(Player player) { assemblyRequestedForm = assemblySwitchTargets[assemblySwitchIndex]; assemblyPreviousForm = player.GetModPlayer() .ReaperProgression.CurrentForm; sawAssemblyController = false; observedAssemblyDuration = 0; timer = 0; SoulHarvest.RequestReaperFormSwitch(assemblyRequestedForm); } private void ObservePersistentDeathTrails(int owner) { Player player = Main.player[owner]; SickleCombatSnapshot snapshot = SickleCombatSnapshot.Capture(player, Math.Max(1, player.GetWeaponDamage(player.HeldItem)), player.GetWeaponKnockback(player.HeldItem, player.HeldItem.knockBack)); float expectedPathLength = ReaperCombatRegistry .GetDeathPrimaryBladeReach(snapshot) * ReaperCombatRegistry.DeathPrimaryHalfSweep * 2f; foreach (Projectile projectile in Main.ActiveProjectiles) { if (projectile.owner != owner) continue; if (projectile.ModProjectile is ReaperDeathDomainRiftProjectile rift) { bool lifetimeMatches = Math.Abs(projectile.timeLeft + rift.DebugAge - rift.DebugLifetime) <= 3; bool pathMatches = rift.DebugPointCount == 65 && Math.Abs(rift.DebugPathLength - expectedPathLength) <= expectedPathLength * 0.02f; sawMaxTempoRift |= lifetimeMatches && pathMatches; } else if (projectile.ModProjectile is DeathWingDomainTrailProjectile wing) { bool timingMatches = Math.Abs(projectile.timeLeft + wing.DebugAge - wing.DebugLifetime) <= 3; sawWingTrailInitialTiming |= timingMatches && wing.DebugPointCount >= 2; sawWingTrailAdvancedTiming |= timingMatches && wing.DebugPointCount >= 2 && wing.DebugAge >= 30; } } } private static bool HasOwnedCombatAction(int owner) { foreach (Projectile projectile in Main.ActiveProjectiles) { if (projectile.owner == owner && projectile.ModProjectile is SickleSwingProjectile or ReaperActionControllerProjectile) { return true; } } return false; } private void Finish(string value) { if (result is not null) return; SimulatedSpecialHeld = false; SimulatedPrimaryHeld = false; result = value; phase = 5; timer = 0; string message = $"SOULHARVEST_CLIENT_SELFTEST: {value}"; if (value.StartsWith("PASS", StringComparison.Ordinal)) Mod.Logger.Info(message); else Mod.Logger.Error(message); } private static int FindTargetDummy() { foreach (NPC npc in Main.ActiveNPCs) { if (npc.type == NPCID.TargetDummy) return npc.whoAmI; } return -1; } private static int FindDamageTarget() { int targetType = ModContent.NPCType(); foreach (NPC npc in Main.ActiveNPCs) { if (npc.type == targetType) return npc.whoAmI; } return -1; } private static bool TrySpawnPrimary(Player player, NPC dummy) { Vector2 aim = (dummy.Center - player.MountedCenter) .SafeNormalize(Vector2.UnitX * player.direction); SickleCombatSnapshot snapshot = SickleCombatSnapshot.Capture( player, Math.Max(1, player.GetWeaponDamage(player.HeldItem)), player.GetWeaponKnockback(player.HeldItem, player.HeldItem.knockBack)); int attackPhase = ReaperCombatService.TakeNextPrimaryPhase( player, snapshot, out float deathTempo); int swingDirection = player.GetModPlayer() .TakeNextSwingDirection(player.direction); int swingIndex = Projectile.NewProjectile( player.GetSource_Misc("SoulHarvest:LiveClientEnergySelfTest"), player.MountedCenter, aim, ModContent.ProjectileType(), snapshot.Damage, snapshot.Knockback, player.whoAmI, swingDirection, 0f, player.HeldItem.type); if (swingIndex < 0 || swingIndex >= Main.maxProjectiles || Main.projectile[swingIndex].ModProjectile is not SickleSwingProjectile swing) { return false; } swing.Configure(snapshot, attackPhase, deathTempo, requireDeathAssembly: snapshot.Form == ReaperFormId.Death && ReaperCombatService.RequiresDeathPrimaryAssembly(player)); return true; } } /// /// Server half of the live combat round-trip. It creates a real Target Dummy in /// melee range and observes the normal projectile collision and delayed Domain /// strike rather than calling either hit hook directly. /// public sealed class ReaperMultiplayerServerCombatSelfTest : ModSystem { private bool enabled; private int dummyIndex; private int damageTargetIndex; private int owner; private int timer; private bool sawEnergy; private bool sawDomainTelegraphQueue; private bool sawDomainStrikeQueue; private bool reportedPass; private bool grantedDeathUltimate; private bool sawMaxTempoRift; private bool spawnedWingTrail; private bool reportedFullCombatPass; public override void OnWorldLoad() { enabled = Environment.GetEnvironmentVariable( "SOULHARVEST_LIVE_COMBAT_SELFTEST") == "1"; dummyIndex = -1; damageTargetIndex = -1; owner = -1; timer = 0; sawEnergy = false; sawDomainTelegraphQueue = false; sawDomainStrikeQueue = false; reportedPass = false; grantedDeathUltimate = false; sawMaxTempoRift = false; spawnedWingTrail = false; reportedFullCombatPass = false; ReaperMultiplayerCombatTelemetry.Reset(); } public override void PostUpdateEverything() { if (!enabled || Main.netMode != NetmodeID.Server) return; timer++; if (owner < 0) { foreach (Player player in Main.ActivePlayers) { MyPlayer modPlayer = player.GetModPlayer(); if (!modPlayer.ServerCharacterStateReady || player.HeldItem.ModItem is not NormalSickle || ReaperCombatRegistry.ResolveUsableForm( modPlayer.ReaperProgression) != ReaperFormId.Death) { continue; } owner = player.whoAmI; modPlayer.ReaperProgression.SetCommonNodeLevel( ReaperCommonNode.DeathTempoCap, ReaperDefinitions.GetCommonNodeMaxLevel( ReaperCommonNode.DeathTempoCap)); modPlayer.ReaperProgression.SetCommonNodeLevel( ReaperCommonNode.DeathSpaceBreak, ReaperDefinitions.GetCommonNodeMaxLevel( ReaperCommonNode.DeathSpaceBreak)); modPlayer.ReaperProgression.SetCommonNodeLevel( ReaperCommonNode.AssemblySpeed, ReaperDefinitions.GetCommonNodeMaxLevel( ReaperCommonNode.AssemblySpeed)); modPlayer.SyncReaperProgression(); foreach (ReaperFormId form in new[] { ReaperFormId.Bone, ReaperFormId.Blood, ReaperFormId.Infernal, ReaperFormId.Frost, ReaperFormId.Soul, ReaperFormId.Void, ReaperFormId.Death }) { modPlayer.DebugSetReaperEnergy(form, 0f); } dummyIndex = FindExistingTargetDummy(); if (dummyIndex < 0) dummyIndex = FindInactiveNpc(); if (dummyIndex < 0) throw new InvalidOperationException( "Live combat self-test could not reserve an NPC slot."); NPC dummy = Main.npc[dummyIndex]; dummy.SetDefaults(NPCID.TargetDummy); dummy.whoAmI = dummyIndex; dummy.active = true; dummy.life = Math.Max(1, dummy.lifeMax); dummy.Center = player.MountedCenter + new Vector2(34f, 0f); dummy.netUpdate = true; NetMessage.SendData(MessageID.SyncNPC, -1, -1, null, dummyIndex); damageTargetIndex = NPC.NewNPC( player.GetSource_Misc( "SoulHarvest:LiveDamageTargetSelfTest"), (int)(player.MountedCenter.X + 240f), (int)(player.MountedCenter.Y - 32f), ModContent.NPCType(), 0, owner + 1f); if (damageTargetIndex < 0 || damageTargetIndex >= Main.maxNPCs) { throw new InvalidOperationException( "Live combat self-test could not create its damageable target."); } NetMessage.SendData(MessageID.SyncNPC, -1, -1, null, damageTargetIndex); timer = 0; Mod.Logger.Info( $"SOULHARVEST_SERVER_LIVE_SELFTEST: ready owner={owner} dummy={dummyIndex} damageTarget={damageTargetIndex} held={player.HeldItem.type} form=Death tempoCap={modPlayer.ReaperProgression.GetCommonNodeLevel(ReaperCommonNode.DeathTempoCap)} spaceBreak={modPlayer.ReaperProgression.GetCommonNodeLevel(ReaperCommonNode.DeathSpaceBreak)}"); break; } return; } if (owner >= Main.maxPlayers || !Main.player[owner].active) return; Player testPlayer = Main.player[owner]; MyPlayer testModPlayer = testPlayer.GetModPlayer(); if (dummyIndex >= 0 && dummyIndex < Main.maxNPCs) { NPC dummy = Main.npc[dummyIndex]; if (!dummy.active || dummy.type != NPCID.TargetDummy) dummy.SetDefaults(NPCID.TargetDummy); dummy.whoAmI = dummyIndex; dummy.active = true; dummy.life = Math.Max(1, dummy.lifeMax); dummy.Center = testPlayer.MountedCenter + new Vector2(34f, 0f); dummy.velocity = Vector2.Zero; sawDomainTelegraphQueue |= testModPlayer .HasPendingReaperDomainHarvest(dummyIndex); sawDomainStrikeQueue |= testModPlayer .HasPendingDeathDomainStrike(dummyIndex); } sawEnergy |= testModPlayer.GetReaperEnergy(ReaperFormId.Death) > 0f; if (!reportedPass && sawEnergy && sawDomainTelegraphQueue && sawDomainStrikeQueue) { Mod.Logger.Info( "SOULHARVEST_SERVER_LIVE_SELFTEST: PASS live-primary-authority=1/1 live-dummy-hit=1/1 live-energy=1/1 live-domain=2/2"); reportedPass = true; } else if (!reportedPass && timer >= 7200) { Mod.Logger.Error( $"SOULHARVEST_SERVER_LIVE_SELFTEST: FAIL timeout energy={sawEnergy} telegraph={sawDomainTelegraphQueue} strike={sawDomainStrikeQueue}"); enabled = false; } if (!reportedPass) return; if (!grantedDeathUltimate) { testModPlayer.TryGainReaperEnergy(ReaperFormId.Death, 100f); grantedDeathUltimate = testModPlayer.GetReaperEnergy( ReaperFormId.Death) >= 100f; } ReaperCombatSystem.DeathPrimaryChain[owner] = (byte)( ReaperCombatService.DeathPrimaryMaximumChain - 1); ReaperCombatSystem.LastDeathPrimaryTick[owner] = Main.GameUpdateCount; foreach (Projectile projectile in Main.ActiveProjectiles) { if (projectile.owner == owner && projectile.ModProjectile is ReaperDeathDomainRiftProjectile) { sawMaxTempoRift = true; } } if (timer % 120 == 0) { List wingPath = [ testPlayer.MountedCenter + new Vector2(-130f, 38f), testPlayer.MountedCenter + new Vector2(-72f, -46f), testPlayer.MountedCenter + new Vector2(0f, -78f), testPlayer.MountedCenter + new Vector2(72f, -46f), testPlayer.MountedCenter + new Vector2(130f, 38f) ]; spawnedWingTrail |= DeathWingDomainTrailProjectile.Spawn( testPlayer.GetSource_Misc( "SoulHarvest:LiveWingTrailSelfTest"), owner, wingPath, 34f, 0.9f, unchecked((int)Main.GameUpdateCount)) >= 0; } bool rightClickDamagePassed = ReaperMultiplayerCombatTelemetry.RightClickHits > 0 && ReaperMultiplayerCombatTelemetry.RightClickDamage > 0; bool ultimateCutsPassed = ReaperMultiplayerCombatTelemetry .DeathUltimateCutPhases.Count == ReaperDeathUltimateGeometry.CutCount && ReaperMultiplayerCombatTelemetry.DeathUltimateCutDamage > 0; bool ultimateShatterPassed = ReaperMultiplayerCombatTelemetry .DeathUltimateShatterHits > 0 && ReaperMultiplayerCombatTelemetry.DeathUltimateShatterDamage > 0; if (!reportedFullCombatPass && rightClickDamagePassed && ultimateCutsPassed && ultimateShatterPassed && sawMaxTempoRift && spawnedWingTrail) { Mod.Logger.Info( $"SOULHARVEST_SERVER_LIVE_SELFTEST: PASS right-click-damage={ReaperMultiplayerCombatTelemetry.RightClickHits}/{ReaperMultiplayerCombatTelemetry.RightClickDamage} death-ultimate-cuts={ReaperMultiplayerCombatTelemetry.DeathUltimateCutPhases.Count}/{ReaperDeathUltimateGeometry.CutCount} cut-damage={ReaperMultiplayerCombatTelemetry.DeathUltimateCutDamage} shatter={ReaperMultiplayerCombatTelemetry.DeathUltimateShatterHits}/{ReaperMultiplayerCombatTelemetry.DeathUltimateShatterDamage} max-tempo-rift=1/1 wing-trail=1/1"); reportedFullCombatPass = true; } else if (!reportedFullCombatPass && timer >= 15000) { Mod.Logger.Error( $"SOULHARVEST_SERVER_LIVE_SELFTEST: FAIL full-combat-timeout rightHits={ReaperMultiplayerCombatTelemetry.RightClickHits} rightDamage={ReaperMultiplayerCombatTelemetry.RightClickDamage} cuts={ReaperMultiplayerCombatTelemetry.DeathUltimateCutPhases.Count}/{ReaperDeathUltimateGeometry.CutCount} cutDamage={ReaperMultiplayerCombatTelemetry.DeathUltimateCutDamage} shatterHits={ReaperMultiplayerCombatTelemetry.DeathUltimateShatterHits} shatterDamage={ReaperMultiplayerCombatTelemetry.DeathUltimateShatterDamage} rift={sawMaxTempoRift} wing={spawnedWingTrail}"); enabled = false; } } private static int FindInactiveNpc() { for (int index = 0; index < Main.maxNPCs; index++) { if (!Main.npc[index].active) return index; } return -1; } private static int FindExistingTargetDummy() { foreach (NPC npc in Main.ActiveNPCs) { if (npc.type == NPCID.TargetDummy) return npc.whoAmI; } return -1; } } /// Opt-in single-player storyboard used to inspect the Death assembly. public sealed class ReaperDeathAssemblyVisualSelfTest : ModSystem { private bool enabled; private int timer; private int formSwitchIndex; private bool formSwitchRequested; private bool initialized; private readonly ReaperFormId[] formSwitchTargets = Enum.GetValues(); public override void OnWorldLoad() { enabled = Environment.GetEnvironmentVariable( "SOULHARVEST_ASSEMBLY_VISUAL_TEST") == "1" || Array.Exists(Environment.GetCommandLineArgs(), argument => argument.Equals("-soulharvestassemblyvisualtest", StringComparison.OrdinalIgnoreCase)); timer = 0; formSwitchIndex = 0; formSwitchRequested = false; initialized = false; DeathSickleAssemblyTextureSystem.ResetDebugMetrics(); ReaperActionControllerProjectile.ResetDebugMetrics(); if (enabled) { Mod.Logger.Info( "SOULHARVEST_ASSEMBLY_VISUAL_TEST: enabled"); } } public override void PostUpdateEverything() { if (!enabled || Main.netMode != NetmodeID.SinglePlayer || Main.gameMenu || !Main.LocalPlayer.active) { return; } Player player = Main.LocalPlayer; player.selectedItem = 0; player.inventory[0].SetDefaults(ModContent.ItemType()); MyPlayer modPlayer = player.GetModPlayer(); if (!initialized) { modPlayer.ReaperProgression.UnlockDeathMasteryForMigration(); modPlayer.ReaperProgression.SetCommonNodeLevel( ReaperCommonNode.AssemblySpeed, ReaperDefinitions.GetCommonNodeMaxLevel( ReaperCommonNode.AssemblySpeed)); modPlayer.ReaperProgression.TrySetCurrentForm(ReaperFormId.Death); initialized = true; } if (formSwitchIndex < formSwitchTargets.Length) { ReaperFormId target = formSwitchTargets[formSwitchIndex]; if (!formSwitchRequested) { SoulHarvest.RequestReaperFormSwitch(target); formSwitchRequested = true; Mod.Logger.Info( $"SOULHARVEST_ASSEMBLY_VISUAL_TEST: form-start target={target} duration={ReaperDefinitions.AssemblyMaximumLevelFrames}"); } if (modPlayer.ReaperProgression.CurrentForm == target && !modPlayer.IsReaperFormAssemblyActive()) { formSwitchIndex++; formSwitchRequested = false; Mod.Logger.Info( $"SOULHARVEST_ASSEMBLY_VISUAL_TEST: form-complete target={target} cache={DeathSickleAssemblyTextureSystem.CachedSourceCount}"); if (formSwitchIndex >= formSwitchTargets.Length) { timer = 0; bool simultaneous = DeathSickleAssemblyTextureSystem .SawSimultaneousPortalBatch && !DeathSickleAssemblyTextureSystem .SawStaggeredPortalBatch && DeathSickleAssemblyTextureSystem .MaxMovingFragmentCount == ReaperDefinitions.AssemblyFragmentCount && DeathSickleAssemblyTextureSystem .MaxVisiblePortalCount == ReaperDefinitions.AssemblyFragmentCount && DeathSickleAssemblyTextureSystem.SawFinalSeamFlash && DeathSickleAssemblyTextureSystem .SawPartialPortalAperture; if (simultaneous) { Mod.Logger.Info( $"SOULHARVEST_ASSEMBLY_VISUAL_TEST: PASS max-parallel={ReaperDefinitions.AssemblyFragmentCount}/{ReaperDefinitions.AssemblyFragmentCount} duration={ReaperDefinitions.AssemblyMaximumLevelFrames} switches={formSwitchTargets.Length}/{formSwitchTargets.Length} textures={DeathSickleAssemblyTextureSystem.CachedSourceCount}/{formSwitchTargets.Length}"); } else { Mod.Logger.Error( $"SOULHARVEST_ASSEMBLY_VISUAL_TEST: FAIL simultaneous={DeathSickleAssemblyTextureSystem.SawSimultaneousPortalBatch} staggered={DeathSickleAssemblyTextureSystem.SawStaggeredPortalBatch} moving={DeathSickleAssemblyTextureSystem.MaxMovingFragmentCount}/{ReaperDefinitions.AssemblyFragmentCount} portals={DeathSickleAssemblyTextureSystem.MaxVisiblePortalCount}/{ReaperDefinitions.AssemblyFragmentCount}"); } } } return; } timer++; if (timer == 30) { SickleCombatSnapshot snapshot = SickleCombatSnapshot.Capture(player, Math.Max(1, player.GetWeaponDamage(player.HeldItem)), player.GetWeaponKnockback(player.HeldItem, player.HeldItem.knockBack)); int index = Projectile.NewProjectile( player.GetSource_Misc("SoulHarvest:AssemblyVisualLeft"), player.MountedCenter, Vector2.UnitX, ModContent.ProjectileType(), snapshot.Damage, snapshot.Knockback, player.whoAmI, 1f, 0f, player.HeldItem.type); if (index >= 0 && index < Main.maxProjectiles && Main.projectile[index].ModProjectile is SickleSwingProjectile swing) { swing.Configure(snapshot, 0); Mod.Logger.Info( "SOULHARVEST_ASSEMBLY_VISUAL_TEST: left-start"); } } else if (timer == 210) { SickleCombatSnapshot snapshot = SickleCombatSnapshot.Capture(player, Math.Max(1, player.GetWeaponDamage(player.HeldItem)), player.GetWeaponKnockback(player.HeldItem, player.HeldItem.knockBack)); ReaperActionControllerProjectile.Spawn( player.GetSource_Misc("SoulHarvest:AssemblyVisualRight"), player, snapshot, new Vector2(320f, -40f), ultimate: false); Mod.Logger.Info( "SOULHARVEST_ASSEMBLY_VISUAL_TEST: right-start"); } else if (timer == 350) { ReaperCombatService.TryReleaseSpecial(player); Mod.Logger.Info( "SOULHARVEST_ASSEMBLY_VISUAL_TEST: right-release"); } else if (timer >= 430) { timer = 0; } } } #endif