using System;
using System.IO;
using Terraria.ModLoader.IO;
namespace SoulHarvest.Common;
/// <summary>
/// Persisted, character-owned reaper progression. Combat energy, cooldowns and
/// action state intentionally do not live here and therefore reset on death/rejoin.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<int[]>("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<int[]>("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<int, byte> 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<int, int> 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<int, int> 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<int, int> 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);
}
}
}
using System;
using System.IO;
using Terraria.ModLoader.IO;
namespace SoulHarvest.Common;
/// <summary>
/// Persisted, character-owned reaper progression. Combat energy, cooldowns and
/// action state intentionally do not live here and therefore reset on death/rejoin.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<int[]>("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<int[]>("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<int, byte> 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<int, int> 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<int, int> 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<int, int> 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);
}
}
}