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>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user