From f7cadaeb68f6cbf5fe1966d62e86db01b78b7bab Mon Sep 17 00:00:00 2001 From: Christopher Wiebe Date: Sat, 9 May 2026 21:26:16 -0700 Subject: [PATCH] =?UTF-8?q?M6.19:=20Confirm=20&=20Begin=20=E2=86=92=20Char?= =?UTF-8?q?acterBuilder=20handoff=20+=20hybrid=20pick=20plumbing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Rules/Character/CharacterBuilder.cs | 52 ++++-- Theriapolis.Godot/Scenes/Steps/StepReview.cs | 26 ++- Theriapolis.Godot/UI/CharacterAssembler.cs | 168 ++++++++++++++++++ .../Rules/HybridCharacterTests.cs | 61 +++++-- 4 files changed, 276 insertions(+), 31 deletions(-) create mode 100644 Theriapolis.Godot/UI/CharacterAssembler.cs 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 ────────────────────────────────────────