using System; using System.IO; using Terraria.ModLoader.IO; namespace SoulHarvest.Common; /// /// Persisted, character-owned reaper progression. Combat energy, cooldowns and /// action state intentionally do not live here and therefore reset on death/rejoin. /// public sealed class ReaperProgressionState { public const int CurrentDataVersion = 7; public const string DefaultSaveKey = "ReaperProgression"; private const int BranchCount = 6; private const int CommonNodeCount = 13; private const int SoulSealStageCount = 3; private readonly ReaperBranchState[] branches = new ReaperBranchState[BranchCount]; private readonly int[] commonNodeLevels = new int[CommonNodeCount]; private readonly byte[] soulSealsIssued = new byte[SoulSealStageCount]; private readonly byte[] soulSealsSpent = new byte[SoulSealStageCount]; private readonly byte[] soulSealsPending = new byte[SoulSealStageCount]; public ReaperProgressionState() { for (int index = 0; index < branches.Length; index++) branches[index] = new ReaperBranchState(); } public int LoadedDataVersion { get; private set; } = CurrentDataVersion; public ReaperFormId CurrentForm { get; private set; } = ReaperFormId.Base; public bool DeathFormUnlocked { get { foreach (ReaperBranchState branch in branches) { if (branch.Stage != ReaperStage.StageIII) return false; for (int skill = 0; skill < ReaperDefinitions.SkillsPerBranch; skill++) { if (branch.GetSkillLevel(skill) != ReaperDefinitions.MaximumSkillLevel) return false; } } return true; } } public ReaperBranchState GetBranch(ReaperFormId form) { int index = GetBranchIndex(form); if (index < 0) throw new ArgumentOutOfRangeException(nameof(form), form, "Only the six branch forms own branch progression."); return branches[index]; } public ReaperStage GetStage(ReaperFormId form) { if (form == ReaperFormId.Base) return ReaperStage.StageI; if (form == ReaperFormId.Death) return DeathFormUnlocked ? ReaperStage.StageIII : ReaperStage.Locked; int index = GetBranchIndex(form); return index >= 0 ? branches[index].Stage : ReaperStage.Locked; } public int GetSkillLevel(ReaperFormId form, int skillIndex) { int index = GetBranchIndex(form); return index >= 0 ? branches[index].GetSkillLevel(skillIndex) : 0; } public int GetCommonNodeLevel(ReaperCommonNode node) { int index = (int)node; return index >= 0 && index < commonNodeLevels.Length ? commonNodeLevels[index] : 0; } public int GetNodeLevel(ReaperNodeId nodeId) { if (!ReaperDefinitions.TryGetNode(nodeId, out ReaperNodeDefinition definition)) return 0; return definition.CommonNode is ReaperCommonNode commonNode ? GetCommonNodeLevel(commonNode) : GetSkillLevel(definition.Form, definition.SkillIndex); } public int GetSoulSealIssued(ReaperStage stage) { int index = GetSoulSealIndex(stage); return index >= 0 ? soulSealsIssued[index] : 0; } public int GetSoulSealSpent(ReaperStage stage) { int index = GetSoulSealIndex(stage); return index >= 0 ? soulSealsSpent[index] : 0; } public int GetSoulSealPending(ReaperStage stage) { int index = GetSoulSealIndex(stage); return index >= 0 ? soulSealsPending[index] : 0; } public int GetSoulSealAvailable(ReaperStage stage) { int index = GetSoulSealIndex(stage); return index >= 0 ? Math.Max(0, soulSealsIssued[index] - soulSealsSpent[index] - soulSealsPending[index]) : 0; } // Plural aliases keep UI/call sites readable and preserve the public contract // used by the initial implementation plan. public int GetPendingSoulSeals(ReaperStage stage) => GetSoulSealPending(stage); public int GetAvailableSoulSeals(ReaperStage stage) => GetSoulSealAvailable(stage); public bool CanIssueSoulSeal(ReaperStage stage) { int index = GetSoulSealIndex(stage); return index >= 0 && soulSealsIssued[index] < ReaperDefinitions.MaximumSoulSealsPerStage; } /// /// Records a server-authoritative boss reward. Pending means the representative /// item could not be placed in inventory yet; the entitlement itself is already /// capped and persisted here. /// public bool TryRecordSoulSealIssued(ReaperStage stage, bool pending) { int index = GetSoulSealIndex(stage); if (index < 0 || soulSealsIssued[index] >= ReaperDefinitions.MaximumSoulSealsPerStage) return false; soulSealsIssued[index]++; if (pending) soulSealsPending[index]++; return true; } public bool TryMarkPendingSoulSealDelivered(ReaperStage stage) { int index = GetSoulSealIndex(stage); if (index < 0 || soulSealsPending[index] <= 0) return false; soulSealsPending[index]--; return true; } internal int MarkMissingSoulSealsPending(ReaperStage stage, int amount) { int index = GetSoulSealIndex(stage); if (index < 0 || amount <= 0) return 0; int maximumMissing = Math.Max(0, soulSealsIssued[index] - soulSealsSpent[index] - soulSealsPending[index]); int marked = Math.Min(amount, maximumMissing); soulSealsPending[index] += (byte)marked; return marked; } public bool TrySetCurrentForm(ReaperFormId form) { if (form == ReaperFormId.Base) { CurrentForm = form; return true; } if (form == ReaperFormId.Death) { if (!DeathFormUnlocked) return false; CurrentForm = form; return true; } if (!ReaperDefinitions.IsBranchForm(form) || GetStage(form) == ReaperStage.Locked) return false; CurrentForm = form; return true; } public void Reset() { foreach (ReaperBranchState branch in branches) branch.Reset(); Array.Clear(commonNodeLevels); Array.Clear(soulSealsIssued); Array.Clear(soulSealsSpent); Array.Clear(soulSealsPending); CurrentForm = ReaperFormId.Base; LoadedDataVersion = CurrentDataVersion; } /// /// Debug-only rollback of every sickle progression node. Issued soul seals are /// retained and marked unspent so the same test character can replay all six /// branches without farming bosses again. /// internal void ResetTreeForDebug() { foreach (ReaperBranchState branch in branches) branch.Reset(); Array.Clear(commonNodeLevels); Array.Clear(soulSealsSpent); Array.Clear(soulSealsPending); CurrentForm = ReaperFormId.Base; LoadedDataVersion = CurrentDataVersion; NormalizeSoulSealLedger(); } public void SaveData(TagCompound rootTag, string key = DefaultSaveKey) { byte[] branchStages = new byte[branches.Length]; byte[] branchSkills = new byte[branches.Length * ReaperDefinitions.SkillsPerBranch]; for (int branch = 0; branch < branches.Length; branch++) { branchStages[branch] = (byte)branches[branch].Stage; branches[branch].CopySkillLevelsTo(branchSkills, branch * ReaperDefinitions.SkillsPerBranch); } byte[] nodeIds = new byte[ReaperDefinitions.Nodes.Count]; byte[] legacyNodeLevels = new byte[ReaperDefinitions.Nodes.Count]; int[] nodeLevels = new int[ReaperDefinitions.Nodes.Count]; for (int index = 0; index < ReaperDefinitions.Nodes.Count; index++) { ReaperNodeDefinition definition = ReaperDefinitions.Nodes[index]; nodeIds[index] = (byte)definition.Id; nodeLevels[index] = GetNodeLevel(definition.Id); legacyNodeLevels[index] = (byte)Math.Clamp(nodeLevels[index], 0, byte.MaxValue); } rootTag[key] = new TagCompound { ["Version"] = CurrentDataVersion, ["CurrentForm"] = (byte)CurrentForm, ["BranchStages"] = branchStages, ["NodeIds"] = nodeIds, ["NodeLevelsInt"] = nodeLevels, ["NodeLevels"] = legacyNodeLevels, // Retained as a downgrade fallback for pre-id 3.0 development saves. ["BranchSkills"] = branchSkills, ["CommonNodesInt"] = (int[])commonNodeLevels.Clone(), ["CommonNodes"] = Array.ConvertAll(commonNodeLevels, value => (byte)Math.Clamp(value, 0, byte.MaxValue)), ["SoulSealsIssued"] = (byte[])soulSealsIssued.Clone(), ["SoulSealsSpent"] = (byte[])soulSealsSpent.Clone(), ["SoulSealsPending"] = (byte[])soulSealsPending.Clone() }; } public void LoadData(TagCompound rootTag, string key = DefaultSaveKey) { Reset(); if (!rootTag.ContainsKey(key)) { LoadedDataVersion = 0; return; } TagCompound tag = rootTag.GetCompound(key); LoadedDataVersion = Math.Max(0, tag.GetInt("Version")); CopyLoadedBytes(tag.GetByteArray("BranchStages"), (index, value) => { branches[index].Stage = (ReaperStage)Math.Clamp(value, (byte)ReaperStage.Locked, (byte)ReaperStage.StageIII); }, branches.Length); bool hasStableNodes = tag.ContainsKey("NodeIds") && (tag.ContainsKey("NodeLevelsInt") || tag.ContainsKey("NodeLevels")); if (hasStableNodes) { byte[] nodeIds = tag.GetByteArray("NodeIds"); int[] nodeLevels = tag.ContainsKey("NodeLevelsInt") ? tag.Get("NodeLevelsInt") : Array.ConvertAll(tag.GetByteArray("NodeLevels"), value => (int)value); int nodeCount = Math.Min(nodeIds.Length, nodeLevels.Length); int migratedDeathDamage = 0; for (int index = 0; index < nodeCount; index++) { ReaperNodeId nodeId = (ReaperNodeId)nodeIds[index]; if (nodeId == ReaperNodeId.CommonDamage && nodeLevels[index] > ReaperDefinitions.GetCommonNodeMaxLevel(ReaperCommonNode.Damage)) { migratedDeathDamage = Math.Max(migratedDeathDamage, nodeLevels[index] - ReaperDefinitions.GetCommonNodeMaxLevel(ReaperCommonNode.Damage)); } if (ReaperDefinitions.TryGetNode(nodeId, out _)) SetNodeLevel(nodeId, nodeLevels[index]); } if (migratedDeathDamage > 0) { SetCommonNodeLevel(ReaperCommonNode.DeathDamage, Math.Max(GetCommonNodeLevel(ReaperCommonNode.DeathDamage), migratedDeathDamage)); } } else { byte[] skills = tag.GetByteArray("BranchSkills"); for (int branch = 0; branch < branches.Length; branch++) { for (int skill = 0; skill < ReaperDefinitions.SkillsPerBranch; skill++) { int flatIndex = branch * ReaperDefinitions.SkillsPerBranch + skill; if (flatIndex < skills.Length) branches[branch].SetSkillLevel(skill, skills[flatIndex]); } } if (tag.ContainsKey("CommonNodesInt")) { int[] loadedCommon = tag.Get("CommonNodesInt"); int migratedDeathDamage = loadedCommon.Length > (int)ReaperCommonNode.Damage ? Math.Max(0, loadedCommon[(int)ReaperCommonNode.Damage] - ReaperDefinitions.GetCommonNodeMaxLevel(ReaperCommonNode.Damage)) : 0; LoadClampedArray(loadedCommon, commonNodeLevels, index => ReaperDefinitions.GetCommonNodeMaxLevel((ReaperCommonNode)index)); if (migratedDeathDamage > 0) SetCommonNodeLevel(ReaperCommonNode.DeathDamage, migratedDeathDamage); } else LoadClampedArray(Array.ConvertAll(tag.GetByteArray("CommonNodes"), value => (int)value), commonNodeLevels, index => ReaperDefinitions.GetCommonNodeMaxLevel((ReaperCommonNode)index)); } for (int branch = 0; branch < branches.Length; branch++) branches[branch].EnsureStageSkills(); LoadClampedArray(tag.GetByteArray("SoulSealsIssued"), soulSealsIssued, _ => ReaperDefinitions.MaximumSoulSealsPerStage); LoadClampedArray(tag.GetByteArray("SoulSealsSpent"), soulSealsSpent, _ => ReaperDefinitions.MaximumSoulSealsPerStage); LoadClampedArray(tag.GetByteArray("SoulSealsPending"), soulSealsPending, _ => ReaperDefinitions.MaximumSoulSealsPerStage); NormalizeSoulSealLedger(); ReaperFormId loadedForm = (ReaperFormId)tag.GetByte("CurrentForm"); CurrentForm = ReaperFormId.Base; TrySetCurrentForm(loadedForm); } public void NetSend(BinaryWriter writer) { writer.Write((byte)CurrentDataVersion); writer.Write((byte)CurrentForm); for (int branch = 0; branch < branches.Length; branch++) { writer.Write((byte)branches[branch].Stage); for (int skill = 0; skill < ReaperDefinitions.SkillsPerBranch; skill++) writer.Write((byte)branches[branch].GetSkillLevel(skill)); } for (int index = 0; index < commonNodeLevels.Length; index++) writer.Write(commonNodeLevels[index]); writer.Write(soulSealsIssued); writer.Write(soulSealsSpent); writer.Write(soulSealsPending); } public void NetReceive(BinaryReader reader) { Reset(); LoadedDataVersion = reader.ReadByte(); ReaperFormId receivedForm = (ReaperFormId)reader.ReadByte(); for (int branch = 0; branch < branches.Length; branch++) { branches[branch].Stage = (ReaperStage)Math.Clamp(reader.ReadByte(), (byte)ReaperStage.Locked, (byte)ReaperStage.StageIII); for (int skill = 0; skill < ReaperDefinitions.SkillsPerBranch; skill++) branches[branch].SetSkillLevel(skill, reader.ReadByte()); branches[branch].EnsureStageSkills(); } for (int index = 0; index < commonNodeLevels.Length; index++) commonNodeLevels[index] = Math.Clamp(reader.ReadInt32(), 0, ReaperDefinitions.GetCommonNodeMaxLevel((ReaperCommonNode)index)); ReadClampedArray(reader, soulSealsIssued, _ => ReaperDefinitions.MaximumSoulSealsPerStage); ReadClampedArray(reader, soulSealsSpent, _ => ReaperDefinitions.MaximumSoulSealsPerStage); ReadClampedArray(reader, soulSealsPending, _ => ReaperDefinitions.MaximumSoulSealsPerStage); NormalizeSoulSealLedger(); CurrentForm = ReaperFormId.Base; TrySetCurrentForm(receivedForm); } internal bool TryUnlockNextStage(ReaperFormId form) { int branchIndex = GetBranchIndex(form); if (branchIndex < 0 || branches[branchIndex].Stage >= ReaperStage.StageIII) return false; branches[branchIndex].Stage++; branches[branchIndex].EnsureStageSkills(); return true; } internal bool TryUpgradeSkill(ReaperFormId form, int skillIndex) { return ReaperDefinitions.TryGetSkillNode(form, skillIndex, out ReaperNodeId nodeId) && TryUpgradeNode(nodeId); } internal bool TryUpgradeCommonNode(ReaperCommonNode node) { return ReaperDefinitions.TryGetCommonNode(node, out ReaperNodeId nodeId) && TryUpgradeNode(nodeId); } internal bool TryUpgradeNode(ReaperNodeId nodeId) { if (!ReaperDefinitions.TryGetNode(nodeId, out ReaperNodeDefinition definition)) return false; if (!definition.IsCommon) { int branchIndex = GetBranchIndex(definition.Form); return branchIndex >= 0 && branches[branchIndex].TryUpgradeSkill(definition.SkillIndex); } ReaperCommonNode commonNode = definition.CommonNode!.Value; int index = (int)commonNode; if (index < 0 || index >= commonNodeLevels.Length || commonNodeLevels[index] >= ReaperDefinitions.GetCommonNodeMaxLevel(commonNode)) { return false; } commonNodeLevels[index]++; return true; } internal bool TryConsumeSoulSeal(ReaperStage stage) { int index = GetSoulSealIndex(stage); if (index < 0 || GetSoulSealAvailable(stage) <= 0) return false; soulSealsSpent[index]++; return true; } internal bool HasAnyBranchAtLeast(ReaperStage stage) { foreach (ReaperBranchState branch in branches) { if (branch.Stage >= stage) return true; } return false; } internal void SetBranchStage(ReaperFormId form, ReaperStage stage) { int index = GetBranchIndex(form); if (index < 0) return; branches[index].Stage = (ReaperStage)Math.Clamp((byte)stage, (byte)ReaperStage.Locked, (byte)ReaperStage.StageIII); branches[index].EnsureStageSkills(); } internal void SetSkillLevel(ReaperFormId form, int skillIndex, int level) { if (ReaperDefinitions.TryGetSkillNode(form, skillIndex, out ReaperNodeId nodeId)) SetNodeLevel(nodeId, level); } internal void SetCommonNodeLevel(ReaperCommonNode node, int level) { if (ReaperDefinitions.TryGetCommonNode(node, out ReaperNodeId nodeId)) SetNodeLevel(nodeId, level); } internal void SetNodeLevel(ReaperNodeId nodeId, int level) { if (!ReaperDefinitions.TryGetNode(nodeId, out ReaperNodeDefinition definition)) return; if (!definition.IsCommon) { int branchIndex = GetBranchIndex(definition.Form); if (branchIndex >= 0) branches[branchIndex].SetSkillLevel(definition.SkillIndex, level); return; } ReaperCommonNode commonNode = definition.CommonNode!.Value; int index = (int)commonNode; if (index >= 0 && index < commonNodeLevels.Length) commonNodeLevels[index] = Math.Clamp(level, 0, ReaperDefinitions.GetCommonNodeMaxLevel(commonNode)); } internal void MarkMigrationComplete() { LoadedDataVersion = CurrentDataVersion; } internal void BackfillSoulSealConsumptionFromBranches() { for (ReaperStage stage = ReaperStage.StageI; stage <= ReaperStage.StageIII; stage++) { int index = GetSoulSealIndex(stage); int consumed = 0; foreach (ReaperBranchState branch in branches) { if (branch.Stage >= stage) consumed++; } soulSealsIssued[index] = (byte)Math.Max(soulSealsIssued[index], consumed); soulSealsSpent[index] = (byte)Math.Max(soulSealsSpent[index], consumed); } NormalizeSoulSealLedger(); } internal void UnlockDeathMasteryForMigration() { foreach (ReaperFormId form in ReaperDefinitions.BranchForms) { SetBranchStage(form, ReaperStage.StageIII); for (int skill = 0; skill < ReaperDefinitions.SkillsPerBranch; skill++) SetSkillLevel(form, skill, ReaperDefinitions.MaximumSkillLevel); } TrySetCurrentForm(ReaperFormId.Death); MarkMigrationComplete(); } private static int GetBranchIndex(ReaperFormId form) { return ReaperDefinitions.IsBranchForm(form) ? (int)form - 1 : -1; } private static int GetSoulSealIndex(ReaperStage stage) { return stage is >= ReaperStage.StageI and <= ReaperStage.StageIII ? (int)stage - 1 : -1; } private static void CopyLoadedBytes(byte[] source, Action setter, int count) { int copyCount = Math.Min(source.Length, count); for (int index = 0; index < copyCount; index++) setter(index, source[index]); } private static void LoadClampedArray(byte[] source, byte[] destination, Func maximum) { int copyCount = Math.Min(source.Length, destination.Length); for (int index = 0; index < copyCount; index++) destination[index] = (byte)Math.Clamp(source[index], 0, maximum(index)); } private static void LoadClampedArray(int[] source, int[] destination, Func maximum) { int copyCount = Math.Min(source.Length, destination.Length); for (int index = 0; index < copyCount; index++) destination[index] = Math.Clamp(source[index], 0, maximum(index)); } private static void ReadClampedArray(BinaryReader reader, byte[] destination, Func maximum) { byte[] source = reader.ReadBytes(destination.Length); LoadClampedArray(source, destination, maximum); } private void NormalizeSoulSealLedger() { for (int index = 0; index < SoulSealStageCount; index++) { soulSealsIssued[index] = (byte)Math.Min((int)soulSealsIssued[index], ReaperDefinitions.MaximumSoulSealsPerStage); soulSealsSpent[index] = (byte)Math.Min((int)soulSealsSpent[index], soulSealsIssued[index]); int unspent = soulSealsIssued[index] - soulSealsSpent[index]; soulSealsPending[index] = (byte)Math.Min((int)soulSealsPending[index], unspent); } } }