using SoulHarvest.Items; using SoulHarvest.Tiles; using Microsoft.Xna.Framework; using System; using System.Collections.Generic; using Terraria; using Terraria.ID; using Terraria.ModLoader; namespace SoulHarvest.Common; /// /// Server-authoritative mutations for the character progression tree. Every method /// validates the complete transaction before consuming currency. /// public static class ReaperProgressionService { private const int InventorySlotCount = 58; private const float MaximumAltarDistance = 12f * 16f; public static bool TryUnlockNextStage( Player player, ReaperFormId form, ReaperStage expectedTargetStage, out ReaperProgressionFailure failure) { failure = ValidateAuthority(player); if (failure != ReaperProgressionFailure.None) return false; if (!IsNearDeathAltar(player)) { failure = ReaperProgressionFailure.NotNearAltar; return false; } if (!ReaperDefinitions.IsBranchForm(form)) { failure = ReaperProgressionFailure.InvalidForm; return false; } MyPlayer modPlayer = player.GetModPlayer(); ReaperProgressionState progression = modPlayer.ReaperProgression; ReaperStage currentStage = progression.GetStage(form); if (currentStage >= ReaperStage.StageIII) { failure = ReaperProgressionFailure.Maxed; return false; } ReaperStage targetStage = (ReaperStage)((int)currentStage + 1); if (expectedTargetStage != targetStage) { failure = ReaperProgressionFailure.InvalidStage; return false; } int essenceCost = ReaperDefinitions.GetStageEssenceCost(targetStage); if (essenceCost < 0) { failure = ReaperProgressionFailure.InvalidStage; return false; } if (!ReaperDefinitions.MeetsWorldGate(targetStage)) { failure = ReaperProgressionFailure.WorldProgressionLocked; return false; } if (progression.GetSoulSealAvailable(targetStage) <= 0) { failure = ReaperProgressionFailure.NotEnoughSoulSeals; return false; } int soulSealType = ReaperSoulSealCatalog.GetItemType(targetStage); if (CountInventoryItem(player, soulSealType) < 1) { failure = ReaperProgressionFailure.NotEnoughSoulSeals; return false; } int essenceType = ModContent.ItemType(); if (CountInventoryItem(player, essenceType) < essenceCost) { failure = ReaperProgressionFailure.NotEnoughEssence; return false; } // All three operations are guaranteed by the checks above and cannot race on // Terraria's world thread. Currency is removed only after state validation. if (!progression.TryUnlockNextStage(form) || !progression.TryConsumeSoulSeal(targetStage)) { failure = ReaperProgressionFailure.InvalidStage; return false; } HashSet changedSlots = []; ConsumeInventoryItem(player, essenceType, essenceCost, changedSlots); ConsumeInventoryItem(player, soulSealType, 1, changedSlots); SoulHarvest.SendReaperProgressionTransaction(player, changedSlots); return true; } public static bool TryUpgradeSkill( Player player, ReaperFormId form, int skillIndex, int expectedTargetLevel, out ReaperProgressionFailure failure) { failure = ValidateAuthority(player); if (failure != ReaperProgressionFailure.None) return false; if (!IsNearDeathAltar(player)) { failure = ReaperProgressionFailure.NotNearAltar; return false; } if (!ReaperDefinitions.IsBranchForm(form)) { failure = ReaperProgressionFailure.InvalidForm; return false; } if (skillIndex < 0 || skillIndex >= ReaperDefinitions.SkillsPerBranch) { failure = ReaperProgressionFailure.InvalidStage; return false; } MyPlayer modPlayer = player.GetModPlayer(); ReaperProgressionState progression = modPlayer.ReaperProgression; int currentLevel = progression.GetSkillLevel(form, skillIndex); if (skillIndex >= (int)progression.GetStage(form) || currentLevel <= 0) { failure = ReaperProgressionFailure.SkillLocked; return false; } if (currentLevel >= ReaperDefinitions.MaximumSkillLevel) { failure = ReaperProgressionFailure.Maxed; return false; } if (expectedTargetLevel != currentLevel + 1) { failure = ReaperProgressionFailure.InvalidStage; return false; } int soulCost = ReaperDefinitions.GetSkillSoulCost(currentLevel + 1); if (soulCost < 0 || modPlayer.Souls < soulCost) { failure = ReaperProgressionFailure.NotEnoughSouls; return false; } if (!progression.TryUpgradeSkill(form, skillIndex)) { // This cannot occur after validation on the world thread. It is kept as a // defensive failure rather than allowing a duplicated upgrade. failure = ReaperProgressionFailure.InvalidStage; return false; } // The preflight makes this spend infallible on Terraria's world thread. // Its balance and the mutated tree are sent together below. if (!modPlayer.TrySpendSouls(soulCost, sync: false)) { failure = ReaperProgressionFailure.NotEnoughSouls; return false; } SoulHarvest.SendReaperProgressionTransaction(player, Array.Empty()); return true; } public static bool TryUpgradeCommonNode( Player player, ReaperCommonNode node, int expectedTargetLevel, out ReaperProgressionFailure failure) { failure = ValidateAuthority(player); if (failure != ReaperProgressionFailure.None) return false; if (!IsNearDeathAltar(player)) { failure = ReaperProgressionFailure.NotNearAltar; return false; } if (!ReaperDefinitions.IsCommonNode(node)) { failure = ReaperProgressionFailure.InvalidStage; return false; } MyPlayer modPlayer = player.GetModPlayer(); ReaperProgressionState progression = modPlayer.ReaperProgression; if (ReaperDefinitions.IsDeathNode(node) && !progression.DeathFormUnlocked) { failure = ReaperProgressionFailure.DeathFormLocked; return false; } if (node == ReaperCommonNode.DeathRiftWidth && progression.GetCommonNodeLevel(ReaperCommonNode.DeathSpaceBreak) <= 0) { failure = ReaperProgressionFailure.SkillLocked; return false; } if (progression.GetCommonNodeLevel(node) == int.MaxValue) { failure = ReaperProgressionFailure.Maxed; return false; } int targetLevel = progression.GetCommonNodeLevel(node) + 1; if (targetLevel > ReaperDefinitions.GetCommonNodeMaxLevel(node)) { failure = ReaperProgressionFailure.Maxed; return false; } if (expectedTargetLevel != targetLevel) { failure = ReaperProgressionFailure.InvalidStage; return false; } ReaperStage requiredStage = ReaperDefinitions.GetCommonNodeRequiredStage(node, targetLevel); if (!progression.HasAnyBranchAtLeast(requiredStage)) { failure = ReaperProgressionFailure.SkillLocked; return false; } ReaperProgressionCost? nullableCost = ReaperDefinitions.GetCommonNodeCost(node, targetLevel); if (nullableCost is not ReaperProgressionCost cost) { failure = ReaperProgressionFailure.InvalidStage; return false; } int essenceType = ModContent.ItemType(); if (cost.Currency == ReaperProgressionCurrency.Souls && modPlayer.Souls < cost.Amount) { failure = ReaperProgressionFailure.NotEnoughSouls; return false; } if (cost.Currency == ReaperProgressionCurrency.SoulEssence && CountInventoryItem(player, essenceType) < cost.Amount) { failure = ReaperProgressionFailure.NotEnoughEssence; return false; } if (!progression.TryUpgradeCommonNode(node)) { failure = ReaperProgressionFailure.InvalidStage; return false; } HashSet changedSlots = []; if (cost.Currency == ReaperProgressionCurrency.Souls && !modPlayer.TrySpendSouls(cost.Amount, sync: false)) { failure = ReaperProgressionFailure.NotEnoughSouls; return false; } if (cost.Currency == ReaperProgressionCurrency.SoulEssence) ConsumeInventoryItem(player, essenceType, cost.Amount, changedSlots); SoulHarvest.SendReaperProgressionTransaction(player, changedSlots); return true; } public static bool TrySwitchForm(Player player, ReaperFormId form, out ReaperProgressionFailure failure) { failure = ValidateAuthority(player); if (failure != ReaperProgressionFailure.None) return false; ReaperProgressionState progression = player.GetModPlayer().ReaperProgression; if (form == ReaperFormId.Death && !progression.DeathFormUnlocked) { failure = ReaperProgressionFailure.DeathFormLocked; return false; } if (form != ReaperFormId.Base && form != ReaperFormId.Death && (!ReaperDefinitions.IsBranchForm(form) || progression.GetStage(form) == ReaperStage.Locked)) { failure = ReaperProgressionFailure.InvalidForm; return false; } if (!player.GetModPlayer().TrySwitchOrQueueReaperForm(form)) { failure = ReaperProgressionFailure.InvalidForm; return false; } return true; } private static ReaperProgressionFailure ValidateAuthority(Player player) { if (Main.netMode == NetmodeID.MultiplayerClient) return ReaperProgressionFailure.ClientAuthority; if (player is null || !player.active) return ReaperProgressionFailure.InvalidForm; if (!player.GetModPlayer().ServerCharacterStateReady) return ReaperProgressionFailure.ClientAuthority; return ReaperProgressionFailure.None; } private static int CountInventoryItem(Player player, int itemType) { if (itemType <= ItemID.None) return 0; int count = 0; int slotCount = Math.Min(InventorySlotCount, player.inventory.Length); for (int slot = 0; slot < slotCount; slot++) { Item item = player.inventory[slot]; if (item.type == itemType && item.stack > 0) count = (int)Math.Min(int.MaxValue, (long)count + item.stack); } return count; } private static bool IsNearDeathAltar(Player player) { Point center = player.Center.ToTileCoordinates(); int tileRadius = (int)Math.Ceiling(MaximumAltarDistance / 16f); int altarType = ModContent.TileType(); for (int x = center.X - tileRadius; x <= center.X + tileRadius; x++) { for (int y = center.Y - tileRadius; y <= center.Y + tileRadius; y++) { if (!WorldGen.InWorld(x, y, 1)) continue; Tile tile = Framing.GetTileSafely(x, y); if (!tile.HasTile || tile.TileType != altarType) continue; Vector2 altarPosition = new((x + 0.5f) * 16f, (y + 0.5f) * 16f); if (Vector2.DistanceSquared(player.Center, altarPosition) <= MaximumAltarDistance * MaximumAltarDistance) { return true; } } } return false; } private static void ConsumeInventoryItem(Player player, int itemType, int amount, HashSet changedSlots) { if (itemType <= ItemID.None || amount <= 0) return; int slotCount = Math.Min(InventorySlotCount, player.inventory.Length); for (int slot = 0; slot < slotCount && amount > 0; slot++) { Item item = player.inventory[slot]; if (item.type != itemType || item.stack <= 0) continue; int consumed = Math.Min(amount, item.stack); item.stack -= consumed; amount -= consumed; if (item.stack <= 0) item.TurnToAir(); changedSlots.Add(slot); } } }