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:
@@ -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);
|
||||
}
|
||||
}
|
||||
+239
@@ -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}'"),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user