using Theriapolis.Core.Data; using Theriapolis.Core.Items; using Theriapolis.Core.Rules.Stats; namespace Theriapolis.Core.Rules.Character; /// /// Runtime aggregate of everything that makes a creature a *character* — /// stats, class, clade, species, background, inventory, HP, conditions, level, /// XP. Composed onto an via the /// Actor.Character field; the actor handles position and rendering, /// the character handles gameplay state. /// /// Phase 5 M2 builds these via at character /// creation. Saved snapshots round-trip through /// . /// 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; } /// /// Phase 6.5 M0 — subclass id, set when the level-3 selection is made. /// Empty pre-L3; references one of after. /// Loaded by save round-trip. /// public string SubclassId { get; set; } = ""; /// /// Phase 6.5 M0 — feature ids learned across all level-ups, in unlock /// order. Includes level-1 features applied by /// plus everything appends. The /// consults this list when resolving combat /// effects, dialogue hooks, etc. /// public List LearnedFeatureIds { get; } = new(); /// /// 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. /// public List LevelUpHistory { get; } = new(); /// /// Phase 6.5 M4 — hybrid-character state. Null for purebred PCs /// (the common case); non-null indicates the PC was built via /// . Universal hybrid /// detriments (Scent Dysphoria, Social Stigma, Illegible Body /// Language, Medical Incompatibility) are inherent and applied via /// at use sites. /// public HybridState? Hybrid { get; set; } /// True when the PC is a hybrid (i.e. non-null). public bool IsHybrid => Hybrid is not null; /// Skill proficiencies — class skills + background skills, deduplicated. public HashSet SkillProficiencies { get; } = new(); public Inventory Inventory { get; } = new(); /// Conditions currently affecting the character. Phase 5 M5 wires durations. public HashSet Conditions { get; } = new(); /// Exhaustion level 0..6, separate from (binary flags). public int ExhaustionLevel { get; set; } = 0; /// /// 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. /// public string FightingStyle { get; set; } = ""; /// Phase 5 M6: Feral Rage uses remaining (refills on long rest; M6 treats as per-encounter). public int RageUsesRemaining { get; set; } = 2; // ── Phase 6.5 M1: per-encounter resource pools ──────────────────────── /// /// 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. /// public int VocalizationDiceRemaining { get; set; } = 4; /// /// Covenant-Keeper Lay on Paws — HP pool remaining. Recharges to /// 5 × CHA on long rest; M1 refills per encounter. /// public int LayOnPawsPoolRemaining { get; set; } /// /// Claw-Wright Field Repair — uses remaining. Once per short rest at L1 /// per the JSON; M1 treats as 1 per encounter. /// public int FieldRepairUsesRemaining { get; set; } = 1; // ── Phase 6.5 M3: ability-stream resource pools ────────────────────── /// /// Scent-Broker Pheromone Craft — uses remaining. The JSON ladder /// (pheromone_craft_2/3/4/5 at L2/L5/L9/L13) sets the per-rest /// cap; /// tops the pool up to that cap at encounter start. /// public int PheromoneUsesRemaining { get; set; } /// /// Covenant-Keeper Covenant's Authority — uses remaining. The JSON /// ladder (covenants_authority_2/3/4/5 at L2/L9/L13/L17) sets /// the cap; M3 tops up per encounter. /// public int CovenantAuthorityUsesRemaining { get; set; } /// /// 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. /// 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; } /// Replace ability scores wholesale (used during creation; combat doesn't mutate this). public void SetAbilities(AbilityScores scores) { Abilities = scores; } /// Body size category, derived from species. public SizeCategory Size => SizeExtensions.FromJson(Species.Size); /// d20 proficiency bonus for the character's current level. public int ProficiencyBonus => Stats.ProficiencyBonus.ForLevel(Level); /// /// 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 's /// deltas (which respect the /// player's roll-vs-average choice and pin to a deterministic seed). /// 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); } /// /// Phase 6.5 M0 — apply a previously-computed /// plus the player's to this character. /// Mutates Level, MaxHp, CurrentHp, SubclassId, Abilities, and appends /// the unlocked features to . Records /// the event in . /// /// 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 /// ; this method trusts what it /// gets so it can be called from tests with bare choices. /// 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(), }); } } /// /// Phase 6.5 M0 — one entry in . /// 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. /// 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(); public int[] AsiAdjustmentsValues { get; init; } = Array.Empty(); public string[] FeaturesUnlocked { get; init; } = Array.Empty(); }