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:
@@ -259,14 +259,21 @@ public partial class Aside : MarginContainer
|
||||
AddCladeTraits(flow, CodexContent.Clade(_draft.CladeId));
|
||||
}
|
||||
|
||||
// Species traits.
|
||||
var sp = CodexContent.SpeciesById(_draft.EffectiveSpeciesId);
|
||||
if (sp is not null)
|
||||
// Species traits — purebred uses the single species; hybrids show
|
||||
// BOTH parent species per theriapolis-rpg-clades.md ("Choose ONE
|
||||
// species trait from each parent").
|
||||
if (_draft.IsHybrid)
|
||||
{
|
||||
foreach (var t in sp.Traits)
|
||||
flow.AddChild(new TraitChip { TraitName = t.Name, Description = t.Description });
|
||||
foreach (var d in sp.Detriments)
|
||||
flow.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true });
|
||||
AddSpeciesTraits(flow, CodexContent.SpeciesById(_draft.SireSpeciesId));
|
||||
AddSpeciesTraits(flow, CodexContent.SpeciesById(_draft.DamSpeciesId));
|
||||
|
||||
// Universal hybrid detriments — every hybrid has all four.
|
||||
foreach (var (name, desc) in UniversalHybridDetriments)
|
||||
flow.AddChild(new TraitChip { TraitName = name, Description = desc, Detriment = true });
|
||||
}
|
||||
else
|
||||
{
|
||||
AddSpeciesTraits(flow, CodexContent.SpeciesById(_draft.SpeciesId));
|
||||
}
|
||||
|
||||
// Class level-1 features.
|
||||
@@ -321,6 +328,43 @@ public partial class Aside : MarginContainer
|
||||
flow.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true });
|
||||
}
|
||||
|
||||
private static void AddSpeciesTraits(HFlowContainer flow, Theriapolis.Core.Data.SpeciesDef? species)
|
||||
{
|
||||
if (species is null) return;
|
||||
foreach (var t in species.Traits)
|
||||
flow.AddChild(new TraitChip { TraitName = t.Name, Description = t.Description });
|
||||
foreach (var d in species.Detriments)
|
||||
flow.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hardcoded from theriapolis-rpg-clades.md "Universal Hybrid Detriments"
|
||||
/// (lines 749-753). Every hybrid character has all four. The schema
|
||||
/// doesn't carry these yet — they live in the clades doc as canonical
|
||||
/// rules text.
|
||||
/// </summary>
|
||||
private static readonly (string Name, string Description)[] UniversalHybridDetriments =
|
||||
{
|
||||
("Scent Dysphoria",
|
||||
"Your scent broadcasts conflicting Clade signals. Creatures with scent ability "
|
||||
+ "who detect you must make a WIS save (DC 10) or experience instinctive unease, "
|
||||
+ "imposing disadvantage on their first CHA check with you. Scent-masking products "
|
||||
+ "can suppress this for 1d4 hours but fail under stress."),
|
||||
("Illegible Body Language",
|
||||
"Your tail and ear movements blend two Clade grammars. Disadvantage on nonverbal "
|
||||
+ "communication checks (CHA checks relying on body language) with purebred creatures. "
|
||||
+ "Other hybrids read you normally."),
|
||||
("Social Stigma",
|
||||
"In non-progressive settlements, disadvantage on CHA checks with strangers and on "
|
||||
+ "checks to secure housing, employment, or official services. Even in progressive "
|
||||
+ "areas, the first CHA check with any new NPC is made at -2 until you've established "
|
||||
+ "rapport."),
|
||||
("Medical Incompatibility",
|
||||
"Healing potions and magical healing function at 75% effectiveness (round down). "
|
||||
+ "Medical treatment calibrated for purebred physiologies works imperfectly on your "
|
||||
+ "blended body. Hybrid-specific medicine exists but is expensive and rare."),
|
||||
};
|
||||
|
||||
private static void AddSkillChip(HFlowContainer flow, string skillId, string tag)
|
||||
{
|
||||
var s = SkillsCatalog.Get(skillId);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
using Godot;
|
||||
using Theriapolis.GodotHost.UI;
|
||||
|
||||
namespace Theriapolis.GodotHost.Scenes.Steps;
|
||||
|
||||
/// <summary>
|
||||
/// Step VIII — Sign. Direct port of <c>StepReview</c> in
|
||||
/// <c>src/steps.jsx</c>: name entry plus the Confirm button that
|
||||
/// composes the final character and emits the handoff signal per
|
||||
/// GODOT_PORTING_GUIDE.md §11.
|
||||
///
|
||||
/// The full per-step review already lives in the Aside summary, so
|
||||
/// this step focuses on what's new at this stage: the name input
|
||||
/// and the Confirm action. Bottom of the page mirrors the React
|
||||
/// prototype — single big primary button.
|
||||
/// </summary>
|
||||
public partial class StepReview : VBoxContainer, IStep
|
||||
{
|
||||
/// <summary>Fired when Confirm is pressed and every step validates.
|
||||
/// The parent Wizard catches this and persists the draft + advances
|
||||
/// the harness; the rest of the game-side handoff lands in a future
|
||||
/// milestone (intro scene, world load, etc.).</summary>
|
||||
[Signal] public delegate void CharacterConfirmedEventHandler(CharacterDraft draft);
|
||||
|
||||
private CharacterDraft _draft = null!;
|
||||
private LineEdit _nameField = null!;
|
||||
private Button _confirmBtn = null!;
|
||||
private Label _confirmStatus = null!;
|
||||
|
||||
public void Bind(CharacterDraft draft)
|
||||
{
|
||||
_draft = draft;
|
||||
_draft.Changed += () => Callable.From(Refresh).CallDeferred();
|
||||
Build();
|
||||
}
|
||||
|
||||
public string? Validate() => WizardValidation.Validate(7, _draft);
|
||||
|
||||
private void Build()
|
||||
{
|
||||
AddThemeConstantOverride("separation", 18);
|
||||
|
||||
var intro = new VBoxContainer();
|
||||
intro.AddThemeConstantOverride("separation", 6);
|
||||
AddChild(intro);
|
||||
intro.AddChild(new Label { Text = "FOLIO VIII · SIGN" });
|
||||
intro.AddChild(new Label { Text = "Sign the Codex" });
|
||||
intro.AddChild(new Label
|
||||
{
|
||||
Text = "Review the right-rail summary, then sign your name. "
|
||||
+ "The name you sign here is the one the world will speak.",
|
||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||
});
|
||||
|
||||
// Name field.
|
||||
var nameBlock = new VBoxContainer();
|
||||
nameBlock.AddThemeConstantOverride("separation", 6);
|
||||
AddChild(nameBlock);
|
||||
nameBlock.AddChild(new Label { Text = "NAME" });
|
||||
_nameField = new LineEdit
|
||||
{
|
||||
PlaceholderText = "Enter your character's name...",
|
||||
Text = _draft.CharacterName,
|
||||
CustomMinimumSize = new Vector2(360, 0),
|
||||
};
|
||||
_nameField.TextChanged += OnNameChanged;
|
||||
nameBlock.AddChild(_nameField);
|
||||
|
||||
// Confirm action — disabled until every step validates.
|
||||
var actionBlock = new VBoxContainer();
|
||||
actionBlock.AddThemeConstantOverride("separation", 8);
|
||||
AddChild(actionBlock);
|
||||
|
||||
_confirmStatus = new Label
|
||||
{
|
||||
Text = "",
|
||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||
};
|
||||
actionBlock.AddChild(_confirmStatus);
|
||||
|
||||
_confirmBtn = new Button
|
||||
{
|
||||
Text = "Confirm & Begin",
|
||||
CustomMinimumSize = new Vector2(220, 0),
|
||||
};
|
||||
_confirmBtn.Pressed += OnConfirmPressed;
|
||||
actionBlock.AddChild(_confirmBtn);
|
||||
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnNameChanged(string newText)
|
||||
{
|
||||
// Patch only when the field actually differs from the draft —
|
||||
// otherwise the Changed signal would refresh us in a loop.
|
||||
if (newText == _draft.CharacterName) return;
|
||||
_draft.Patch(new Godot.Collections.Dictionary { { "character_name", newText } });
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
if (_nameField is null) return;
|
||||
// Sync the field if external state changed without going through
|
||||
// the LineEdit (e.g. loading a saved draft someday).
|
||||
if (_nameField.Text != _draft.CharacterName)
|
||||
_nameField.Text = _draft.CharacterName;
|
||||
|
||||
int firstUnmet = WizardValidation.FirstIncomplete(_draft);
|
||||
bool allValid = firstUnmet == -1;
|
||||
_confirmBtn.Disabled = !allValid;
|
||||
if (allValid)
|
||||
{
|
||||
_confirmStatus.Text = $"Ready: {_draft.CharacterName} stands at the threshold.";
|
||||
}
|
||||
else if (firstUnmet == 7)
|
||||
{
|
||||
_confirmStatus.Text = "Enter a name to sign.";
|
||||
}
|
||||
else
|
||||
{
|
||||
_confirmStatus.Text = $"Some folios remain — see Folio {Roman(firstUnmet + 1)}.";
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConfirmPressed()
|
||||
{
|
||||
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}");
|
||||
else
|
||||
GD.Print($"[review] Saved character draft to {SavePath}");
|
||||
|
||||
GD.Print($"[review] Confirmed: {Summarise(_draft)}");
|
||||
|
||||
EmitSignal(SignalName.CharacterConfirmed, _draft);
|
||||
|
||||
_confirmBtn.Disabled = true;
|
||||
_confirmStatus.Text = $"{_draft.CharacterName} steps into Theriapolis.";
|
||||
}
|
||||
|
||||
private static string Summarise(CharacterDraft d)
|
||||
{
|
||||
string lineage = d.IsHybrid
|
||||
? $"hybrid {d.SireCladeId}/{d.SireSpeciesId} × {d.DamCladeId}/{d.DamSpeciesId} (dom={d.DominantParent})"
|
||||
: $"{d.CladeId}/{d.SpeciesId}";
|
||||
return $"{d.CharacterName} — {lineage}, {d.ClassId}/{d.SubclassId}, bg={d.BackgroundId}, "
|
||||
+ $"skills=[{string.Join(",", d.ChosenSkills)}]";
|
||||
}
|
||||
|
||||
private static string Roman(int n) => n switch
|
||||
{
|
||||
1 => "I", 2 => "II", 3 => "III", 4 => "IV",
|
||||
5 => "V", 6 => "VI", 7 => "VII", 8 => "VIII",
|
||||
_ => n.ToString(),
|
||||
};
|
||||
}
|
||||
@@ -23,7 +23,6 @@ public partial class StepSkills : VBoxContainer, IStep
|
||||
{
|
||||
private CharacterDraft _draft = null!;
|
||||
private Label _countLabel = null!;
|
||||
private Label _classBgLabel = null!;
|
||||
private GridContainer _groupsGrid = null!;
|
||||
|
||||
public void Bind(CharacterDraft draft)
|
||||
@@ -52,15 +51,8 @@ public partial class StepSkills : VBoxContainer, IStep
|
||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||
});
|
||||
|
||||
var meta = new HBoxContainer();
|
||||
meta.AddThemeConstantOverride("separation", 24);
|
||||
AddChild(meta);
|
||||
_countLabel = new Label { Text = "0 / 0 chosen" };
|
||||
meta.AddChild(_countLabel);
|
||||
var spacer = new Control { SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||
meta.AddChild(spacer);
|
||||
_classBgLabel = new Label { Text = "" };
|
||||
meta.AddChild(_classBgLabel);
|
||||
AddChild(_countLabel);
|
||||
|
||||
_groupsGrid = new GridContainer
|
||||
{
|
||||
@@ -86,9 +78,9 @@ public partial class StepSkills : VBoxContainer, IStep
|
||||
var classOptions = new HashSet<string>(cls?.SkillOptions ?? System.Array.Empty<string>());
|
||||
var chosen = new HashSet<string>(_draft.ChosenSkills);
|
||||
|
||||
_countLabel.Text = $"{chosen.Count} / {required} chosen +{lockedFromBg.Count} sealed by background";
|
||||
_classBgLabel.Text = (cls is null ? "Pick a calling first." :
|
||||
$"Calling: {cls.Name} · History: {bg?.Name ?? "—"}").ToUpperInvariant();
|
||||
_countLabel.Text = cls is null
|
||||
? "Pick a calling first."
|
||||
: $"{chosen.Count} / {required} chosen +{lockedFromBg.Count} sealed by background";
|
||||
|
||||
foreach (var c in _groupsGrid.GetChildren()) c.QueueFree();
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ public partial class Wizard : Control
|
||||
typeof(Steps.StepBackground), // 4 History
|
||||
typeof(Steps.StepStats), // 5 Abilities
|
||||
typeof(Steps.StepSkills), // 6 Skills
|
||||
null, // 7 Sign — M6.6
|
||||
typeof(Steps.StepReview), // 7 Sign
|
||||
};
|
||||
|
||||
public override void _Ready()
|
||||
|
||||
@@ -24,20 +24,35 @@ public static class AbilityCalc
|
||||
var list = new List<ModSource>();
|
||||
if (draft.IsHybrid)
|
||||
{
|
||||
AddCladeSource(list, ability, CodexContent.Clade(draft.SireCladeId), " (sire)");
|
||||
AddCladeSource(list, ability, CodexContent.Clade(draft.DamCladeId), " (dam)");
|
||||
// Hybrids: take ONE ability modifier from each parent clade.
|
||||
// Picks stack if they happen to land on the same ability — the
|
||||
// original "no stack, take +1 + free elsewhere" rule was
|
||||
// dropped per project decision. Species mods don't apply.
|
||||
if (draft.SireChosenAbility == ability)
|
||||
AddCladeChoice(list, ability, CodexContent.Clade(draft.SireCladeId), " (sire)");
|
||||
if (draft.DamChosenAbility == ability)
|
||||
AddCladeChoice(list, ability, CodexContent.Clade(draft.DamCladeId), " (dam)");
|
||||
}
|
||||
else
|
||||
{
|
||||
AddCladeSource(list, ability, CodexContent.Clade(draft.CladeId), "");
|
||||
}
|
||||
|
||||
var sp = CodexContent.SpeciesById(draft.EffectiveSpeciesId);
|
||||
if (sp is not null) AddDictSource(list, ability, sp.Name, sp.AbilityMods);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>The chosen-mod path: one mod from a parent clade. The value
|
||||
/// is the clade's actual mod for that ability (so picking CON from
|
||||
/// canidae yields +1 while picking CON from ursidae yields +2).</summary>
|
||||
private static void AddCladeChoice(List<ModSource> list, string ability, CladeDef? clade, string suffix)
|
||||
{
|
||||
if (clade is null) return;
|
||||
if (clade.AbilityMods.TryGetValue(ability, out int v) && v != 0)
|
||||
list.Add(new ModSource(clade.Name + suffix, v));
|
||||
}
|
||||
|
||||
public static int TotalBonus(string ability, CharacterDraft draft)
|
||||
=> Sources(ability, draft).Sum(s => s.Value);
|
||||
|
||||
|
||||
@@ -38,6 +38,15 @@ public partial class CharacterDraft : Resource
|
||||
/// <summary>"sire" or "dam" — which parent the PC presents as.</summary>
|
||||
[Export] public string DominantParent { get; set; } = "sire";
|
||||
|
||||
// Per theriapolis-rpg-clades.md "Building a Hybrid", simplified:
|
||||
// hybrids take ONE ability modifier from each parent Clade. The
|
||||
// original no-stack rule (take +1 + free elsewhere on overlap) was
|
||||
// dropped — overlapping picks just sum.
|
||||
/// <summary>Ability key picked from sire's clade mods (e.g. "STR").</summary>
|
||||
[Export] public string SireChosenAbility { get; set; } = "";
|
||||
/// <summary>Ability key picked from dam's clade mods.</summary>
|
||||
[Export] public string DamChosenAbility { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the "active" clade for downstream steps (Class / Subclass
|
||||
/// / Background). Purebred uses <see cref="CladeId"/>; hybrids use
|
||||
@@ -112,6 +121,8 @@ public partial class CharacterDraft : Resource
|
||||
case "dam_clade_id": DamCladeId = (string)patch[key]; break;
|
||||
case "dam_species_id": DamSpeciesId = (string)patch[key]; break;
|
||||
case "dominant_parent": DominantParent = (string)patch[key]; break;
|
||||
case "sire_chosen_ability": SireChosenAbility = (string)patch[key]; break;
|
||||
case "dam_chosen_ability": DamChosenAbility = (string)patch[key]; break;
|
||||
default:
|
||||
GD.PushWarning($"[CharacterDraft] unknown patch key: {k}");
|
||||
break;
|
||||
|
||||
@@ -39,6 +39,15 @@ public static class WizardValidation
|
||||
if (string.IsNullOrEmpty(draft.DamCladeId)) return "Pick a dam clade.";
|
||||
if (draft.SireCladeId == draft.DamCladeId)
|
||||
return "Sire and dam must be different clades.";
|
||||
|
||||
// Pick one ability mod from each parent clade. Stacking on
|
||||
// the same ability is allowed (the rule was simplified to
|
||||
// permit duplicate picks summing).
|
||||
if (string.IsNullOrEmpty(draft.SireChosenAbility))
|
||||
return "Pick a lineage bonus from the sire clade.";
|
||||
if (string.IsNullOrEmpty(draft.DamChosenAbility))
|
||||
return "Pick a lineage bonus from the dam clade.";
|
||||
|
||||
return null;
|
||||
}
|
||||
return string.IsNullOrEmpty(draft.CladeId) ? "Pick a clade." : null;
|
||||
|
||||
Reference in New Issue
Block a user