M6.19: Confirm & Begin → CharacterBuilder handoff + hybrid pick plumbing

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 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-05-09 21:26:16 -07:00
parent 97b49d4145
commit f7cadaeb68
4 changed files with 276 additions and 31 deletions
@@ -65,6 +65,21 @@ public sealed class CharacterBuilder
/// </summary> /// </summary>
public ParentLineage HybridDominantParent { get; set; } = ParentLineage.Sire; public ParentLineage HybridDominantParent { get; set; } = ParentLineage.Sire;
/// <summary>
/// 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.
/// </summary>
public string HybridSireChosenAbility { get; set; } = "";
/// <summary>Dam's chosen ability key — see <see cref="HybridSireChosenAbility"/>.</summary>
public string HybridDamChosenAbility { get; set; } = "";
// ── Builder fluent helpers ────────────────────────────────────────── // ── Builder fluent helpers ──────────────────────────────────────────
public CharacterBuilder WithClade(CladeDef c) { Clade = c; return this; } public CharacterBuilder WithClade(CladeDef c) { Clade = c; return this; }
@@ -282,17 +297,16 @@ public sealed class CharacterBuilder
var dominantSpecies = HybridDominantParent == ParentLineage.Sire var dominantSpecies = HybridDominantParent == ParentLineage.Sire
? HybridSireSpecies! : HybridDamSpecies!; ? HybridSireSpecies! : HybridDamSpecies!;
// Blend ability mods: apply BOTH parent clades' mods, then BOTH // Blend ability mods: take ONE chosen ability mod from each parent
// species mods. Same-key collisions accumulate (e.g. two clades // clade — the picks are recorded on HybridSireChosenAbility /
// each granting +1 CON yield +2 CON). This is a small departure // HybridDamChosenAbility (set by the wizard's StepClade lineage
// from clades.md's "take one from each" but matches the engine's // bonus picker, or left empty in headless tests for no bonus).
// declarative-mod model and produces sensible totals; M4 ships it // Picks stack additively if both parents land on the same ability.
// and the rule fine-tunes in playtesting. // Species mods don't apply for hybrids per project decision; the
// 2-mod ceiling intentionally caps hybrid bonuses below purebred's.
var modded = BaseAbilities; var modded = BaseAbilities;
modded = ApplyMods(modded, HybridSireClade!.AbilityMods); modded = ApplyOneMod(modded, HybridSireChosenAbility, HybridSireClade!.AbilityMods);
modded = ApplyMods(modded, HybridDamClade!.AbilityMods); modded = ApplyOneMod(modded, HybridDamChosenAbility, HybridDamClade!.AbilityMods);
modded = ApplyMods(modded, HybridSireSpecies!.AbilityMods);
modded = ApplyMods(modded, HybridDamSpecies!.AbilityMods);
var c = new Character(dominantClade, dominantSpecies, ClassDef, Background, modded) var c = new Character(dominantClade, dominantSpecies, ClassDef, Background, modded)
{ {
@@ -367,6 +381,24 @@ public sealed class CharacterBuilder
return a.Plus(dict); return a.Plus(dict);
} }
/// <summary>
/// Apply a single mod sourced from <paramref name="modsSource"/> at key
/// <paramref name="chosenAbility"/>. 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).
/// </summary>
private static AbilityScores ApplyOneMod(
AbilityScores a, string chosenAbility, IReadOnlyDictionary<string, int> 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<AbilityId, int> { [id] = value };
return a.Plus(dict);
}
private static bool TryParseAbility(string raw, out AbilityId id) private static bool TryParseAbility(string raw, out AbilityId id)
{ {
switch (raw.ToUpperInvariant()) switch (raw.ToUpperInvariant())
+19 -7
View File
@@ -127,15 +127,27 @@ public partial class StepReview : VBoxContainer, IStep
{ {
if (WizardValidation.FirstIncomplete(_draft) != -1) return; if (WizardValidation.FirstIncomplete(_draft) != -1) return;
// Persist the draft so a future load path can pick it up. // Persist the draft so a future load path can resume editing.
const string SavePath = "user://character.tres"; const string DraftPath = "user://character.tres";
var err = ResourceSaver.Save(_draft, SavePath); var saveErr = ResourceSaver.Save(_draft, DraftPath);
if (err != Error.Ok) if (saveErr != Error.Ok)
GD.PushWarning($"[review] ResourceSaver.Save failed: {err}"); GD.PushWarning($"[review] ResourceSaver.Save failed: {saveErr}");
else 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); EmitSignal(SignalName.CharacterConfirmed, _draft);
+168
View File
@@ -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;
/// <summary>
/// Bridges the wizard's <see cref="CharacterDraft"/> (Godot Resource) into a
/// runtime <see cref="Character"/> via <see cref="CharacterBuilder"/>. Used by
/// StepReview's "Confirm &amp; Begin" handoff.
///
/// Holds the most-recently-built character in <see cref="LastBuilt"/> so a
/// future PlayScreen / world-load step can pick it up without re-running the
/// builder, and writes the captured <see cref="PlayerCharacterState"/> to
/// <c>user://character.json</c> for cold-restart resumability before the M7
/// save format lands.
/// </summary>
public static class CharacterAssembler
{
/// <summary>The most recently confirmed character, or null if none.</summary>
public static Character? LastBuilt { get; private set; }
/// <summary>Path used for the resumability dump.</summary>
public const string PersistedStatePath = "user://character.json";
private static IReadOnlyDictionary<string, ItemDef>? _items;
private static IReadOnlyDictionary<string, ItemDef> 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<string, ItemDef>(arr.Length);
foreach (var it in arr) dict[it.Id] = it;
_items = dict;
return _items;
}
/// <summary>
/// Build the runtime <see cref="Character"/> from the draft. Returns false
/// with a populated <paramref name="error"/> when the draft is incomplete
/// or content lookups fail; on success <paramref name="character"/> holds
/// the built object and <see cref="LastBuilt"/> is updated.
/// </summary>
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;
}
/// <summary>
/// Capture the live <see cref="Character"/> into a flat
/// <see cref="PlayerCharacterState"/> and write it as JSON to
/// <see cref="PersistedStatePath"/>. Lets a future cold-load path
/// recover the confirmed character before the M7 save format lands.
/// </summary>
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"));
}
}
+47 -14
View File
@@ -93,27 +93,60 @@ public sealed class HybridCharacterTests
} }
[Fact] [Fact]
public void TryBuildHybrid_BlendsAbilityMods() public void TryBuildHybrid_AppliesChosenAbilityFromEachParentClade()
{ {
// Wolf-Folk Sire: // Hybrid PCs take ONE ability mod from each parent clade — the
// canidae clade: +1 CON, +1 WIS // wizard's StepClade picker records the choices, and the builder
// wolf species: +1 STR // applies exactly those. Species mods don't apply for hybrids.
// × Rabbit-Folk Dam: //
// leporidae clade: -1 STR, +2 DEX // Wolf-Folk Sire (canidae: +1 CON, +1 WIS) — sire picks CON.
// rabbit species: +1 WIS // Rabbit-Folk Dam (leporidae: -1 STR, +2 DEX) — dam picks DEX.
// Base 10 across the board. // Base 10 across the board.
var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "rabbit"); var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "rabbit");
b.BaseAbilities = new AbilityScores(10, 10, 10, 10, 10, 10); 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 _); bool ok = b.TryBuildHybrid(_content.Items, out var c, out _);
Assert.True(ok); 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(10, c!.Abilities.STR);
Assert.Equal(12, c.Abilities.DEX); Assert.Equal(10, c.Abilities.DEX);
Assert.Equal(11, c.Abilities.CON); Assert.Equal(10, c.Abilities.CON);
Assert.Equal(12, c.Abilities.WIS); Assert.Equal(10, c.Abilities.WIS);
} }
// ── Cross-clade pairings smoke ──────────────────────────────────────── // ── Cross-clade pairings smoke ────────────────────────────────────────