Files
TheriapolisV3/Theriapolis.Core/Rules/Character/CharacterBuilder.cs
T
Christopher Wiebe f7cadaeb68 M6.19: Confirm & Begin → CharacterBuilder handoff + hybrid pick plumbing
The wizard now produces a real runtime Character instead of just
persisting the draft Resource. New Theriapolis.Godot/UI/CharacterAssembler
bridges CharacterDraft → CharacterBuilder, picking the purebred Build
or hybrid TryBuildHybrid path, threading clade/species/class/background
lookups, ability scores, skill picks, dominant-parent, subclass id,
and the items table for the starting kit. The captured
PlayerCharacterState writes to user://character.json (resumability
before the M7 save format lands) and the live Character is held in
CharacterAssembler.LastBuilt for future PlayScreen pickup.

StepReview.OnConfirmPressed surfaces build errors on the status label
instead of crashing, logs HP/hybrid/skill totals on success, and
keeps emitting the existing CharacterConfirmed signal.

Hybrid lineage bonuses now match the wizard's preview math.
CharacterBuilder gains HybridSireChosenAbility / HybridDamChosenAbility;
TryBuildHybrid replaces the old "apply both clades' full mods + both
species mods" blend with "apply each parent's chosen mod only" —
species mods don't apply for hybrids per project decision. Picks
stack additively when both parents land on the same ability
(canidae +1 CON × ursidae +2 CON → +3 CON). Empty pick = no bonus
(defensive fallback for headless builds). HybridCharacterTests'
BlendsAbilityMods rewritten for the new rule, plus two new tests
for stacking and empty-pick fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 21:26:16 -07:00

482 lines
21 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;
/// <summary>
/// Sire's chosen ability key ("STR", "DEX", "CON", "INT", "WIS", "CHA").
/// Hybrid PCs take ONE ability mod from each parent clade — this records
/// which one the player chose from sire's clade. Empty string means no
/// bonus from this side (graceful default for headless builds and old
/// content). The resulting mod value is whatever the sire clade grants
/// for that ability (e.g. picking CON from canidae yields +1, picking
/// CON from ursidae yields +2). Stacks additively with the dam pick if
/// both happen to land on the same ability.
/// </summary>
public string HybridSireChosenAbility { get; set; } = "";
/// <summary>Dam's chosen ability key — see <see cref="HybridSireChosenAbility"/>.</summary>
public string HybridDamChosenAbility { get; set; } = "";
// ── 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: take ONE chosen ability mod from each parent
// clade — the picks are recorded on HybridSireChosenAbility /
// HybridDamChosenAbility (set by the wizard's StepClade lineage
// bonus picker, or left empty in headless tests for no bonus).
// Picks stack additively if both parents land on the same ability.
// Species mods don't apply for hybrids per project decision; the
// 2-mod ceiling intentionally caps hybrid bonuses below purebred's.
var modded = BaseAbilities;
modded = ApplyOneMod(modded, HybridSireChosenAbility, HybridSireClade!.AbilityMods);
modded = ApplyOneMod(modded, HybridDamChosenAbility, HybridDamClade!.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);
}
/// <summary>
/// Apply a single mod sourced from <paramref name="modsSource"/> at key
/// <paramref name="chosenAbility"/>. No-op when the key is empty (player
/// hasn't chosen) or absent from the source (defensive — the wizard's
/// picker should only offer abilities present in the clade's mods, but
/// we don't want a typo'd save to crash character finalize).
/// </summary>
private static AbilityScores ApplyOneMod(
AbilityScores a, string chosenAbility, IReadOnlyDictionary<string, int> modsSource)
{
if (string.IsNullOrEmpty(chosenAbility)) return a;
if (!TryParseAbility(chosenAbility, out var id)) return a;
if (modsSource is null || !modsSource.TryGetValue(chosenAbility, out int value) || value == 0)
return a;
var dict = new Dictionary<AbilityId, int> { [id] = 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",
SkillId.Brawl => "brawl",
SkillId.BuildRead => "build_read",
SkillId.Driving => "driving",
SkillId.Endurance => "endurance",
SkillId.Force => "force",
SkillId.Fortitude => "fortitude",
SkillId.Hardiness => "hardiness",
SkillId.Haulage => "haulage",
SkillId.LungCraft => "lung_craft",
SkillId.Marksmanship => "marksmanship",
SkillId.PainTolerance => "pain_tolerance",
SkillId.ScentSpeak => "scent_speak",
_ => 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;
}
}