Files
TheriapolisV3/Theriapolis.Core/Rules/Character/LevelUpFlow.cs
T
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:40:51 -07:00

108 lines
4.6 KiB
C#

using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Rules.Character;
/// <summary>
/// Phase 6.5 M0 — the level-up flow.
///
/// <see cref="Compute"/> is a pure function: given a character, a target
/// level, and a deterministic seed, it produces a <see cref="LevelUpResult"/>
/// describing what the level-up *would* do without mutating anything. The
/// level-up screen previews this; <see cref="Character.ApplyLevelUp"/>
/// commits it.
///
/// Determinism: <c>seed = worldSeed ^ characterCreationMs ^ RNG_LEVELUP ^ targetLevel</c>.
/// Same seed → same HP roll, same feature list, same ASI/subclass slots open.
/// Save mid-flow → load → re-compute produces byte-identical payload.
/// </summary>
public static class LevelUpFlow
{
/// <summary>
/// True when the character has accumulated enough XP to level up.
/// Wraps the <see cref="XpTable"/> threshold check.
/// </summary>
public static bool CanLevelUp(Character character)
{
if (character.Level >= C.CHARACTER_LEVEL_MAX) return false;
return character.Xp >= XpTable.XpRequiredForNextLevel(character.Level);
}
/// <summary>
/// Compute (but do not apply) the level-up payload for advancing
/// <paramref name="character"/> to <paramref name="targetLevel"/>.
/// Caller is expected to validate <c>targetLevel == character.Level + 1</c>;
/// no in-method assertion so unit tests can roll forward without mutation.
///
/// <paramref name="seed"/> determines HP roll outcome when the player
/// chooses to roll instead of take average. Seeded callers should pass
/// <c>worldSeed ^ characterCreationMs ^ C.RNG_LEVELUP ^ (ulong)targetLevel</c>.
///
/// <paramref name="subclasses"/> is the content resolver's subclass
/// dictionary; when non-null and the character has a chosen subclass,
/// the result's <see cref="LevelUpResult.SubclassFeaturesUnlocked"/>
/// is populated. Phase 6.5 M0 callers (and tests without content) pass
/// null, which yields an empty subclass-feature list.
/// </summary>
public static LevelUpResult Compute(
Character character,
int targetLevel,
ulong seed,
bool takeAverage = true,
IReadOnlyDictionary<string, Data.SubclassDef>? 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<string>();
if (targetLevel >= 1 && targetLevel <= classDef.LevelTable.Length)
{
var entry = classDef.LevelTable[targetLevel - 1];
classFeatures = entry.Features ?? Array.Empty<string>();
}
// 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<string>();
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),
};
}
}