#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
#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