Files
TheriapolisV3/Theriapolis.Core/Rules/Character/Character.cs
T

285 lines
12 KiB
C#
Raw Normal View History

using Theriapolis.Core.Data;
using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Stats;
namespace Theriapolis.Core.Rules.Character;
/// <summary>
/// Runtime aggregate of everything that makes a creature a *character* —
/// stats, class, clade, species, background, inventory, HP, conditions, level,
/// XP. Composed onto an <see cref="Entities.Actor"/> via the
/// <c>Actor.Character</c> field; the actor handles position and rendering,
/// the character handles gameplay state.
///
/// Phase 5 M2 builds these via <see cref="CharacterBuilder"/> at character
/// creation. Saved snapshots round-trip through
/// <see cref="Persistence.PlayerCharacterState"/>.
/// </summary>
public sealed class Character
{
public CladeDef Clade { get; }
public SpeciesDef Species { get; }
public ClassDef ClassDef { get; }
public BackgroundDef Background { get; }
public AbilityScores Abilities { get; private set; }
public int Level { get; set; } = 1;
public int Xp { get; set; } = 0;
public int MaxHp { get; set; }
public int CurrentHp { get; set; }
/// <summary>
/// Phase 6.5 M0 — subclass id, set when the level-3 selection is made.
/// Empty pre-L3; references one of <see cref="ClassDef.SubclassIds"/> after.
/// Loaded by save round-trip.
/// </summary>
public string SubclassId { get; set; } = "";
/// <summary>
/// Phase 6.5 M0 — feature ids learned across all level-ups, in unlock
/// order. Includes level-1 features applied by <see cref="CharacterBuilder"/>
/// plus everything <see cref="ApplyLevelUp"/> appends. The
/// <see cref="FeatureProcessor"/> consults this list when resolving combat
/// effects, dialogue hooks, etc.
/// </summary>
public List<string> LearnedFeatureIds { get; } = new();
/// <summary>
/// Phase 6.5 M0 — append-only history of per-level deltas. Used by
/// the level-up screen for display and by save round-trip for
/// reproducibility. Index = level - 1 (so [0] is the level-1 entry,
/// [1] is the level-2 entry, etc.); element 0 is synthesized at
/// character creation.
/// </summary>
public List<LevelUpRecord> LevelUpHistory { get; } = new();
/// <summary>
/// Phase 6.5 M4 — hybrid-character state. Null for purebred PCs
/// (the common case); non-null indicates the PC was built via
/// <see cref="CharacterBuilder.TryBuildHybrid"/>. Universal hybrid
/// detriments (Scent Dysphoria, Social Stigma, Illegible Body
/// Language, Medical Incompatibility) are inherent and applied via
/// <see cref="HybridDetriments"/> at use sites.
/// </summary>
public HybridState? Hybrid { get; set; }
/// <summary>True when the PC is a hybrid (i.e. <see cref="Hybrid"/> non-null).</summary>
public bool IsHybrid => Hybrid is not null;
/// <summary>Skill proficiencies — class skills + background skills, deduplicated.</summary>
public HashSet<SkillId> SkillProficiencies { get; } = new();
public Inventory Inventory { get; } = new();
/// <summary>Conditions currently affecting the character. Phase 5 M5 wires durations.</summary>
public HashSet<Condition> Conditions { get; } = new();
/// <summary>Exhaustion level 0..6, separate from <see cref="Conditions"/> (binary flags).</summary>
public int ExhaustionLevel { get; set; } = 0;
/// <summary>
/// Phase 5 M6: chosen Fangsworn fighting style ("duelist", "great_weapon",
/// "shieldwall", "fang_and_blade", "natural_predator"). Empty for non-Fangsworn
/// or when not yet picked. Defaults to "duelist" via CharacterBuilder.
/// </summary>
public string FightingStyle { get; set; } = "";
/// <summary>Phase 5 M6: Feral Rage uses remaining (refills on long rest; M6 treats as per-encounter).</summary>
public int RageUsesRemaining { get; set; } = 2;
// ── Phase 6.5 M1: per-encounter resource pools ────────────────────────
/// <summary>
/// Muzzle-Speaker Vocalization Dice — uses remaining (long-rest, 4 default
/// at level 1). Refilled per encounter at M1 since the rest model lives
/// in Phase 8.
/// </summary>
public int VocalizationDiceRemaining { get; set; } = 4;
/// <summary>
/// Covenant-Keeper Lay on Paws — HP pool remaining. Recharges to
/// <c>5 × CHA</c> on long rest; M1 refills per encounter.
/// </summary>
public int LayOnPawsPoolRemaining { get; set; }
/// <summary>
/// Claw-Wright Field Repair — uses remaining. Once per short rest at L1
/// per the JSON; M1 treats as 1 per encounter.
/// </summary>
public int FieldRepairUsesRemaining { get; set; } = 1;
// ── Phase 6.5 M3: ability-stream resource pools ──────────────────────
/// <summary>
/// Scent-Broker Pheromone Craft — uses remaining. The JSON ladder
/// (<c>pheromone_craft_2/3/4/5</c> at L2/L5/L9/L13) sets the per-rest
/// cap; <see cref="Combat.FeatureProcessor.EnsurePheromoneUsesReady"/>
/// tops the pool up to that cap at encounter start.
/// </summary>
public int PheromoneUsesRemaining { get; set; }
/// <summary>
/// Covenant-Keeper Covenant's Authority — uses remaining. The JSON
/// ladder (<c>covenants_authority_2/3/4/5</c> at L2/L9/L13/L17) sets
/// the cap; M3 tops up per encounter.
/// </summary>
public int CovenantAuthorityUsesRemaining { get; set; }
/// <summary>
/// Phase 6 M3 — Fangs (Theriapolis's universal coin). Used by the shop
/// dialogue branch. Defaults to a small starting stipend so the very
/// first merchant interaction has something to buy with.
/// </summary>
public int CurrencyFang { get; set; } = 25;
public bool IsAlive => CurrentHp > 0 || Conditions.Contains(Condition.Unconscious);
public Character(
CladeDef clade,
SpeciesDef species,
ClassDef classDef,
BackgroundDef background,
AbilityScores abilities)
{
Clade = clade ?? throw new ArgumentNullException(nameof(clade));
Species = species ?? throw new ArgumentNullException(nameof(species));
ClassDef = classDef ?? throw new ArgumentNullException(nameof(classDef));
Background = background ?? throw new ArgumentNullException(nameof(background));
Abilities = abilities;
}
/// <summary>Replace ability scores wholesale (used during creation; combat doesn't mutate this).</summary>
public void SetAbilities(AbilityScores scores)
{
Abilities = scores;
}
/// <summary>Body size category, derived from species.</summary>
public SizeCategory Size => SizeExtensions.FromJson(Species.Size);
/// <summary>d20 proficiency bonus for the character's current level.</summary>
public int ProficiencyBonus => Stats.ProficiencyBonus.ForLevel(Level);
/// <summary>
/// Computes max HP from class hit die + CON modifier at level 1 (and
/// avg-rounded-up + CON for each level beyond, per d20 default).
/// Phase 6.5 M0: still useful for character-creation initial HP, but
/// real per-level HP gains come from <see cref="ApplyLevelUp"/>'s
/// <see cref="LevelUpResult.HpGained"/> deltas (which respect the
/// player's roll-vs-average choice and pin to a deterministic seed).
/// </summary>
public int ComputeMaxHpFromScratch()
{
int conMod = Abilities.ModFor(AbilityId.CON);
int hp = ClassDef.HitDie + conMod;
// Levels 2+ add avg-rounded-up + CON each. Phase 5 = level 1 only,
// but the formula stays correct in case external code passes Level > 1.
for (int lv = 2; lv <= Level; lv++)
{
int avgRoundedUp = (ClassDef.HitDie / 2) + 1;
hp += avgRoundedUp + conMod;
}
return Math.Max(1, hp);
}
/// <summary>
/// Phase 6.5 M0 — apply a previously-computed <see cref="LevelUpResult"/>
/// plus the player's <see cref="LevelUpChoices"/> to this character.
/// Mutates Level, MaxHp, CurrentHp, SubclassId, Abilities, and appends
/// the unlocked features to <see cref="LearnedFeatureIds"/>. Records
/// the event in <see cref="LevelUpHistory"/>.
///
/// The caller is responsible for verifying the choices are valid for
/// the result's open slots (subclass selected when GrantsSubclassChoice;
/// ASI sums to +2 when GrantsAsiChoice). Validation lives in
/// <see cref="LevelUpChoicesValidator"/>; this method trusts what it
/// gets so it can be called from tests with bare choices.
/// </summary>
public void ApplyLevelUp(LevelUpResult result, LevelUpChoices choices)
{
if (result is null) throw new ArgumentNullException(nameof(result));
if (choices is null) throw new ArgumentNullException(nameof(choices));
// Apply ASI (if any).
if (result.GrantsAsiChoice && choices.AsiAdjustments.Count > 0)
{
var newAbilities = Abilities;
foreach (var (ability, delta) in choices.AsiAdjustments)
{
int current = newAbilities.Get(ability);
int cap = result.NewLevel >= C.CHARACTER_LEVEL_MAX
? C.ABILITY_SCORE_CAP_AT_L20
: C.ABILITY_SCORE_CAP_PRE_L20;
int next = Math.Min(cap, current + delta);
newAbilities = newAbilities.With(ability, next);
}
Abilities = newAbilities;
}
// Subclass selection (if any).
if (result.GrantsSubclassChoice && !string.IsNullOrEmpty(choices.SubclassId))
{
SubclassId = choices.SubclassId!;
}
// HP. The result's HpGained already incorporates CON mod at compute
// time; if the player took CON via ASI on the same level-up, we use
// the *new* CON for HP gained. Recompute defensively.
int conMod = Abilities.ModFor(AbilityId.CON);
int hpGained;
if (result.HpWasAveraged)
{
int avgRoundedUp = (ClassDef.HitDie / 2) + 1;
hpGained = Math.Max(1, avgRoundedUp + conMod);
}
else
{
// Roll value already determined; but CON might have changed.
hpGained = Math.Max(1, result.HpHitDieResult + conMod);
}
MaxHp += hpGained;
CurrentHp += hpGained; // level-up restores per d20 default
// Learned features.
foreach (var fid in result.ClassFeaturesUnlocked)
LearnedFeatureIds.Add(fid);
foreach (var fid in result.SubclassFeaturesUnlocked)
LearnedFeatureIds.Add(fid);
// Level — last, so all the per-level computations above use the
// pre-level-up Level for any branching they need.
Level = result.NewLevel;
// Record the event.
LevelUpHistory.Add(new LevelUpRecord
{
Level = result.NewLevel,
HpGained = hpGained,
HpWasAveraged = result.HpWasAveraged,
HpHitDieResult = result.HpHitDieResult,
SubclassChosen = result.GrantsSubclassChoice ? choices.SubclassId : null,
AsiAdjustmentsKeys = choices.AsiAdjustments.Keys.Select(k => (byte)k).ToArray(),
AsiAdjustmentsValues = choices.AsiAdjustments.Values.ToArray(),
FeaturesUnlocked = result.ClassFeaturesUnlocked
.Concat(result.SubclassFeaturesUnlocked)
.ToArray(),
});
}
}
/// <summary>
/// Phase 6.5 M0 — one entry in <see cref="Character.LevelUpHistory"/>.
/// Plain-data, serializable. Records the *deltas* (not the post-state), so
/// the history can be replayed on load and the level-up screen can show
/// the player what happened at each level.
/// </summary>
public sealed class LevelUpRecord
{
public int Level { get; init; }
public int HpGained { get; init; }
public bool HpWasAveraged { get; init; }
public int HpHitDieResult { get; init; }
public string? SubclassChosen { get; init; }
public byte[] AsiAdjustmentsKeys { get; init; } = Array.Empty<byte>();
public int[] AsiAdjustmentsValues { get; init; } = Array.Empty<int>();
public string[] FeaturesUnlocked { get; init; } = Array.Empty<string>();
}