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:
Christopher Wiebe
2026-04-30 20:40:51 -07:00
commit b451f83174
525 changed files with 75786 additions and 0 deletions
@@ -0,0 +1,36 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Immutable background definition loaded from backgrounds.json.
/// Phase 5 grants the listed skill / tool proficiencies but does not
/// apply the named feature's mechanical effect — those resolve to
/// dialogue / quest / faction systems shipped in Phase 6.
/// </summary>
public sealed record BackgroundDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
[JsonPropertyName("name")]
public string Name { get; init; } = "";
[JsonPropertyName("flavor")]
public string Flavor { get; init; } = "";
[JsonPropertyName("skill_proficiencies")]
public string[] SkillProficiencies { get; init; } = Array.Empty<string>();
[JsonPropertyName("tool_proficiencies")]
public string[] ToolProficiencies { get; init; } = Array.Empty<string>();
[JsonPropertyName("feature_name")]
public string FeatureName { get; init; } = "";
[JsonPropertyName("feature_description")]
public string FeatureDescription { get; init; } = "";
[JsonPropertyName("suggested_personality")]
public string SuggestedPersonality { get; init; } = "";
}
@@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Immutable clade (race-equivalent) record loaded from clades.json.
/// Defines the broad biological family — Canidae, Felidae, etc. —
/// plus the ability mods, traits, and detriments shared by all member
/// species. See clades.md for the authoritative content.
/// </summary>
public sealed record CladeDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
[JsonPropertyName("name")]
public string Name { get; init; } = "";
/// <summary>STR/DEX/CON/INT/WIS/CHA → modifier (typically +1 each on two abilities).</summary>
[JsonPropertyName("ability_mods")]
public Dictionary<string, int> AbilityMods { get; init; } = new();
[JsonPropertyName("traits")]
public TraitDef[] Traits { get; init; } = Array.Empty<TraitDef>();
[JsonPropertyName("detriments")]
public TraitDef[] Detriments { get; init; } = Array.Empty<TraitDef>();
[JsonPropertyName("languages")]
public string[] Languages { get; init; } = Array.Empty<string>();
/// <summary>
/// "Predator" / "Prey" — surfaces in dialogue + faction-affinity logic
/// (Phase 6) and gates a few class features in Phase 5 (e.g. Feral
/// level-20 Apex Predator vs Apex Prey).
/// </summary>
[JsonPropertyName("kind")]
public string Kind { get; init; } = "predator";
}
@@ -0,0 +1,129 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Immutable class definition loaded from classes.json. Phase 5 reads
/// every field — including the full level table — but only level-1
/// features have runtime effect; higher-level entries are forward-compat
/// scaffolding for the level-up flow shipped in Phase 5.5 / 6.
/// </summary>
public sealed record ClassDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
[JsonPropertyName("name")]
public string Name { get; init; } = "";
/// <summary>Hit die size: 6 / 8 / 10 / 12.</summary>
[JsonPropertyName("hit_die")]
public int HitDie { get; init; } = 8;
/// <summary>Primary ability key(s) (STR / DEX / CON / INT / WIS / CHA).</summary>
[JsonPropertyName("primary_ability")]
public string[] PrimaryAbility { get; init; } = Array.Empty<string>();
/// <summary>Saving-throw proficiencies.</summary>
[JsonPropertyName("saves")]
public string[] Saves { get; init; } = Array.Empty<string>();
/// <summary>Armor proficiency tags: "light", "medium", "heavy", "shields".</summary>
[JsonPropertyName("armor_proficiencies")]
public string[] ArmorProficiencies { get; init; } = Array.Empty<string>();
/// <summary>Weapon proficiency tags: "simple", "martial", "natural", or specific item ids.</summary>
[JsonPropertyName("weapon_proficiencies")]
public string[] WeaponProficiencies { get; init; } = Array.Empty<string>();
/// <summary>Tool proficiency tags.</summary>
[JsonPropertyName("tool_proficiencies")]
public string[] ToolProficiencies { get; init; } = Array.Empty<string>();
[JsonPropertyName("skills_choose")]
public int SkillsChoose { get; init; } = 0;
[JsonPropertyName("skill_options")]
public string[] SkillOptions { get; init; } = Array.Empty<string>();
/// <summary>
/// Per-level entries. Level 1..20. Phase 5 only consults level 1, but
/// the full table loads so the level-up flow doesn't need a schema bump.
/// </summary>
[JsonPropertyName("level_table")]
public ClassLevelEntry[] LevelTable { get; init; } = Array.Empty<ClassLevelEntry>();
/// <summary>Description of each named feature referenced from level_table.</summary>
[JsonPropertyName("feature_definitions")]
public Dictionary<string, ClassFeatureDef> FeatureDefinitions { get; init; } = new();
/// <summary>Allowed subclass ids (cross-reference into subclasses.json).</summary>
[JsonPropertyName("subclass_ids")]
public string[] SubclassIds { get; init; } = Array.Empty<string>();
/// <summary>
/// Items handed to a level-1 character of this class at creation time.
/// <see cref="Rules.Character.CharacterBuilder"/> adds each entry to the
/// inventory and, if <see cref="StartingKitItem.AutoEquip"/> is true,
/// equips it into <see cref="StartingKitItem.EquipSlot"/>.
/// </summary>
[JsonPropertyName("starting_kit")]
public StartingKitItem[] StartingKit { get; init; } = Array.Empty<StartingKitItem>();
}
/// <summary>
/// One row in <see cref="ClassDef.StartingKit"/>: the item id, quantity, and
/// optional auto-equip target. ItemId must resolve against items.json.
/// </summary>
public sealed record StartingKitItem
{
[JsonPropertyName("item_id")]
public string ItemId { get; init; } = "";
[JsonPropertyName("qty")]
public int Qty { get; init; } = 1;
/// <summary>If true, the item is equipped into <see cref="EquipSlot"/> at creation.</summary>
[JsonPropertyName("auto_equip")]
public bool AutoEquip { get; init; } = false;
/// <summary>"main_hand" / "off_hand" / "body" / "helm" / "cloak" / "boots" / "adaptive_pack" / etc.</summary>
[JsonPropertyName("equip_slot")]
public string EquipSlot { get; init; } = "";
}
public sealed record ClassLevelEntry
{
[JsonPropertyName("level")]
public int Level { get; init; } = 1;
[JsonPropertyName("prof")]
public int ProficiencyBonus { get; init; } = 2;
/// <summary>Feature ids unlocked at this level. Resolves into <see cref="ClassDef.FeatureDefinitions"/>.</summary>
[JsonPropertyName("features")]
public string[] Features { get; init; } = Array.Empty<string>();
}
public sealed record ClassFeatureDef
{
[JsonPropertyName("name")]
public string Name { get; init; } = "";
[JsonPropertyName("description")]
public string Description { get; init; } = "";
/// <summary>"passive", "active", "choice", "bonus_action", "reaction", "stub".</summary>
[JsonPropertyName("kind")]
public string Kind { get; init; } = "passive";
[JsonPropertyName("uses_per_short_rest")]
public int? UsesPerShortRest { get; init; }
[JsonPropertyName("uses_per_long_rest")]
public int? UsesPerLongRest { get; init; }
/// <summary>For "choice" features: the available pick ids.</summary>
[JsonPropertyName("options")]
public string[]? Options { get; init; }
}
@@ -0,0 +1,35 @@
namespace Theriapolis.Core.Data;
/// <summary>
/// Pre-loaded content lookup tables. Constructing one calls every loader
/// exactly once and indexes results by id, so subsequent
/// <c>resolver.Clades["canidae"]</c> lookups are O(1).
///
/// Used by character creation, save/load (id → def resolution), and Phase 5 M5
/// NPC instantiation. Shared across screens that need any combination of
/// these tables.
/// </summary>
public sealed class ContentResolver
{
public IReadOnlyDictionary<string, CladeDef> Clades { get; }
public IReadOnlyDictionary<string, SpeciesDef> Species { get; }
public IReadOnlyDictionary<string, ClassDef> Classes { get; }
public IReadOnlyDictionary<string, SubclassDef> Subclasses { get; }
public IReadOnlyDictionary<string, BackgroundDef> Backgrounds { get; }
public IReadOnlyDictionary<string, ItemDef> Items { get; }
public NpcTemplateContent Npcs { get; }
public ContentResolver(ContentLoader loader)
{
var clades = loader.LoadClades();
Clades = clades.ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase);
Species = loader.LoadSpecies(clades).ToDictionary(s => s.Id, StringComparer.OrdinalIgnoreCase);
var classes = loader.LoadClasses();
Classes = classes.ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase);
Subclasses = loader.LoadSubclasses(classes).ToDictionary(s => s.Id, StringComparer.OrdinalIgnoreCase);
Backgrounds = loader.LoadBackgrounds().ToDictionary(b => b.Id, StringComparer.OrdinalIgnoreCase);
var items = loader.LoadItems();
Items = items.ToDictionary(i => i.Id, StringComparer.OrdinalIgnoreCase);
Npcs = loader.LoadNpcTemplates(items);
}
}
@@ -0,0 +1,38 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Immutable species (subrace-equivalent) record loaded from species.json.
/// Refines the parent <see cref="CladeDef"/>: adds a body size, additional
/// ability mods, species-specific traits, and species-specific detriments.
/// </summary>
public sealed record SpeciesDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
[JsonPropertyName("clade_id")]
public string CladeId { get; init; } = "";
[JsonPropertyName("name")]
public string Name { get; init; } = "";
/// <summary>Body size category, snake_case (small / medium / medium_large / large).</summary>
[JsonPropertyName("size")]
public string Size { get; init; } = "medium";
/// <summary>Additional ability mods on top of the clade's mods.</summary>
[JsonPropertyName("ability_mods")]
public Dictionary<string, int> AbilityMods { get; init; } = new();
/// <summary>Base movement speed in feet per turn (5 ft. = 1 tactical tile per d20 standard).</summary>
[JsonPropertyName("base_speed_ft")]
public int BaseSpeedFt { get; init; } = 30;
[JsonPropertyName("traits")]
public TraitDef[] Traits { get; init; } = Array.Empty<TraitDef>();
[JsonPropertyName("detriments")]
public TraitDef[] Detriments { get; init; } = Array.Empty<TraitDef>();
}
@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace Theriapolis.Core.Data;
/// <summary>
/// Trait or detriment entry shared by clades, species, and class features.
/// Phase 5 mostly stores these as descriptive text — only a handful have
/// real runtime mechanics (level-1 combat-touching features). The rest
/// surface as flavor in tooltips and the character sheet UI.
/// </summary>
public sealed record TraitDef
{
[JsonPropertyName("id")]
public string Id { get; init; } = "";
[JsonPropertyName("name")]
public string Name { get; init; } = "";
[JsonPropertyName("description")]
public string Description { get; init; } = "";
}
@@ -0,0 +1,87 @@
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>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;
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).
/// </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);
}
}
@@ -0,0 +1,239 @@
using Theriapolis.Core.Data;
using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
namespace Theriapolis.Core.Rules.Character;
/// <summary>
/// Fluent builder for a level-1 <see cref="Character"/>. Used by both the
/// in-game character-creation screen and the headless <c>character-roll</c>
/// Tools command, plus the M2 test suite.
///
/// Pattern: set inputs (clade, species, class, background, base scores,
/// chosen skills, name), then call <see cref="Build"/>. <see cref="Validate"/>
/// returns the first error string when any required input is missing or
/// inconsistent — <see cref="Build"/> calls Validate and throws on failure.
/// </summary>
public sealed class CharacterBuilder
{
public CladeDef? Clade { get; set; }
public SpeciesDef? Species { get; set; }
public ClassDef? ClassDef { get; set; }
public BackgroundDef? Background { get; set; }
/// <summary>Pre-clade-mod base scores (e.g. Standard Array assignment or 4d6 roll outcome).</summary>
public AbilityScores BaseAbilities { get; set; } = new(10, 10, 10, 10, 10, 10);
/// <summary>Class-skill picks. Background skills are added automatically by Build().</summary>
public HashSet<SkillId> ChosenClassSkills { get; } = new();
public string Name { get; set; } = "Wanderer";
// ── Builder fluent helpers ──────────────────────────────────────────
public CharacterBuilder WithClade(CladeDef c) { Clade = c; return this; }
public CharacterBuilder WithSpecies(SpeciesDef s) { Species = s; return this; }
public CharacterBuilder WithClass(ClassDef c) { ClassDef = c; return this; }
public CharacterBuilder WithBackground(BackgroundDef b) { Background = b; return this; }
public CharacterBuilder WithAbilities(AbilityScores a) { BaseAbilities = a; return this; }
public CharacterBuilder WithName(string name) { Name = name ?? "Wanderer"; return this; }
public CharacterBuilder ChooseSkill(SkillId s)
{
ChosenClassSkills.Add(s);
return this;
}
// ── Validation ──────────────────────────────────────────────────────
public bool Validate(out string error)
{
error = "";
if (Clade is null) { error = "Clade not selected."; return false; }
if (Species is null) { error = "Species not selected."; return false; }
if (ClassDef is null) { error = "Class not selected."; return false; }
if (Background is null) { error = "Background not selected."; return false; }
if (!string.Equals(Species.CladeId, Clade.Id, StringComparison.OrdinalIgnoreCase))
{
error = $"Species '{Species.Id}' belongs to clade '{Species.CladeId}', not '{Clade.Id}'.";
return false;
}
// Validate every chosen class skill is in the class's offered list.
foreach (var s in ChosenClassSkills)
{
string raw = SkillToJsonName(s);
bool listed = false;
foreach (var opt in ClassDef.SkillOptions)
if (string.Equals(opt, raw, StringComparison.OrdinalIgnoreCase))
{
listed = true;
break;
}
if (!listed)
{
error = $"Class '{ClassDef.Id}' does not offer skill '{raw}'.";
return false;
}
}
if (ChosenClassSkills.Count != ClassDef.SkillsChoose)
{
error = $"Class '{ClassDef.Id}' requires {ClassDef.SkillsChoose} skill picks, got {ChosenClassSkills.Count}.";
return false;
}
return true;
}
// ── Build ───────────────────────────────────────────────────────────
public Character Build(IReadOnlyDictionary<string, ItemDef>? itemsForStartingKit = null)
{
if (!Validate(out string error))
throw new InvalidOperationException($"Cannot build character: {error}");
// Apply clade + species ability mods to the base scores.
var clade = Clade!;
var species = Species!;
var classD = ClassDef!;
var bgD = Background!;
var modded = BaseAbilities;
modded = ApplyMods(modded, clade.AbilityMods);
modded = ApplyMods(modded, species.AbilityMods);
var c = new Character(clade, species, classD, bgD, modded)
{
Level = 1,
Xp = 0,
};
// Skills: class-chosen + background freebies (deduplicated).
foreach (var s in ChosenClassSkills) c.SkillProficiencies.Add(s);
foreach (var raw in bgD.SkillProficiencies)
{
try { c.SkillProficiencies.Add(SkillIdExtensions.FromJson(raw)); }
catch (ArgumentException) { /* unknown skill names ignored — content bug, but don't crash creation */ }
}
// HP: HitDie + CON modifier at level 1.
c.MaxHp = c.ComputeMaxHpFromScratch();
c.CurrentHp = c.MaxHp;
// Optional starting kit. The caller passes the loaded item table
// (typically <see cref="Data.ContentResolver.Items"/>); if null, the
// character starts with an empty inventory (existing test behaviour).
if (itemsForStartingKit is not null)
ApplyStartingKit(c, itemsForStartingKit);
return c;
}
/// <summary>
/// Adds every entry from <see cref="ClassDef.StartingKit"/> to the
/// character's inventory and auto-equips entries flagged for it. Logs and
/// continues on missing items / unknown slots — content bugs should fail
/// loud at content-validate time, not crash character creation.
/// </summary>
public static void ApplyStartingKit(Character c, IReadOnlyDictionary<string, ItemDef> items)
{
foreach (var entry in c.ClassDef.StartingKit)
{
if (!items.TryGetValue(entry.ItemId, out var def))
continue; // unknown item id — skip silently (caught by ContentValidate)
var inst = c.Inventory.Add(def, Math.Max(1, entry.Qty));
if (!entry.AutoEquip || string.IsNullOrEmpty(entry.EquipSlot))
continue;
var slot = EquipSlotExtensions.FromJson(entry.EquipSlot);
if (slot is null) continue;
// Best-effort equip; ignore the error string here. If a structural
// conflict occurs (two-handed in main when off-hand pre-occupied),
// the item stays in the bag rather than blocking creation.
c.Inventory.TryEquip(inst, slot.Value, out _);
}
}
private static AbilityScores ApplyMods(AbilityScores a, IReadOnlyDictionary<string, int> mods)
{
if (mods is null || mods.Count == 0) return a;
var dict = new Dictionary<AbilityId, int>();
foreach (var kv in mods)
{
if (TryParseAbility(kv.Key, out var id))
dict[id] = (dict.TryGetValue(id, out var existing) ? existing : 0) + kv.Value;
}
return a.Plus(dict);
}
private static bool TryParseAbility(string raw, out AbilityId id)
{
switch (raw.ToUpperInvariant())
{
case "STR": id = AbilityId.STR; return true;
case "DEX": id = AbilityId.DEX; return true;
case "CON": id = AbilityId.CON; return true;
case "INT": id = AbilityId.INT; return true;
case "WIS": id = AbilityId.WIS; return true;
case "CHA": id = AbilityId.CHA; return true;
default: id = AbilityId.STR; return false;
}
}
private static string SkillToJsonName(SkillId s) => s switch
{
SkillId.Acrobatics => "acrobatics",
SkillId.AnimalHandling => "animal_handling",
SkillId.Arcana => "arcana",
SkillId.Athletics => "athletics",
SkillId.Deception => "deception",
SkillId.History => "history",
SkillId.Insight => "insight",
SkillId.Intimidation => "intimidation",
SkillId.Investigation => "investigation",
SkillId.Medicine => "medicine",
SkillId.Nature => "nature",
SkillId.Perception => "perception",
SkillId.Performance => "performance",
SkillId.Persuasion => "persuasion",
SkillId.Religion => "religion",
SkillId.SleightOfHand => "sleight_of_hand",
SkillId.Stealth => "stealth",
SkillId.Survival => "survival",
_ => s.ToString().ToLowerInvariant(),
};
// ── Stat-rolling ────────────────────────────────────────────────────
/// <summary>
/// Roll 4d6-drop-lowest six times, returning a fresh <see cref="AbilityScores"/>
/// in (STR, DEX, CON, INT, WIS, CHA) order. Player assigns afterward.
///
/// Seed:
/// <c>worldSeed ^ C.RNG_STAT_ROLL ^ msSinceGameStart</c>
/// where <paramref name="msSinceGameStart"/> is wall-clock ms since process
/// launch in production, or a fixed test override for reproducibility.
/// </summary>
public static AbilityScores RollAbilityScores(ulong worldSeed, ulong msSinceGameStart)
{
var rng = SeededRng.ForSubsystem(worldSeed, C.RNG_STAT_ROLL ^ msSinceGameStart);
int[] r = new int[6];
for (int i = 0; i < 6; i++) r[i] = Roll4d6DropLowest(rng);
return new AbilityScores(r[0], r[1], r[2], r[3], r[4], r[5]);
}
/// <summary>4d6, drop the lowest; returns 3..18.</summary>
public static int Roll4d6DropLowest(SeededRng rng)
{
int d1 = (int)(rng.NextUInt64() % 6) + 1;
int d2 = (int)(rng.NextUInt64() % 6) + 1;
int d3 = (int)(rng.NextUInt64() % 6) + 1;
int d4 = (int)(rng.NextUInt64() % 6) + 1;
int low = Math.Min(d1, Math.Min(d2, Math.Min(d3, d4)));
return d1 + d2 + d3 + d4 - low;
}
}
@@ -0,0 +1,88 @@
namespace Theriapolis.Core.Rules.Stats;
/// <summary>
/// The six d20-adjacent ability scores. Score range is 1..30; level-1
/// characters typically end up in 8..18 after clade/species mods.
/// </summary>
public readonly struct AbilityScores : IEquatable<AbilityScores>
{
public readonly byte STR;
public readonly byte DEX;
public readonly byte CON;
public readonly byte INT;
public readonly byte WIS;
public readonly byte CHA;
public AbilityScores(int str, int dex, int con, int @int, int wis, int cha)
{
STR = ClampScore(str);
DEX = ClampScore(dex);
CON = ClampScore(con);
INT = ClampScore(@int);
WIS = ClampScore(wis);
CHA = ClampScore(cha);
}
/// <summary>Standard d20 ability modifier: floor((score - 10) / 2).</summary>
public static int Mod(int score)
{
// C# integer division truncates toward zero; for negatives we need
// floor toward -infinity to match d20 behaviour (score 9 → -1 not 0).
int diff = score - 10;
return diff >= 0 ? diff / 2 : (diff - 1) / 2;
}
public int ModFor(AbilityId id) => Mod(Get(id));
public byte Get(AbilityId id) => id switch
{
AbilityId.STR => STR,
AbilityId.DEX => DEX,
AbilityId.CON => CON,
AbilityId.INT => INT,
AbilityId.WIS => WIS,
AbilityId.CHA => CHA,
_ => throw new ArgumentOutOfRangeException(nameof(id)),
};
/// <summary>Returns a new score block with <paramref name="id"/> replaced.</summary>
public AbilityScores With(AbilityId id, int newScore) => id switch
{
AbilityId.STR => new AbilityScores(newScore, DEX, CON, INT, WIS, CHA),
AbilityId.DEX => new AbilityScores(STR, newScore, CON, INT, WIS, CHA),
AbilityId.CON => new AbilityScores(STR, DEX, newScore, INT, WIS, CHA),
AbilityId.INT => new AbilityScores(STR, DEX, CON, newScore, WIS, CHA),
AbilityId.WIS => new AbilityScores(STR, DEX, CON, INT, newScore, CHA),
AbilityId.CHA => new AbilityScores(STR, DEX, CON, INT, WIS, newScore),
_ => throw new ArgumentOutOfRangeException(nameof(id)),
};
/// <summary>Returns a new block with each ability incremented by the supplied dictionary.</summary>
public AbilityScores Plus(IReadOnlyDictionary<AbilityId, int> mods)
{
var s = this;
foreach (var kv in mods) s = s.With(kv.Key, s.Get(kv.Key) + kv.Value);
return s;
}
/// <summary>The standard array, in descending order: 15, 14, 13, 12, 10, 8.</summary>
public static int[] StandardArray => new[] { 15, 14, 13, 12, 10, 8 };
private static byte ClampScore(int v) => (byte)Math.Clamp(v, 1, 30);
public bool Equals(AbilityScores o) =>
STR == o.STR && DEX == o.DEX && CON == o.CON && INT == o.INT && WIS == o.WIS && CHA == o.CHA;
public override bool Equals(object? o) => o is AbilityScores a && Equals(a);
public override int GetHashCode() => HashCode.Combine(STR, DEX, CON, INT, WIS, CHA);
public override string ToString() => $"STR {STR} DEX {DEX} CON {CON} INT {INT} WIS {WIS} CHA {CHA}";
}
public enum AbilityId : byte
{
STR = 0,
DEX = 1,
CON = 2,
INT = 3,
WIS = 4,
CHA = 5,
}
@@ -0,0 +1,66 @@
namespace Theriapolis.Core.Rules.Stats;
/// <summary>
/// Body-size category from clades.md. Determines tactical-tile footprint,
/// reach, equipment fit, and grappling rules.
/// </summary>
public enum SizeCategory : byte
{
Tiny = 0, // reserved; no Phase 5 species uses this
Small = 1, // rabbit-folk, housecat-folk, ferret-folk
Medium = 2, // fox-folk, deer-folk, ram-folk, leopard-folk, badger-folk, hare-folk
MediumLarge = 3, // wolf-folk, elk-folk, lion-folk, wolverine-folk, coyote-folk
Large = 4, // brown bear-folk, polar bear-folk, moose-folk, bull-folk, bison-folk
Huge = 5, // reserved; no Phase 5 species uses this
}
public static class SizeExtensions
{
/// <summary>Tactical-tile footprint per side (1 = 1×1, 2 = 2×2).</summary>
public static int FootprintTiles(this SizeCategory s) => s switch
{
SizeCategory.Tiny => 1,
SizeCategory.Small => 1,
SizeCategory.Medium => 1,
SizeCategory.MediumLarge => 1, // counts as Large for grappling/carrying only
SizeCategory.Large => 2,
SizeCategory.Huge => 3,
_ => throw new ArgumentOutOfRangeException(nameof(s)),
};
/// <summary>Default melee reach in tactical tiles (weapon-modifiable).</summary>
public static int DefaultReachTiles(this SizeCategory s) => s switch
{
SizeCategory.Tiny => 1,
SizeCategory.Small => 1,
SizeCategory.Medium => 1,
SizeCategory.MediumLarge => 1,
SizeCategory.Large => 2,
SizeCategory.Huge => 3,
_ => throw new ArgumentOutOfRangeException(nameof(s)),
};
/// <summary>Carrying-capacity multiplier per equipment.md (Small ½×, Large 2×).</summary>
public static float CarryCapacityMult(this SizeCategory s) => s switch
{
SizeCategory.Tiny => 0.25f,
SizeCategory.Small => 0.5f,
SizeCategory.Medium => 1.0f,
SizeCategory.MediumLarge => 1.0f, // Medium frame, Large for grappling
SizeCategory.Large => 2.0f,
SizeCategory.Huge => 4.0f,
_ => throw new ArgumentOutOfRangeException(nameof(s)),
};
/// <summary>Parses a snake_case JSON value (e.g. "medium_large") into a SizeCategory.</summary>
public static SizeCategory FromJson(string? raw) => raw switch
{
"tiny" => SizeCategory.Tiny,
"small" => SizeCategory.Small,
"medium" => SizeCategory.Medium,
"medium_large" => SizeCategory.MediumLarge,
"large" => SizeCategory.Large,
"huge" => SizeCategory.Huge,
_ => throw new ArgumentException($"Unknown size category: '{raw}'"),
};
}
@@ -0,0 +1,77 @@
namespace Theriapolis.Core.Rules.Stats;
/// <summary>
/// Standard d20-adjacent skill list. Each skill is backed by a single
/// ability — see <see cref="SkillAbility"/>.
/// </summary>
public enum SkillId : byte
{
Acrobatics = 0,
AnimalHandling = 1,
Arcana = 2, // Theriapolis: "Advanced Engineering"
Athletics = 3,
Deception = 4,
History = 5,
Insight = 6,
Intimidation = 7,
Investigation = 8,
Medicine = 9,
Nature = 10,
Perception = 11,
Performance = 12,
Persuasion = 13,
Religion = 14, // Theriapolis: Covenant lore
SleightOfHand = 15,
Stealth = 16,
Survival = 17,
}
public static class SkillIdExtensions
{
public static AbilityId Ability(this SkillId s) => s switch
{
SkillId.Acrobatics => AbilityId.DEX,
SkillId.AnimalHandling => AbilityId.WIS,
SkillId.Arcana => AbilityId.INT,
SkillId.Athletics => AbilityId.STR,
SkillId.Deception => AbilityId.CHA,
SkillId.History => AbilityId.INT,
SkillId.Insight => AbilityId.WIS,
SkillId.Intimidation => AbilityId.CHA,
SkillId.Investigation => AbilityId.INT,
SkillId.Medicine => AbilityId.WIS,
SkillId.Nature => AbilityId.INT,
SkillId.Perception => AbilityId.WIS,
SkillId.Performance => AbilityId.CHA,
SkillId.Persuasion => AbilityId.CHA,
SkillId.Religion => AbilityId.INT,
SkillId.SleightOfHand => AbilityId.DEX,
SkillId.Stealth => AbilityId.DEX,
SkillId.Survival => AbilityId.WIS,
_ => throw new ArgumentOutOfRangeException(nameof(s)),
};
/// <summary>Parses a snake_case JSON value (e.g. "animal_handling") into a SkillId.</summary>
public static SkillId FromJson(string raw) => raw.ToLowerInvariant() switch
{
"acrobatics" => SkillId.Acrobatics,
"animal_handling" => SkillId.AnimalHandling,
"arcana" => SkillId.Arcana,
"athletics" => SkillId.Athletics,
"deception" => SkillId.Deception,
"history" => SkillId.History,
"insight" => SkillId.Insight,
"intimidation" => SkillId.Intimidation,
"investigation" => SkillId.Investigation,
"medicine" => SkillId.Medicine,
"nature" => SkillId.Nature,
"perception" => SkillId.Perception,
"performance" => SkillId.Performance,
"persuasion" => SkillId.Persuasion,
"religion" => SkillId.Religion,
"sleight_of_hand" => SkillId.SleightOfHand,
"stealth" => SkillId.Stealth,
"survival" => SkillId.Survival,
_ => throw new ArgumentException($"Unknown skill: '{raw}'"),
};
}