using SoulHarvest.Items; using System; using System.Collections.Generic; using Terraria; using Terraria.ID; using Terraria.ModLoader; using Terraria.ModLoader.IO; namespace SoulHarvest.Common; /// /// Converts pre-3.0 item-owned sickle routes into the character progression tree. /// Only the backpack and the four personal banks are visited; world containers are /// deliberately outside the migration boundary. /// internal static class ReaperLegacyMigration { internal const int MigrationVersion = 1; private const int InventorySlotCount = 58; private const int MaximumUnmappedRefund = 60; public static ReaperLegacyMigrationResult Apply(Player player, ReaperProgressionState progression) { List sickles = CollectSickles(player); if (sickles.Count == 0) { progression.MarkMigrationComplete(); return new ReaperLegacyMigrationResult(0, 0, CreateSnapshot(progression, null, 0, 0)); } LegacySickleSnapshot canonical = SelectCanonicalDonor(sickles); bool hadLegacyDeath = false; int maximumLifeSteal = 0; int maximumFatedLevel = 0; foreach (LegacySickleSnapshot sickle in sickles) { if (sickle.ItemType == ModContent.ItemType()) hadLegacyDeath = true; ApplyLegacySnapshotRoute(progression, sickle); SetCommonMaximum(progression, ReaperCommonNode.Damage, MapDamageLevel(sickle.DamageLevel)); SetCommonMaximum(progression, ReaperCommonNode.AttackSpeed, MapFiveLevelStat(sickle.AttackSpeedLevel, 9)); SetCommonMaximum(progression, ReaperCommonNode.ArmorPenetration, MapFiveLevelStat(sickle.ArmorPenetrationLevel, 19)); SetCommonMaximum(progression, ReaperCommonNode.SpecialCooldown, MapFiveLevelStat(sickle.HitCooldownLevel, 9)); maximumLifeSteal = Math.Max(maximumLifeSteal, sickle.LifeStealLevel); maximumFatedLevel = Math.Max(maximumFatedLevel, MapFatedLevel(sickle)); } if (hadLegacyDeath) { progression.UnlockDeathMasteryForMigration(); } else { // These two old generic investments now live in form-specific trees. // Grant the minimum prerequisite branch so the mapped value is never // silently discarded for a player who took the other legacy route. if (maximumLifeSteal > 0) { SetBranchMaximum(progression, ReaperFormId.Blood, ReaperStage.StageI); progression.SetSkillLevel(ReaperFormId.Blood, 0, MapLifeStealLevel(maximumLifeSteal)); } if (maximumFatedLevel > 0) { SetBranchMaximum(progression, ReaperFormId.Soul, ReaperStage.StageIII); progression.SetSkillLevel(ReaperFormId.Soul, 2, maximumFatedLevel); } progression.MarkMigrationComplete(); } // Granted legacy stages already represent seals paid in the old item route. // Mirror that entitlement in the new ledger so migrated characters are not // issued obsolete low-stage seals (and old Death owners receive none at all). progression.BackfillSoulSealConsumptionFromBranches(); int refund = CalculateRefund(canonical, progression, hadLegacyDeath); int convertedCount = ConvertSicklesToShells(player, sickles); TagCompound snapshot = CreateSnapshot(progression, canonical, refund, convertedCount); return new ReaperLegacyMigrationResult(refund, convertedCount, snapshot); } private static List CollectSickles(Player player) { List result = []; CollectFromContainer(player.inventory, Math.Min(InventorySlotCount, player.inventory.Length), inventory: true, result); CollectFromContainer(player.bank.item, player.bank.item.Length, inventory: false, result); CollectFromContainer(player.bank2.item, player.bank2.item.Length, inventory: false, result); CollectFromContainer(player.bank3.item, player.bank3.item.Length, inventory: false, result); CollectFromContainer(player.bank4.item, player.bank4.item.Length, inventory: false, result); return result; } private static void CollectFromContainer(Item[] items, int count, bool inventory, List destination) { for (int slot = 0; slot < count; slot++) { Item item = items[slot]; if (item.IsAir || item.ModItem is not NormalSickle sickle) continue; TagCompound legacyTag = []; sickle.SaveData(legacyTag); int[] history = [ legacyTag.GetInt("previousTierOneType"), legacyTag.GetInt("previousTierTwoType"), legacyTag.GetInt("previousTierThreeType") ]; LegacySickleSnapshot snapshot = new( item, item.type, sickle.SickleTier, inventory ? slot : -1, sickle.DamageLevel, sickle.ProjectileCount, sickle.ProjectileDurationLevel, sickle.ArmorPenetrationLevel, sickle.LifeStealLevel, sickle.AttackSpeedLevel, sickle.KnockbackLevel, sickle.HitCooldownLevel, sickle.FatedUnlocked, sickle.FatedMaximumStacksLevel, sickle.FatedStacksPerHitLevel, sickle.FatedDurationLevel, history); destination.Add(snapshot with { LegacyInvestment = CalculateLegacyInvestment(snapshot) }); } } private static LegacySickleSnapshot SelectCanonicalDonor(List sickles) { LegacySickleSnapshot best = sickles[0]; for (int index = 1; index < sickles.Count; index++) { LegacySickleSnapshot candidate = sickles[index]; if (candidate.Tier > best.Tier || candidate.Tier == best.Tier && candidate.LegacyInvestment > best.LegacyInvestment) { best = candidate; } } return best; } private static void ApplyLegacySnapshotRoute(ReaperProgressionState progression, in LegacySickleSnapshot sickle) { ApplyLegacyRouteType(progression, sickle.ItemType); if (sickle.ItemType == ModContent.ItemType()) return; int tierOneType = sickle.RouteHistory.Length > 0 ? sickle.RouteHistory[0] : ItemID.None; int tierTwoType = sickle.RouteHistory.Length > 1 ? sickle.RouteHistory[1] : ItemID.None; int tierThreeType = sickle.RouteHistory.Length > 2 ? sickle.RouteHistory[2] : ItemID.None; int highestReachedTier = Math.Max( sickle.Tier, tierThreeType > ItemID.None ? 4 : tierTwoType > ItemID.None ? 3 : tierOneType > ItemID.None ? 2 : 0); // Regression keeps these history slots. Read them regardless of the item's // current tier so a weapon taken to Soul/Void/Death and later regressed does // not lose the routes it demonstrably reached. Raw 2.x type ids can shift // when a mod list changes, so ambiguous historical forms use the smallest // legal union instead of trusting a coincidental numeric fingerprint. if (highestReachedTier >= 2) { SetBranchMaximum(progression, ReaperFormId.Bone, ReaperStage.StageI); SetBranchMaximum(progression, ReaperFormId.Blood, ReaperStage.StageI); } if (highestReachedTier >= 3 || highestReachedTier >= 2 && sickle.Tier < 2) { SetBranchMaximum(progression, ReaperFormId.Infernal, ReaperStage.StageII); SetBranchMaximum(progression, ReaperFormId.Frost, ReaperStage.StageII); } if (highestReachedTier >= 4 || highestReachedTier >= 3 && sickle.Tier < 3) { SetBranchMaximum(progression, ReaperFormId.Soul, ReaperStage.StageIII); SetBranchMaximum(progression, ReaperFormId.Void, ReaperStage.StageIII); } } private static void ApplyLegacyRouteType(ReaperProgressionState progression, int itemType) { if (itemType <= ItemID.None) return; if (itemType == ModContent.ItemType()) { SetBranchMaximum(progression, ReaperFormId.Bone, ReaperStage.StageI); } else if (itemType == ModContent.ItemType()) { SetBranchMaximum(progression, ReaperFormId.Blood, ReaperStage.StageI); } else if (itemType == ModContent.ItemType()) { SetBranchMaximum(progression, ReaperFormId.Infernal, ReaperStage.StageII); } else if (itemType == ModContent.ItemType()) { SetBranchMaximum(progression, ReaperFormId.Frost, ReaperStage.StageII); } else if (itemType == ModContent.ItemType()) { SetBranchMaximum(progression, ReaperFormId.Soul, ReaperStage.StageIII); } else if (itemType == ModContent.ItemType()) { SetBranchMaximum(progression, ReaperFormId.Void, ReaperStage.StageIII); } else if (itemType == ModContent.ItemType()) { progression.UnlockDeathMasteryForMigration(); } } private static void SetBranchMaximum(ReaperProgressionState progression, ReaperFormId form, ReaperStage stage) { if (progression.GetStage(form) < stage) progression.SetBranchStage(form, stage); } private static void SetCommonMaximum(ReaperProgressionState progression, ReaperCommonNode node, int level) { if (progression.GetCommonNodeLevel(node) < level) progression.SetCommonNodeLevel(node, level); } private static int MapDamageLevel(int oldLevel) { return Math.Clamp((int)Math.Ceiling((Math.Clamp(oldLevel, 1, 40) - 1) * 10d / 39d), 0, 10); } private static int MapFiveLevelStat(int oldLevel, int oldIntervals) { return Math.Clamp((int)Math.Ceiling((Math.Max(1, oldLevel) - 1) * 5d / oldIntervals), 0, 5); } private static int MapLifeStealLevel(int oldLevel) { return Math.Clamp((int)Math.Ceiling(Math.Clamp(oldLevel, 0, 5) * 3d / 5d), 1, 3); } private static int MapFatedLevel(in LegacySickleSnapshot sickle) { if (!sickle.FatedUnlocked) return 0; double mastery = Math.Max( (Math.Clamp(sickle.FatedMaximumStacksLevel, 1, 10) - 1) / 9d, Math.Max( (Math.Clamp(sickle.FatedStacksPerHitLevel, 1, 5) - 1) / 4d, (Math.Clamp(sickle.FatedDurationLevel, 1, 10) - 1) / 9d)); return Math.Clamp(1 + (int)Math.Ceiling(mastery * 2d), 1, 3); } private static int CalculateRefund( in LegacySickleSnapshot canonical, ReaperProgressionState progression, bool hadLegacyDeath) { if (hadLegacyDeath) return 85; int routeRefund = canonical.Tier switch { 1 => 0, 2 => 13, 3 => 39, _ => 0 }; double mappedValue = CalculateMappedValue(canonical, progression); int unmappedRefund = Math.Clamp( (int)Math.Floor(Math.Max(0d, canonical.LegacyInvestment - mappedValue) * 0.25d), 0, MaximumUnmappedRefund); return routeRefund + unmappedRefund; } private static double CalculateMappedValue(in LegacySickleSnapshot sickle, ReaperProgressionState progression) { double value = 0d; value += GetCommonNodeValue(ReaperCommonNode.Damage, MapDamageLevel(sickle.DamageLevel)); value += GetCommonNodeValue(ReaperCommonNode.AttackSpeed, MapFiveLevelStat(sickle.AttackSpeedLevel, 9)); value += GetCommonNodeValue(ReaperCommonNode.ArmorPenetration, MapFiveLevelStat(sickle.ArmorPenetrationLevel, 19)); value += GetCommonNodeValue(ReaperCommonNode.SpecialCooldown, MapFiveLevelStat(sickle.HitCooldownLevel, 9)); if (sickle.LifeStealLevel > 0 && progression.GetStage(ReaperFormId.Blood) >= ReaperStage.StageI) value += GetSkillValue(MapLifeStealLevel(sickle.LifeStealLevel)); if (sickle.FatedUnlocked && progression.GetStage(ReaperFormId.Soul) >= ReaperStage.StageIII) value += GetSkillValue(MapFatedLevel(sickle)); return value; } private static double GetCommonNodeValue(ReaperCommonNode node, int level) { double value = 0d; for (int targetLevel = 1; targetLevel <= level; targetLevel++) { if (ReaperDefinitions.GetCommonNodeCost(node, targetLevel) is not ReaperProgressionCost cost) continue; value += cost.Currency == ReaperProgressionCurrency.Souls ? cost.Amount / 100d : cost.Amount; } return value; } private static double GetSkillValue(int level) { double value = 0d; for (int targetLevel = 2; targetLevel <= level; targetLevel++) value += Math.Max(0, ReaperDefinitions.GetSkillSoulCost(targetLevel)) / 100d; return value; } private static double CalculateLegacyInvestment(in LegacySickleSnapshot sickle) { double value = 0d; for (int level = 1; level < sickle.DamageLevel; level++) value += level < 20 ? level * 2d / 100d : level < 30 ? 1 + (level - 20) / 2 : 6 + level - 30; for (int level = 1; level < sickle.ProjectileCount; level++) value += 1 << Math.Min(20, level - 1); for (int level = 1; level < sickle.ProjectileDurationLevel; level++) value += 1 + (level - 1) / 2; for (int level = 1; level < sickle.ArmorPenetrationLevel; level++) value += 1 + (level - 1) / 3; for (int level = 0; level < sickle.LifeStealLevel; level++) value += 1 + level * 2; for (int level = 1; level < sickle.AttackSpeedLevel; level++) value += level < 6 ? level * 25d / 100d : 1 + level / 2; for (int level = 1; level < sickle.KnockbackLevel; level++) value += level < 6 ? level * 15d / 100d : 1 + level / 3; for (int level = 1; level < sickle.HitCooldownLevel; level++) value += 1 + (level - 1) / 2; if (sickle.FatedUnlocked) { value += 1; for (int level = 1; level < sickle.FatedMaximumStacksLevel; level++) value += level; for (int level = 1; level < sickle.FatedStacksPerHitLevel; level++) value += level * 2; for (int level = 1; level < sickle.FatedDurationLevel; level++) value += 1 + (level - 1) / 2; } return value; } private static int ConvertSicklesToShells(Player player, List sickles) { int normalType = ModContent.ItemType(); foreach (LegacySickleSnapshot snapshot in sickles) { Item item = snapshot.Item; int prefix = item.prefix; int stack = Math.Max(1, item.stack); bool favorited = item.favorited; bool newAndShiny = item.newAndShiny; item.SetDefaults(normalType); if (prefix > 0) item.Prefix(prefix); item.stack = Math.Clamp(stack, 1, item.maxStack); item.favorited = favorited; item.newAndShiny = newAndShiny; if (snapshot.InventorySlot >= 0) SyncInventorySlot(player, snapshot.InventorySlot); } return sickles.Count; } private static void SyncInventorySlot(Player player, int inventorySlot) { if (Main.netMode == NetmodeID.SinglePlayer) return; NetMessage.SendData( MessageID.SyncEquipment, -1, -1, null, player.whoAmI, PlayerItemSlotID.Inventory0 + inventorySlot, player.inventory[inventorySlot].prefix); if (Main.netMode == NetmodeID.Server) SoulHarvest.SendInventorySlot(player, inventorySlot); } private static TagCompound CreateSnapshot( ReaperProgressionState progression, LegacySickleSnapshot? canonical, int refund, int convertedCount) { byte[] stages = new byte[ReaperDefinitions.BranchForms.Count]; for (int index = 0; index < stages.Length; index++) stages[index] = (byte)progression.GetStage(ReaperDefinitions.BranchForms[index]); byte[] common = new byte[6]; for (int index = 0; index < common.Length; index++) common[index] = (byte)progression.GetCommonNodeLevel((ReaperCommonNode)index); return new TagCompound { ["Version"] = MigrationVersion, ["ConvertedWeaponCount"] = convertedCount, ["CanonicalType"] = canonical?.ItemType ?? ItemID.None, ["CanonicalTier"] = canonical?.Tier ?? 0, ["CanonicalInvestment"] = canonical?.LegacyInvestment ?? 0d, ["RefundEssence"] = refund, ["BranchStages"] = stages, ["CommonNodes"] = common, ["DeathUnlocked"] = progression.DeathFormUnlocked }; } private readonly record struct LegacySickleSnapshot( Item Item, int ItemType, int Tier, int InventorySlot, int DamageLevel, int ProjectileCount, int ProjectileDurationLevel, int ArmorPenetrationLevel, int LifeStealLevel, int AttackSpeedLevel, int KnockbackLevel, int HitCooldownLevel, bool FatedUnlocked, int FatedMaximumStacksLevel, int FatedStacksPerHitLevel, int FatedDurationLevel, int[] RouteHistory) { public double LegacyInvestment { get; init; } } }