using Theriapolis.Core.Data; using Theriapolis.Core.Items; using Theriapolis.Core.Rules.Stats; using Theriapolis.Core.Util; namespace Theriapolis.Core.Rules.Character; /// /// Fluent builder for a level-1 . Used by both the /// in-game character-creation screen and the headless character-roll /// Tools command, plus the M2 test suite. /// /// Pattern: set inputs (clade, species, class, background, base scores, /// chosen skills, name), then call . /// returns the first error string when any required input is missing or /// inconsistent — calls Validate and throws on failure. /// public sealed class CharacterBuilder { public CladeDef? Clade { get; set; } public SpeciesDef? Species { get; set; } public ClassDef? ClassDef { get; set; } public BackgroundDef? Background { get; set; } /// Pre-clade-mod base scores (e.g. Standard Array assignment or 4d6 roll outcome). public AbilityScores BaseAbilities { get; set; } = new(10, 10, 10, 10, 10, 10); /// Class-skill picks. Background skills are added automatically by Build(). public HashSet ChosenClassSkills { get; } = new(); public string Name { get; set; } = "Wanderer"; /// /// 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. /// public string FightingStyle { get; set; } = ""; // ── Phase 6.5 M4: hybrid origin ───────────────────────────────────── /// /// When true, is the canonical build path /// and / are the *dominant* /// parent's lineage; / /// populate the secondary parent. Defaults /// to false (purebred path); the character creation screen flips this /// when the player ticks the Hybrid checkbox. /// public bool IsHybridOrigin { get; set; } = false; /// Sire clade for hybrid origin path (paternal lineage). public CladeDef? HybridSireClade { get; set; } /// Sire species for hybrid origin path. public SpeciesDef? HybridSireSpecies { get; set; } /// Dam clade for hybrid origin path (maternal lineage). public CladeDef? HybridDamClade { get; set; } /// Dam species for hybrid origin path. public SpeciesDef? HybridDamSpecies { get; set; } /// /// Which parent's expression dominates. Drives Passing presentation /// (the PC scent-reads as this lineage's clade). Default is Sire. /// 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? 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 ); 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 ───────────────────────────────── /// /// 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. /// 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; } /// /// Build a hybrid character from the configured sire + dam pair. The /// builder resolves the dominant parent's clade + species as the /// primary / /// (so existing systems that key off these fields keep working), and /// records the full sire+dam genealogy in . /// /// Ability mod blending follows clades.md 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.) /// public bool TryBuildHybrid( IReadOnlyDictionary? 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; } /// /// Adds every entry from 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. /// public static void ApplyStartingKit(Character c, IReadOnlyDictionary 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 mods) { if (mods is null || mods.Count == 0) return a; var dict = new Dictionary(); 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", 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 ──────────────────────────────────────────────────── /// /// Roll 4d6-drop-lowest six times, returning a fresh /// in (STR, DEX, CON, INT, WIS, CHA) order. Player assigns afterward. /// /// Seed: /// worldSeed ^ C.RNG_STAT_ROLL ^ msSinceGameStart /// where is wall-clock ms since process /// launch in production, or a fixed test override for reproducibility. /// 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]); } /// 4d6, drop the lowest; returns 3..18. 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; } }