using Theriapolis.Core.Rules.Stats; using Theriapolis.Core.Util; namespace Theriapolis.Core.Rules.Character; /// /// Phase 6.5 M0 — the level-up flow. /// /// is a pure function: given a character, a target /// level, and a deterministic seed, it produces a /// describing what the level-up *would* do without mutating anything. The /// level-up screen previews this; /// commits it. /// /// Determinism: seed = worldSeed ^ characterCreationMs ^ RNG_LEVELUP ^ targetLevel. /// Same seed → same HP roll, same feature list, same ASI/subclass slots open. /// Save mid-flow → load → re-compute produces byte-identical payload. /// public static class LevelUpFlow { /// /// True when the character has accumulated enough XP to level up. /// Wraps the threshold check. /// public static bool CanLevelUp(Character character) { if (character.Level >= C.CHARACTER_LEVEL_MAX) return false; return character.Xp >= XpTable.XpRequiredForNextLevel(character.Level); } /// /// Compute (but do not apply) the level-up payload for advancing /// to . /// Caller is expected to validate targetLevel == character.Level + 1; /// no in-method assertion so unit tests can roll forward without mutation. /// /// determines HP roll outcome when the player /// chooses to roll instead of take average. Seeded callers should pass /// worldSeed ^ characterCreationMs ^ C.RNG_LEVELUP ^ (ulong)targetLevel. /// /// is the content resolver's subclass /// dictionary; when non-null and the character has a chosen subclass, /// the result's /// is populated. Phase 6.5 M0 callers (and tests without content) pass /// null, which yields an empty subclass-feature list. /// public static LevelUpResult Compute( Character character, int targetLevel, ulong seed, bool takeAverage = true, IReadOnlyDictionary? subclasses = null) { var classDef = character.ClassDef; int conMod = character.Abilities.ModFor(AbilityId.CON); int hitDie = classDef.HitDie; int avgHp = (hitDie / 2) + 1; int rollHp; if (takeAverage) { rollHp = avgHp; } else { // Roll 1d{hitDie} from a fresh stream so the result is reproducible // per-call. Use SeededRng to match Phase 5/6 RNG conventions. var rng = new SeededRng(seed); rollHp = (int)(rng.NextUInt64() % (uint)hitDie) + 1; } int hpGained = Math.Max(1, rollHp + conMod); // Class features at this level. The level-table indexes by Level // (1-based); arrays are 0-based so look up at [level - 1]. var classFeatures = Array.Empty(); if (targetLevel >= 1 && targetLevel <= classDef.LevelTable.Length) { var entry = classDef.LevelTable[targetLevel - 1]; classFeatures = entry.Features ?? Array.Empty(); } // Phase 6.5 M2 — resolve subclass features from the chosen subclass // (post-L3). When `subclasses` is null (M0 callers / tests without // content), no subclass features are unlocked. string[] subclassFeatures = subclasses is not null && !string.IsNullOrEmpty(character.SubclassId) ? SubclassResolver.UnlockedFeaturesAt(subclasses, character.SubclassId, targetLevel) : Array.Empty(); bool grantsSubclass = targetLevel == C.SUBCLASS_SELECTION_LEVEL && string.IsNullOrEmpty(character.SubclassId) && classDef.SubclassIds.Length > 0; bool grantsAsi = Array.IndexOf(C.ASI_LEVELS, targetLevel) >= 0; return new LevelUpResult { NewLevel = targetLevel, HpGained = hpGained, HpHitDieResult = rollHp, HpWasAveraged = takeAverage, ClassFeaturesUnlocked = classFeatures, SubclassFeaturesUnlocked = subclassFeatures, GrantsSubclassChoice = grantsSubclass, GrantsAsiChoice = grantsAsi, NewProficiencyBonus = ProficiencyBonus.ForLevel(targetLevel), }; } }