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:
@@ -65,6 +65,21 @@ public sealed class CharacterBuilder
|
||||
/// </summary>
|
||||
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 ──────────────────────────────────────────
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
switch (raw.ToUpperInvariant())
|
||||
|
||||
Reference in New Issue
Block a user