XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

DeathMod

【Terraria】DeathMod (now aka Soul Harvest)

公开
关注 0 Fork 0 Star 0
UTF-8
#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;

/// <summary>
/// Opt-in dedicated-server integration checks. This code is excluded from the
/// release package and only runs when SOULHARVEST_SERVER_SELFTEST=1.
/// </summary>
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<SoulHarvest>();
            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<MyPlayer>().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<NormalSickle>());
            player.ownedProjectileCounts[ModContent.ProjectileType<
                ReaperActionControllerProjectile>()] = 0;

            MyPlayer modPlayer = player.GetModPlayer<MyPlayer>();
            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<ReaperFormAssemblyProjectile>());
                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<ReaperFormAssemblyProjectile>());
            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<ReaperActionControllerProjectile>());
                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<SickleSwingProjectile>(),
                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<MyGlobalProjectile>().OnHitNPC(
                harvestSwing, harvestTarget, new NPC.HitInfo(), damageDone: 1);
            Require(harvestTarget.HasBuff(
                    ModContent.BuffType<Buffs.DeathMarkBuff>()),
                "authoritative Harvest Sickle hit did not apply Death Mark");
            MyGlobalNPC harvestStatus = harvestTarget.GetGlobalNPC<MyGlobalNPC>();
            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<ReaperActionControllerProjectile>());
            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<ReaperDeathEchoScytheProjectile>()) == 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<ReaperDeathEchoScytheProjectile>()) == 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<SickleSwingProjectile>(),
                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<SickleSwingProjectile>(),
                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<SickleSwingProjectile>(),
                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<MyPlayer>().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<SickleSwingProjectile>();
        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);
    }
}

/// <summary>
/// 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.
/// </summary>
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<MyPlayer>().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<int> 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<SoulHarvest>().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<SoulHarvest>().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<SoulHarvest>().Logger.Info(
                $"SOULHARVEST_SERVER_LIVE_SELFTEST: damage-event kind=ultimate-shatter damage={damageDone}");
        }
    }
}

/// <summary>
/// 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.
/// </summary>
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);
    }
}

/// <summary>
/// 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.
/// </summary>
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<ReaperMultiplayerDamageTarget>();
        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}");
    }
}

/// <summary>
/// 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.
/// </summary>
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<int> 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<int> deathUltimateVisualPhases = [];
    private readonly List<ReaperFormId> 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<MyPlayer>()
                .ClientCharacterStateAcknowledged)
            {
                if (timer >= 600)
                    Finish("FAIL handshake-timeout");
                return;
            }

            player.selectedItem = 0;
            player.inventory[0].SetDefaults(
                ModContent.ItemType<NormalSickle>());
            NetMessage.SendData(MessageID.SyncEquipment, -1, -1, null,
                player.whoAmI, PlayerItemSlotID.Inventory0,
                player.inventory[0].prefix);
            ReaperProgressionState progression = player.GetModPlayer<MyPlayer>()
                .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<ReaperFormId>())
                {
                    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<MyPlayer>()
                .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<MyPlayer>().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<MyPlayer>().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<MyPlayer>()
                .TakeNextSwingDirection(player.direction);
            int swingIndex = Projectile.NewProjectile(
                player.GetSource_Misc("SoulHarvest:LiveClientPrimarySelfTest"),
                player.MountedCenter,
                aim,
                ModContent.ProjectileType<SickleSwingProjectile>(),
                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<MyPlayer>()
                .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<MyPlayer>()
                .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<MyPlayer>();
            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<SickleSwingProjectile>()]} controller={player.ownedProjectileCounts[ModContent.ProjectileType<ReaperActionControllerProjectile>()]}");
                }
                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<MyPlayer>()
                .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<MyPlayer>();
            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<MyPlayer>()
            .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<ReaperMultiplayerDamageTarget>();
        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<MyPlayer>()
            .TakeNextSwingDirection(player.direction);
        int swingIndex = Projectile.NewProjectile(
            player.GetSource_Misc("SoulHarvest:LiveClientEnergySelfTest"),
            player.MountedCenter,
            aim,
            ModContent.ProjectileType<SickleSwingProjectile>(),
            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;
    }
}

/// <summary>
/// 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.
/// </summary>
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<MyPlayer>();
                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<ReaperMultiplayerDamageTarget>(),
                    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<MyPlayer>();
        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<Vector2> 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;
    }
}

/// <summary>Opt-in single-player storyboard used to inspect the Death assembly.</summary>
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<ReaperFormId>();

    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<NormalSickle>());
        MyPlayer modPlayer = player.GetModPlayer<MyPlayer>();
        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<SickleSwingProjectile>(),
                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