diff --git a/Theriapolis.Core/Rules/Character/CharacterBuilder.cs b/Theriapolis.Core/Rules/Character/CharacterBuilder.cs index 6852e4c..76d2e15 100644 --- a/Theriapolis.Core/Rules/Character/CharacterBuilder.cs +++ b/Theriapolis.Core/Rules/Character/CharacterBuilder.cs @@ -65,6 +65,21 @@ public sealed class CharacterBuilder /// public ParentLineage HybridDominantParent { get; set; } = ParentLineage.Sire; + /// + /// 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. + /// + public string HybridSireChosenAbility { get; set; } = ""; + + /// Dam's chosen ability key — see . + public string HybridDamChosenAbility { get; set; } = ""; + // ── Builder fluent helpers ────────────────────────────────────────── public CharacterBuilder WithClade(CladeDef c) { Clade = c; return this; } @@ -282,17 +297,16 @@ public sealed class CharacterBuilder 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. + // 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 = ApplyMods(modded, HybridSireClade!.AbilityMods); - modded = ApplyMods(modded, HybridDamClade!.AbilityMods); - modded = ApplyMods(modded, HybridSireSpecies!.AbilityMods); - modded = ApplyMods(modded, HybridDamSpecies!.AbilityMods); + modded = ApplyOneMod(modded, HybridSireChosenAbility, HybridSireClade!.AbilityMods); + modded = ApplyOneMod(modded, HybridDamChosenAbility, HybridDamClade!.AbilityMods); var c = new Character(dominantClade, dominantSpecies, ClassDef, Background, modded) { @@ -367,6 +381,24 @@ public sealed class CharacterBuilder return a.Plus(dict); } + /// + /// Apply a single mod sourced from at key + /// . 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). + /// + private static AbilityScores ApplyOneMod( + AbilityScores a, string chosenAbility, IReadOnlyDictionary 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 { [id] = value }; + return a.Plus(dict); + } + private static bool TryParseAbility(string raw, out AbilityId id) { switch (raw.ToUpperInvariant()) diff --git a/Theriapolis.Godot/Scenes/Steps/StepReview.cs b/Theriapolis.Godot/Scenes/Steps/StepReview.cs index 00f48c4..894d6fc 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepReview.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepReview.cs @@ -127,15 +127,27 @@ public partial class StepReview : VBoxContainer, IStep { if (WizardValidation.FirstIncomplete(_draft) != -1) return; - // Persist the draft so a future load path can pick it up. - const string SavePath = "user://character.tres"; - var err = ResourceSaver.Save(_draft, SavePath); - if (err != Error.Ok) - GD.PushWarning($"[review] ResourceSaver.Save failed: {err}"); + // Persist the draft so a future load path can resume editing. + const string DraftPath = "user://character.tres"; + var saveErr = ResourceSaver.Save(_draft, DraftPath); + if (saveErr != Error.Ok) + GD.PushWarning($"[review] ResourceSaver.Save failed: {saveErr}"); else - GD.Print($"[review] Saved character draft to {SavePath}"); + GD.Print($"[review] Saved character draft to {DraftPath}"); - GD.Print($"[review] Confirmed: {Summarise(_draft)}"); + // The actual handoff: build the runtime Character via Core's + // CharacterBuilder. Failure here is a content/wiring bug, not a + // user error — the wizard's validation should have caught everything + // the builder rejects. Surface the message and stay on this step. + if (!CharacterAssembler.TryBuild(_draft, out var built, out string buildError)) + { + GD.PushError($"[review] Character build failed: {buildError}"); + _confirmStatus.Text = $"Could not finalize character — {buildError}"; + return; + } + + GD.Print($"[review] Confirmed: {Summarise(_draft)} → HP={built!.MaxHp}, " + + $"hybrid={built.Hybrid is not null}, skills={built.SkillProficiencies.Count}"); EmitSignal(SignalName.CharacterConfirmed, _draft); diff --git a/Theriapolis.Godot/UI/CharacterAssembler.cs b/Theriapolis.Godot/UI/CharacterAssembler.cs new file mode 100644 index 0000000..866700c --- /dev/null +++ b/Theriapolis.Godot/UI/CharacterAssembler.cs @@ -0,0 +1,168 @@ +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using Godot; +using Theriapolis.Core.Data; +using Theriapolis.Core.Items; +using Theriapolis.Core.Persistence; +using Theriapolis.Core.Rules.Character; +using Theriapolis.Core.Rules.Stats; +using Theriapolis.GodotHost.Platform; + +namespace Theriapolis.GodotHost.UI; + +/// +/// Bridges the wizard's (Godot Resource) into a +/// runtime via . Used by +/// StepReview's "Confirm & Begin" handoff. +/// +/// Holds the most-recently-built character in so a +/// future PlayScreen / world-load step can pick it up without re-running the +/// builder, and writes the captured to +/// user://character.json for cold-restart resumability before the M7 +/// save format lands. +/// +public static class CharacterAssembler +{ + /// The most recently confirmed character, or null if none. + public static Character? LastBuilt { get; private set; } + + /// Path used for the resumability dump. + public const string PersistedStatePath = "user://character.json"; + + private static IReadOnlyDictionary? _items; + + private static IReadOnlyDictionary Items() + { + if (_items is not null) return _items; + var loader = new Theriapolis.Core.Data.ContentLoader(ContentPaths.DataDir); + var arr = loader.LoadItems(); + var dict = new Dictionary(arr.Length); + foreach (var it in arr) dict[it.Id] = it; + _items = dict; + return _items; + } + + /// + /// Build the runtime from the draft. Returns false + /// with a populated when the draft is incomplete + /// or content lookups fail; on success holds + /// the built object and is updated. + /// + public static bool TryBuild(CharacterDraft draft, out Character? character, out string error) + { + character = null; + error = ""; + + var builder = new CharacterBuilder + { + BaseAbilities = ToAbilityScores(draft.StatAssign), + Name = string.IsNullOrWhiteSpace(draft.CharacterName) ? "Wanderer" : draft.CharacterName, + }; + + // Class + background lookups are shared by purebred and hybrid paths. + var classDef = CodexContent.Class(draft.ClassId); + if (classDef is null) { error = $"Unknown class id '{draft.ClassId}'."; return false; } + builder.ClassDef = classDef; + + var bgDef = CodexContent.Background(draft.BackgroundId); + if (bgDef is null) { error = $"Unknown background id '{draft.BackgroundId}'."; return false; } + builder.Background = bgDef; + + foreach (Variant v in draft.ChosenSkills) + { + string raw = (string)v; + try { builder.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } + catch (System.ArgumentException) { error = $"Unknown skill id '{raw}'."; return false; } + } + + Character built; + if (draft.IsHybrid) + { + builder.IsHybridOrigin = true; + + var sireClade = CodexContent.Clade(draft.SireCladeId); + var sireSp = CodexContent.SpeciesById(draft.SireSpeciesId); + var damClade = CodexContent.Clade(draft.DamCladeId); + var damSp = CodexContent.SpeciesById(draft.DamSpeciesId); + if (sireClade is null || sireSp is null || damClade is null || damSp is null) + { + error = "Hybrid lineage incomplete (sire/dam clade or species missing)."; + return false; + } + + builder.HybridSireClade = sireClade; + builder.HybridSireSpecies = sireSp; + builder.HybridDamClade = damClade; + builder.HybridDamSpecies = damSp; + builder.HybridDominantParent = + draft.DominantParent == "dam" ? ParentLineage.Dam : ParentLineage.Sire; + // Hybrid lineage-bonus picks: the wizard's StepClade picker + // records one chosen ability per parent. The builder applies + // exactly those (no full ability_mods spread, no species mods) + // so the built Character matches the Aside's preview math. + builder.HybridSireChosenAbility = draft.SireChosenAbility ?? ""; + builder.HybridDamChosenAbility = draft.DamChosenAbility ?? ""; + + if (!builder.TryBuildHybrid(Items(), out var hybridChar, out error)) return false; + built = hybridChar!; + } + else + { + var clade = CodexContent.Clade(draft.CladeId); + var sp = CodexContent.SpeciesById(draft.SpeciesId); + if (clade is null || sp is null) + { + error = "Lineage incomplete (clade or species missing)."; + return false; + } + builder.Clade = clade; + builder.Species = sp; + + try { built = builder.Build(Items()); } + catch (System.InvalidOperationException ex) { error = ex.Message; return false; } + } + + // Subclass selection happens at level 3 mechanically, but the wizard + // locks the choice in at L1 — stamp it onto the character so the L3 + // unlock has the player's pick already recorded. + if (!string.IsNullOrEmpty(draft.SubclassId)) built.SubclassId = draft.SubclassId; + + character = built; + LastBuilt = built; + PersistState(built); + return true; + } + + /// + /// Capture the live into a flat + /// and write it as JSON to + /// . Lets a future cold-load path + /// recover the confirmed character before the M7 save format lands. + /// + private static void PersistState(Character c) + { + try + { + var snap = CharacterCodec.Capture(c); + string globalPath = ProjectSettings.GlobalizePath(PersistedStatePath); + string? dir = Path.GetDirectoryName(globalPath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + string json = JsonSerializer.Serialize(snap, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(globalPath, json); + GD.Print($"[character] Persisted state to {PersistedStatePath}"); + } + catch (System.Exception ex) + { + GD.PushWarning($"[character] PersistState failed: {ex.Message}"); + } + } + + private static AbilityScores ToAbilityScores(Godot.Collections.Dictionary statAssign) + { + int Get(string key) => statAssign.ContainsKey(key) ? (int)statAssign[key] : 10; + return new AbilityScores( + Get("STR"), Get("DEX"), Get("CON"), + Get("INT"), Get("WIS"), Get("CHA")); + } +} diff --git a/Theriapolis.Tests/Rules/HybridCharacterTests.cs b/Theriapolis.Tests/Rules/HybridCharacterTests.cs index 81d3494..cac9966 100644 --- a/Theriapolis.Tests/Rules/HybridCharacterTests.cs +++ b/Theriapolis.Tests/Rules/HybridCharacterTests.cs @@ -93,27 +93,60 @@ public sealed class HybridCharacterTests } [Fact] - public void TryBuildHybrid_BlendsAbilityMods() + public void TryBuildHybrid_AppliesChosenAbilityFromEachParentClade() { - // Wolf-Folk Sire: - // canidae clade: +1 CON, +1 WIS - // wolf species: +1 STR - // × Rabbit-Folk Dam: - // leporidae clade: -1 STR, +2 DEX - // rabbit species: +1 WIS + // Hybrid PCs take ONE ability mod from each parent clade — the + // wizard's StepClade picker records the choices, and the builder + // applies exactly those. Species mods don't apply for hybrids. + // + // Wolf-Folk Sire (canidae: +1 CON, +1 WIS) — sire picks CON. + // Rabbit-Folk Dam (leporidae: -1 STR, +2 DEX) — dam picks DEX. // Base 10 across the board. var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "rabbit"); b.BaseAbilities = new AbilityScores(10, 10, 10, 10, 10, 10); + b.HybridSireChosenAbility = "CON"; + b.HybridDamChosenAbility = "DEX"; + bool ok = b.TryBuildHybrid(_content.Items, out var c, out _); + Assert.True(ok); + Assert.Equal(10, c!.Abilities.STR); // no -1 (dam didn't pick STR), no +1 (no species mods) + Assert.Equal(12, c.Abilities.DEX); // dam pick: +2 + Assert.Equal(11, c.Abilities.CON); // sire pick: +1 + Assert.Equal(10, c.Abilities.WIS); // no +1 (sire picked CON, not WIS) + } + + [Fact] + public void TryBuildHybrid_StacksWhenBothParentsPickSameAbility() + { + // Canidae gives +1 CON, ursidae gives +2 CON. If both parents pick + // CON, both bonuses apply additively. + var b = NewBuilderWithClassAndSkills(); + b.HybridSireClade = _content.Clades["canidae"]; + b.HybridSireSpecies = _content.Species["wolf"]; + b.HybridDamClade = _content.Clades["ursidae"]; + b.HybridDamSpecies = _content.Species["brown_bear"]; + b.BaseAbilities = new AbilityScores(10, 10, 10, 10, 10, 10); + b.HybridSireChosenAbility = "CON"; + b.HybridDamChosenAbility = "CON"; + bool ok = b.TryBuildHybrid(_content.Items, out var c, out _); + Assert.True(ok); + Assert.Equal(13, c!.Abilities.CON); // 10 + 1 (canid) + 2 (ursid) + } + + [Fact] + public void TryBuildHybrid_NoBonusWhenPickIsEmpty() + { + // Defensive: empty pick = no bonus from that side. Headless tests + // and old saves leave the field blank; the builder should not + // silently fall back to the old "apply everything" rule. + var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "rabbit"); + b.BaseAbilities = new AbilityScores(10, 10, 10, 10, 10, 10); + // No sire or dam pick set. bool ok = b.TryBuildHybrid(_content.Items, out var c, out _); Assert.True(ok); - // Net STR: 10 + 1 (wolf) - 1 (leporid) = 10. - // Net DEX: 10 + 2 (leporid) = 12. - // Net CON: 10 + 1 (canid) = 11. - // Net WIS: 10 + 1 (canid) + 1 (rabbit) = 12. Assert.Equal(10, c!.Abilities.STR); - Assert.Equal(12, c.Abilities.DEX); - Assert.Equal(11, c.Abilities.CON); - Assert.Equal(12, c.Abilities.WIS); + Assert.Equal(10, c.Abilities.DEX); + Assert.Equal(10, c.Abilities.CON); + Assert.Equal(10, c.Abilities.WIS); } // ── Cross-clade pairings smoke ────────────────────────────────────────