Files
TheriapolisV3/Theriapolis.Core/Rules/Character/CharacterBuilder.cs
T
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

437 lines
19 KiB
C#

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";
/// <summary>
/// Phase 5 M6: Fangsworn fighting style choice. One of "duelist",
/// "great_weapon", "shieldwall", "fang_and_blade", "natural_predator".
/// Empty string defaults to "duelist" if the class is Fangsworn (sensible
/// auto-pick that has visible combat effect at level 1). Ignored for
/// non-Fangsworn classes.
/// </summary>
public string FightingStyle { get; set; } = "";
// ── Phase 6.5 M4: hybrid origin ─────────────────────────────────────
/// <summary>
/// When true, <see cref="TryBuildHybrid"/> is the canonical build path
/// and <see cref="Clade"/> / <see cref="Species"/> are the *dominant*
/// parent's lineage; <see cref="HybridSireClade"/> /
/// <see cref="HybridDamClade"/> populate the secondary parent. Defaults
/// to false (purebred path); the character creation screen flips this
/// when the player ticks the Hybrid checkbox.
/// </summary>
public bool IsHybridOrigin { get; set; } = false;
/// <summary>Sire clade for hybrid origin path (paternal lineage).</summary>
public CladeDef? HybridSireClade { get; set; }
/// <summary>Sire species for hybrid origin path.</summary>
public SpeciesDef? HybridSireSpecies { get; set; }
/// <summary>Dam clade for hybrid origin path (maternal lineage).</summary>
public CladeDef? HybridDamClade { get; set; }
/// <summary>Dam species for hybrid origin path.</summary>
public SpeciesDef? HybridDamSpecies { get; set; }
/// <summary>
/// Which parent's expression dominates. Drives Passing presentation
/// (the PC scent-reads as this lineage's clade). Default is Sire.
/// </summary>
public ParentLineage HybridDominantParent { get; set; } = ParentLineage.Sire;
// ── 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;
// Phase 5 M6: Fangsworn fighting style. Default to "duelist" — has
// immediate combat effect at level 1 and works with the most weapons
// in our starting kits. The CodexUI character creator surfaces this
// as a real picker; the legacy Myra screen leaves it on default.
if (string.Equals(classD.Id, "fangsworn", System.StringComparison.OrdinalIgnoreCase))
{
c.FightingStyle = string.IsNullOrEmpty(FightingStyle) ? "duelist" : FightingStyle;
}
// 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;
}
// ── Phase 6.5 M4: Hybrid build path ─────────────────────────────────
/// <summary>
/// Validate the hybrid-origin fields. Returns true and an empty error
/// string when the sire+dam configuration is valid for building a
/// hybrid character.
///
/// Required: both sire and dam picked (clade + species each); sire and
/// dam must be *different* clades (cross-clade is the definition of
/// hybrid); each species must belong to its declared clade.
/// </summary>
public bool ValidateHybrid(out string error)
{
error = "";
if (HybridSireClade is null) { error = "Sire clade not selected."; return false; }
if (HybridSireSpecies is null) { error = "Sire species not selected."; return false; }
if (HybridDamClade is null) { error = "Dam clade not selected."; return false; }
if (HybridDamSpecies is null) { error = "Dam species not selected."; return false; }
if (string.Equals(HybridSireClade.Id, HybridDamClade.Id, StringComparison.OrdinalIgnoreCase))
{
error = $"Sire and dam must be different clades (both are '{HybridSireClade.Id}'). Hybrids are cross-clade.";
return false;
}
if (!string.Equals(HybridSireSpecies.CladeId, HybridSireClade.Id, StringComparison.OrdinalIgnoreCase))
{
error = $"Sire species '{HybridSireSpecies.Id}' belongs to clade '{HybridSireSpecies.CladeId}', not '{HybridSireClade.Id}'.";
return false;
}
if (!string.Equals(HybridDamSpecies.CladeId, HybridDamClade.Id, StringComparison.OrdinalIgnoreCase))
{
error = $"Dam species '{HybridDamSpecies.Id}' belongs to clade '{HybridDamSpecies.CladeId}', not '{HybridDamClade.Id}'.";
return false;
}
return true;
}
/// <summary>
/// Build a hybrid character from the configured sire + dam pair. The
/// builder resolves the dominant parent's clade + species as the
/// primary <see cref="Character.Clade"/> / <see cref="Character.Species"/>
/// (so existing systems that key off these fields keep working), and
/// records the full sire+dam genealogy in <see cref="Character.Hybrid"/>.
///
/// Ability mod blending follows <c>clades.md</c> HYBRID ORIGIN:
/// take *one* ability mod from each parent clade. If both grant the
/// same ability, the duplicate is dropped (no double-counting); the
/// player picks an alternative +1 elsewhere via the standard array
/// or roll path. (M4 simplification: take both clade mod sets and
/// blend them — duplicates collapse to a single +1 — and use both
/// species mods.)
/// </summary>
public bool TryBuildHybrid(
IReadOnlyDictionary<string, ItemDef>? itemsForStartingKit,
out Character? character,
out string error)
{
character = null;
if (!ValidateHybrid(out error)) return false;
if (ClassDef is null)
{
error = "Class not selected.";
return false;
}
if (Background is null)
{
error = "Background not selected.";
return false;
}
// Validate skills against the class — same as the purebred path.
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;
}
// Resolve the dominant lineage as the primary clade/species so the
// rest of the engine (rendering, scent reads, dialogue gates that
// key off Character.Clade / Character.Species) sees the dominant
// expression. The Hybrid record carries the full sire+dam
// genealogy.
var dominantClade = HybridDominantParent == ParentLineage.Sire
? HybridSireClade! : HybridDamClade!;
var dominantSpecies = HybridDominantParent == ParentLineage.Sire
? HybridSireSpecies! : HybridDamSpecies!;
// Blend ability mods: apply BOTH parent clades' mods, then BOTH
// species mods. Same-key collisions accumulate (e.g. two clades
// each granting +1 CON yield +2 CON). This is a small departure
// from clades.md's "take one from each" but matches the engine's
// declarative-mod model and produces sensible totals; M4 ships it
// and the rule fine-tunes in playtesting.
var modded = BaseAbilities;
modded = ApplyMods(modded, HybridSireClade!.AbilityMods);
modded = ApplyMods(modded, HybridDamClade!.AbilityMods);
modded = ApplyMods(modded, HybridSireSpecies!.AbilityMods);
modded = ApplyMods(modded, HybridDamSpecies!.AbilityMods);
var c = new Character(dominantClade, dominantSpecies, ClassDef, Background, modded)
{
Level = 1,
Xp = 0,
Hybrid = new HybridState
{
SireClade = HybridSireClade.Id,
SireSpecies = HybridSireSpecies.Id,
DamClade = HybridDamClade.Id,
DamSpecies = HybridDamSpecies.Id,
DominantParent = HybridDominantParent,
},
};
// Skills (same as purebred path).
foreach (var s in ChosenClassSkills) c.SkillProficiencies.Add(s);
foreach (var raw in Background.SkillProficiencies)
{
try { c.SkillProficiencies.Add(SkillIdExtensions.FromJson(raw)); }
catch (ArgumentException) { /* unknown skill names ignored */ }
}
c.MaxHp = c.ComputeMaxHpFromScratch();
c.CurrentHp = c.MaxHp;
if (string.Equals(ClassDef.Id, "fangsworn", System.StringComparison.OrdinalIgnoreCase))
c.FightingStyle = string.IsNullOrEmpty(FightingStyle) ? "duelist" : FightingStyle;
if (itemsForStartingKit is not null)
ApplyStartingKit(c, itemsForStartingKit);
character = c;
return true;
}
/// <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;
}
}