M6.6: StepReview signing + hybrid math revision

- New Step VIII (Review): name input and Confirm button that
  saves the finalized character to user://character.tres.
- Hybrid lineage rules simplified per project decision: drop
  the "no-stack on overlap, take +1 free elsewhere" rule from
  theriapolis-rpg-clades.md. Hybrids now pick one ability mod
  from each parent clade and they sum if they overlap.
  Removes HybridFreeAbility, the free-bonus picker row, and the
  overlap special case from AbilityCalc + WizardValidation.
- StepClade bonus rows now mutate in place (sync ButtonPressed)
  instead of tearing down on every Refresh — the old path freed
  the very button mid-Pressed-signal, leaving stale buttons next
  to the new ones.
- StepSkills drops the redundant "Calling: X · History: Y" meta
  line; both are already shown in the Aside summary.
- Aside hybrid section adds dual-species traits and the
  universal-hybrid detriment pills.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-05-03 20:51:55 -07:00
parent 0e5d4b7425
commit bb986d49f9
8 changed files with 364 additions and 31 deletions
+102
View File
@@ -26,10 +26,25 @@ public partial class StepClade : VBoxContainer, IStep
private VBoxContainer _hybridSection = null!;
private OptionButton _dominantToggle = null!;
// Lineage-bonus pickers (per theriapolis-rpg-clades.md hybrid rules,
// simplified to allow stacking on the same ability).
private VBoxContainer _bonusSection = null!;
private HBoxContainer _sireBonusRow = null!;
private HBoxContainer _damBonusRow = null!;
private readonly Dictionary<string, PanelContainer> _purebredCards = new();
private readonly Dictionary<string, PanelContainer> _sireCards = new();
private readonly Dictionary<string, PanelContainer> _damCards = new();
// Bonus rows are mutated in place too: when the clade hasn't changed
// since the last build, we only flip ButtonPressed states. Rebuilding
// would Free() the very button whose Pressed callback we're inside,
// which Godot defers — leaving stale buttons next to the new ones.
private readonly Dictionary<string, Button> _sireBonusButtons = new();
private readonly Dictionary<string, Button> _damBonusButtons = new();
private string _sireBonusBuiltFor = "";
private string _damBonusBuiltFor = "";
public void Bind(CharacterDraft draft)
{
_draft = draft;
@@ -86,6 +101,21 @@ public partial class StepClade : VBoxContainer, IStep
_hybridSection.AddChild(damGrid);
PopulateGrid(damGrid, _damCards, id => OnLineageCladePicked("dam", id));
// Lineage bonus pickers — hybrids pick ONE ability mod from each
// parent clade. Stacking on the same ability is allowed; mods sum.
_bonusSection = new VBoxContainer();
_bonusSection.AddThemeConstantOverride("separation", 8);
_hybridSection.AddChild(_bonusSection);
_bonusSection.AddChild(new Label { Text = "LINEAGE BONUSES" });
_sireBonusRow = new HBoxContainer();
_sireBonusRow.AddThemeConstantOverride("separation", 8);
_bonusSection.AddChild(_sireBonusRow);
_damBonusRow = new HBoxContainer();
_damBonusRow.AddThemeConstantOverride("separation", 8);
_bonusSection.AddChild(_damBonusRow);
var dominantRow = new HBoxContainer();
dominantRow.AddThemeConstantOverride("separation", 8);
_hybridSection.AddChild(dominantRow);
@@ -131,6 +161,8 @@ public partial class StepClade : VBoxContainer, IStep
patch["sire_species_id"] = "";
patch["dam_clade_id"] = "";
patch["dam_species_id"] = "";
patch["sire_chosen_ability"] = "";
patch["dam_chosen_ability"] = "";
}
ClearBackgroundIfInvalidated(patch);
_draft.Patch(patch);
@@ -159,6 +191,63 @@ public partial class StepClade : VBoxContainer, IStep
int dominantIdx = _draft.DominantParent == "dam" ? 1 : 0;
if (_dominantToggle.Selected != dominantIdx) _dominantToggle.Select(dominantIdx);
if (hybrid) RebuildBonusPickers();
}
/// <summary>
/// Syncs the lineage-bonus button rows with the current sire/dam
/// clade picks. When the clade hasn't changed since the last build
/// we only update each button's ButtonPressed state — see field
/// comment above for why a full rebuild here is unsafe.
/// </summary>
private void RebuildBonusPickers()
{
SyncLineageBonusRow(_sireBonusRow, _sireBonusButtons, ref _sireBonusBuiltFor, "sire",
_draft.SireCladeId, _draft.SireChosenAbility);
SyncLineageBonusRow(_damBonusRow, _damBonusButtons, ref _damBonusBuiltFor, "dam",
_draft.DamCladeId, _draft.DamChosenAbility);
}
private void SyncLineageBonusRow(HBoxContainer row, Dictionary<string, Button> cache,
ref string builtFor, string lineage,
string cladeId, string chosen)
{
if (cladeId == builtFor)
{
foreach (var (ab, btn) in cache)
{
bool want = ab == chosen;
if (btn.ButtonPressed != want) btn.SetPressedNoSignal(want);
}
return;
}
foreach (var c in row.GetChildren()) c.Free();
cache.Clear();
builtFor = cladeId;
var clade = CodexContent.Clade(cladeId);
if (clade is null)
{
row.AddChild(new Label { Text = "(pick a clade above)" });
return;
}
foreach (var (ab, value) in clade.AbilityMods)
{
string captured = ab;
var btn = new Button
{
Text = $"{ab} {(value >= 0 ? "+" : "")}{value}",
ToggleMode = true,
ButtonPressed = ab == chosen,
FocusMode = Control.FocusModeEnum.None,
};
btn.Pressed += () => OnLineageBonusPicked(lineage, captured);
row.AddChild(btn);
cache[ab] = btn;
}
}
private static void UpdateSelection(Dictionary<string, PanelContainer> cards, string selectedId)
@@ -192,10 +281,23 @@ public partial class StepClade : VBoxContainer, IStep
var sp = CodexContent.SpeciesById(currentSpecies);
if (sp is null || !string.Equals(sp.CladeId, cladeId, System.StringComparison.OrdinalIgnoreCase))
patch[lineage + "_species_id"] = "";
// Clade swap invalidates the previously-picked lineage bonus
// (different clade has different mod options).
patch[lineage + "_chosen_ability"] = "";
ClearBackgroundIfInvalidated(patch);
_draft.Patch(patch);
}
private void OnLineageBonusPicked(string lineage, string ability)
{
_draft.Patch(new Godot.Collections.Dictionary
{
{ lineage + "_chosen_ability", ability },
});
}
/// <summary>
/// Clade changes can make a previously-valid background unavailable
/// (e.g. picking Felidae while Pack-Raised is selected). Build the