Files
Christopher Wiebe b451f83174 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>
2026-04-30 20:40:51 -07:00

285 lines
12 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>();
}