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 ────────────────────────────────────────