b451f83174
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>
108 lines
4.6 KiB
C#
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),
|
|
};
|
|
}
|
|
}
|