285 lines
12 KiB
C#
285 lines
12 KiB
C#
|
|
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>();
|
|||
|
|
}
|