using System.Collections.Generic; using System.Linq; using Theriapolis.Core.Data; namespace Theriapolis.GodotHost.UI; /// /// Final ability score math — base assignment + clade and species mods. /// Used by StepStats and Aside to render the score breakdown with a /// "+N from Canidae · +2 from Wolf" hover popover. /// /// For hybrids the lineage contribution comes from BOTH parent clades /// (each tagged with its lineage in the source label) and the dominant /// species. Core's CharacterBuilder.TryBuildHybrid is the authority on /// the final mechanical rules at character-finalize time; this is a /// preview-display helper. /// public static class AbilityCalc { public readonly record struct ModSource(string Label, int Value); public static List Sources(string ability, CharacterDraft draft) { var list = new List(); if (draft.IsHybrid) { // Hybrids: take ONE ability modifier from each parent clade. // Picks stack if they happen to land on the same ability — the // original "no stack, take +1 + free elsewhere" rule was // dropped per project decision. Species mods don't apply. if (draft.SireChosenAbility == ability) AddCladeChoice(list, ability, CodexContent.Clade(draft.SireCladeId), " (sire)"); if (draft.DamChosenAbility == ability) AddCladeChoice(list, ability, CodexContent.Clade(draft.DamCladeId), " (dam)"); } else { AddCladeSource(list, ability, CodexContent.Clade(draft.CladeId), ""); var sp = CodexContent.SpeciesById(draft.EffectiveSpeciesId); if (sp is not null) AddDictSource(list, ability, sp.Name, sp.AbilityMods); } return list; } /// The chosen-mod path: one mod from a parent clade. The value /// is the clade's actual mod for that ability (so picking CON from /// canidae yields +1 while picking CON from ursidae yields +2). private static void AddCladeChoice(List list, string ability, CladeDef? clade, string suffix) { if (clade is null) return; if (clade.AbilityMods.TryGetValue(ability, out int v) && v != 0) list.Add(new ModSource(clade.Name + suffix, v)); } public static int TotalBonus(string ability, CharacterDraft draft) => Sources(ability, draft).Sum(s => s.Value); public static int BaseScore(string ability, CharacterDraft draft) => draft.StatAssign.ContainsKey(ability) ? (int)draft.StatAssign[ability] : 0; public static int FinalScore(string ability, CharacterDraft draft) => BaseScore(ability, draft) + TotalBonus(ability, draft); public static int D20Modifier(int score) => (int)System.Math.Floor((score - 10) / 2.0); public static string FormatSigned(int n) => n >= 0 ? $"+{n}" : n.ToString(); /// "+1 from Canidae · +2 from Wolf" — empty when no sources. public static string FormatBreakdown(IEnumerable sources) => string.Join(" · ", sources.Select(s => $"{FormatSigned(s.Value)} from {s.Label}")); private static void AddCladeSource(List list, string ability, CladeDef? clade, string suffix) { if (clade is null) return; AddDictSource(list, ability, clade.Name + suffix, clade.AbilityMods); } private static void AddDictSource(List list, string ability, string sourceName, Dictionary mods) { if (mods.TryGetValue(ability, out int v) && v != 0) list.Add(new ModSource(sourceName, v)); } }