diff --git a/Theriapolis.Godot/Scenes/Aside.cs b/Theriapolis.Godot/Scenes/Aside.cs index 352b0f2..08217c4 100644 --- a/Theriapolis.Godot/Scenes/Aside.cs +++ b/Theriapolis.Godot/Scenes/Aside.cs @@ -249,24 +249,30 @@ public partial class Aside : MarginContainer flow.AddThemeConstantOverride("v_separation", 6); _content.AddChild(flow); - // Clade traits (purebred = single clade; hybrid = both). + // Clade — purebred: full trait+detriment list of the one clade. + // Hybrid: only the player-picked clade traits per parent (2/1 split), + // but ALL clade detriments from BOTH parents per doc rule. if (_draft!.IsHybrid) { - AddCladeTraits(flow, CodexContent.Clade(_draft.SireCladeId)); - AddCladeTraits(flow, CodexContent.Clade(_draft.DamCladeId)); + AddPickedCladeTraits(flow, CodexContent.Clade(_draft.SireCladeId), _draft.SireChosenCladeTraits); + AddPickedCladeTraits(flow, CodexContent.Clade(_draft.DamCladeId), _draft.DamChosenCladeTraits); + AddCladeDetriments(flow, CodexContent.Clade(_draft.SireCladeId)); + AddCladeDetriments(flow, CodexContent.Clade(_draft.DamCladeId)); } else { AddCladeTraits(flow, CodexContent.Clade(_draft.CladeId)); } - // Species traits — purebred uses the single species; hybrids show - // BOTH parent species per theriapolis-rpg-clades.md ("Choose ONE - // species trait from each parent"). + // Species — purebred shows everything from the single species. + // Hybrid shows only the picked trait + picked detriment per parent + // (single-pick each, per doc) plus the four universal detriments. if (_draft.IsHybrid) { - AddSpeciesTraits(flow, CodexContent.SpeciesById(_draft.SireSpeciesId)); - AddSpeciesTraits(flow, CodexContent.SpeciesById(_draft.DamSpeciesId)); + AddPickedSpeciesPick(flow, CodexContent.SpeciesById(_draft.SireSpeciesId), + _draft.SireChosenSpeciesTrait, _draft.SireChosenSpeciesDetriment); + AddPickedSpeciesPick(flow, CodexContent.SpeciesById(_draft.DamSpeciesId), + _draft.DamChosenSpeciesTrait, _draft.DamChosenSpeciesDetriment); // Universal hybrid detriments — every hybrid has all four. foreach (var (name, desc) in UniversalHybridDetriments) @@ -338,6 +344,37 @@ public partial class Aside : MarginContainer flow.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true }); } + /// Hybrid: only the chosen subset of clade traits. + private static void AddPickedCladeTraits(HFlowContainer flow, Theriapolis.Core.Data.CladeDef? clade, + Godot.Collections.Array chosen) + { + if (clade is null) return; + foreach (var t in clade.Traits) + if (chosen.Contains(t.Id)) + flow.AddChild(new TraitChip { TraitName = t.Name, Description = t.Description }); + } + + /// Hybrid: all clade detriments from this parent (both inherited). + private static void AddCladeDetriments(HFlowContainer flow, Theriapolis.Core.Data.CladeDef? clade) + { + if (clade is null) return; + foreach (var d in clade.Detriments) + flow.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true }); + } + + /// Hybrid: one chosen species trait + one chosen species detriment. + private static void AddPickedSpeciesPick(HFlowContainer flow, Theriapolis.Core.Data.SpeciesDef? species, + string chosenTraitId, string chosenDetrimentId) + { + if (species is null) return; + foreach (var t in species.Traits) + if (t.Id == chosenTraitId) + flow.AddChild(new TraitChip { TraitName = t.Name, Description = t.Description }); + foreach (var d in species.Detriments) + if (d.Id == chosenDetrimentId) + flow.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true }); + } + /// /// Hardcoded from theriapolis-rpg-clades.md "Universal Hybrid Detriments" /// (lines 749-753). Every hybrid character has all four. The schema diff --git a/Theriapolis.Godot/Scenes/Steps/StepClade.cs b/Theriapolis.Godot/Scenes/Steps/StepClade.cs index 02857b8..4bb8f3a 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepClade.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepClade.cs @@ -32,6 +32,13 @@ public partial class StepClade : VBoxContainer, IStep private HBoxContainer _sireBonusRow = null!; private HBoxContainer _damBonusRow = null!; + // Phase B clade-trait pickers: 2 from dominant parent + 1 from non-dominant. + private VBoxContainer _traitSection = null!; + private VBoxContainer _sireTraitCol = null!; + private VBoxContainer _damTraitCol = null!; + private Label _sireTraitHeader = null!; + private Label _damTraitHeader = null!; + private readonly Dictionary _purebredCards = new(); private readonly Dictionary _sireCards = new(); private readonly Dictionary _damCards = new(); @@ -45,6 +52,11 @@ public partial class StepClade : VBoxContainer, IStep private string _sireBonusBuiltFor = ""; private string _damBonusBuiltFor = ""; + private readonly Dictionary _sireTraitButtons = new(); + private readonly Dictionary _damTraitButtons = new(); + private string _sireTraitsBuiltFor = ""; + private string _damTraitsBuiltFor = ""; + public void Bind(CharacterDraft draft) { _draft = draft; @@ -134,6 +146,36 @@ public partial class StepClade : VBoxContainer, IStep _dominantToggle.ItemSelected += OnDominantSelected; dominantRow.AddChild(_dominantToggle); + // Phase B clade-trait pickers — per doc "2/1 split, player's choice + // of which parent is dominant". DominantParent drives the limit. + _traitSection = new VBoxContainer(); + _traitSection.AddThemeConstantOverride("separation", 8); + _hybridSection.AddChild(_traitSection); + _traitSection.AddChild(new Label { Text = "CLADE TRAITS", ThemeTypeVariation = "Eyebrow" }); + _traitSection.AddChild(new Label + { + Text = "Pick two clade traits from the dominant parent and one from the other. " + + "All clade detriments from both parents are inherited automatically.", + AutowrapMode = TextServer.AutowrapMode.WordSmart, + }); + + var traitGrid = new HBoxContainer(); + traitGrid.AddThemeConstantOverride("separation", 16); + traitGrid.SizeFlagsHorizontal = SizeFlags.ExpandFill; + _traitSection.AddChild(traitGrid); + + _sireTraitCol = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill }; + _sireTraitCol.AddThemeConstantOverride("separation", 4); + traitGrid.AddChild(_sireTraitCol); + _sireTraitHeader = new Label { Text = "SIRE", ThemeTypeVariation = "Eyebrow" }; + _sireTraitCol.AddChild(_sireTraitHeader); + + _damTraitCol = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill }; + _damTraitCol.AddThemeConstantOverride("separation", 4); + traitGrid.AddChild(_damTraitCol); + _damTraitHeader = new Label { Text = "DAM", ThemeTypeVariation = "Eyebrow" }; + _damTraitCol.AddChild(_damTraitHeader); + Refresh(); } @@ -171,6 +213,12 @@ public partial class StepClade : VBoxContainer, IStep patch["dam_species_id"] = ""; patch["sire_chosen_ability"] = ""; patch["dam_chosen_ability"] = ""; + patch["sire_chosen_clade_traits"] = new Godot.Collections.Array(); + patch["dam_chosen_clade_traits"] = new Godot.Collections.Array(); + patch["sire_chosen_species_trait"] = ""; + patch["dam_chosen_species_trait"] = ""; + patch["sire_chosen_species_detriment"] = ""; + patch["dam_chosen_species_detriment"] = ""; } ClearBackgroundIfInvalidated(patch); _draft.Patch(patch); @@ -178,10 +226,28 @@ public partial class StepClade : VBoxContainer, IStep private void OnDominantSelected(long index) { - _draft.Patch(new Godot.Collections.Dictionary + string newDominant = index == 0 ? "sire" : "dam"; + var patch = new Godot.Collections.Dictionary { - { "dominant_parent", index == 0 ? "sire" : "dam" }, - }); + { "dominant_parent", newDominant }, + }; + + // Trim clade-trait picks down to the new limit per side: 2 for the + // dominant lineage, 1 for the other. Only trims, never expands — + // user re-picks the missing slot manually. + TrimToLimit(patch, "sire", _draft.SireChosenCladeTraits, newDominant == "sire" ? 2 : 1); + TrimToLimit(patch, "dam", _draft.DamChosenCladeTraits, newDominant == "dam" ? 2 : 1); + + _draft.Patch(patch); + } + + private static void TrimToLimit(Godot.Collections.Dictionary patch, string lineage, + Godot.Collections.Array current, int limit) + { + if (current.Count <= limit) return; + var trimmed = new Godot.Collections.Array(); + for (int i = 0; i < limit; i++) trimmed.Add(current[i]); + patch[lineage + "_chosen_clade_traits"] = trimmed; } private void Refresh() @@ -200,7 +266,11 @@ public partial class StepClade : VBoxContainer, IStep int dominantIdx = _draft.DominantParent == "dam" ? 1 : 0; if (_dominantToggle.Selected != dominantIdx) _dominantToggle.Select(dominantIdx); - if (hybrid) RebuildBonusPickers(); + if (hybrid) + { + RebuildBonusPickers(); + RebuildTraitPickers(); + } } /// @@ -288,11 +358,17 @@ public partial class StepClade : VBoxContainer, IStep string currentSpecies = lineage == "sire" ? _draft.SireSpeciesId : _draft.DamSpeciesId; var sp = CodexContent.SpeciesById(currentSpecies); if (sp is null || !string.Equals(sp.CladeId, cladeId, System.StringComparison.OrdinalIgnoreCase)) + { patch[lineage + "_species_id"] = ""; + patch[lineage + "_chosen_species_trait"] = ""; + patch[lineage + "_chosen_species_detriment"] = ""; + } // Clade swap invalidates the previously-picked lineage bonus - // (different clade has different mod options). + // (different clade has different mod options) and the clade + // trait picks. patch[lineage + "_chosen_ability"] = ""; + patch[lineage + "_chosen_clade_traits"] = new Godot.Collections.Array(); ClearBackgroundIfInvalidated(patch); _draft.Patch(patch); @@ -306,6 +382,102 @@ public partial class StepClade : VBoxContainer, IStep }); } + /// + /// Syncs both clade-trait columns. Header text reflects the current + /// limit (2 for dominant, 1 for other). Mutate-in-place same as + /// SyncLineageBonusRow — Free()-during-Pressed leaves stale buttons. + /// + private void RebuildTraitPickers() + { + SyncCladeTraitColumn(_sireTraitCol, _sireTraitHeader, _sireTraitButtons, + ref _sireTraitsBuiltFor, "sire", _draft.SireCladeId, + _draft.SireChosenCladeTraits, _draft.CladeTraitLimit("sire")); + SyncCladeTraitColumn(_damTraitCol, _damTraitHeader, _damTraitButtons, + ref _damTraitsBuiltFor, "dam", _draft.DamCladeId, + _draft.DamChosenCladeTraits, _draft.CladeTraitLimit("dam")); + } + + private void SyncCladeTraitColumn(VBoxContainer col, Label header, + Dictionary cache, + ref string builtFor, string lineage, + string cladeId, + Godot.Collections.Array chosen, + int limit) + { + string headerLabel = lineage.ToUpperInvariant() + (lineage == _draft.DominantParent ? " ★" : ""); + header.Text = $"{headerLabel} ({chosen.Count}/{limit})"; + + if (cladeId == builtFor) + { + foreach (var (id, btn) in cache) + { + bool want = chosen.Contains(id); + if (btn.ButtonPressed != want) btn.SetPressedNoSignal(want); + bool atCap = chosen.Count >= limit && !want; + btn.Disabled = atCap; + } + return; + } + + // Remove old buttons but keep the header (index 0). + for (int i = col.GetChildCount() - 1; i >= 1; i--) col.GetChild(i).Free(); + cache.Clear(); + builtFor = cladeId; + + var clade = CodexContent.Clade(cladeId); + if (clade is null) + { + col.AddChild(new Label { Text = "(pick a clade above)" }); + return; + } + + foreach (var t in clade.Traits) + { + string captured = t.Id; + string capturedName = t.Name; + string capturedDesc = t.Description; + bool isPicked = chosen.Contains(t.Id); + var btn = new Button + { + Text = t.Name, + ToggleMode = true, + ButtonPressed = isPicked, + FocusMode = Control.FocusModeEnum.None, + Disabled = !isPicked && chosen.Count >= limit, + SizeFlagsHorizontal = SizeFlags.ExpandFill, + }; + btn.Toggled += pressed => OnCladeTraitToggled(lineage, captured, pressed); + var btnRef = btn; + btn.MouseEntered += () => + PopoverLayer.Instance?.ShowFor(btnRef, capturedName, capturedDesc, "trait", false); + btn.MouseExited += () => + PopoverLayer.Instance?.ScheduleClose(); + col.AddChild(btn); + cache[t.Id] = btn; + } + } + + private void OnCladeTraitToggled(string lineage, string traitId, bool pressed) + { + var current = lineage == "sire" ? _draft.SireChosenCladeTraits : _draft.DamChosenCladeTraits; + int limit = _draft.CladeTraitLimit(lineage); + var next = new Godot.Collections.Array(current); + if (pressed) + { + if (next.Contains(traitId)) return; + if (next.Count >= limit) return; // cap (UI also disables, defense-in-depth) + next.Add(traitId); + } + else + { + next.Remove(traitId); + } + _draft.Patch(new Godot.Collections.Dictionary + { + { lineage + "_chosen_clade_traits", next }, + }); + } + /// /// Clade changes can make a previously-valid background unavailable /// (e.g. picking Felidae while Pack-Raised is selected). Build the diff --git a/Theriapolis.Godot/Scenes/Steps/StepSpecies.cs b/Theriapolis.Godot/Scenes/Steps/StepSpecies.cs index 06122d0..7f89a57 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepSpecies.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepSpecies.cs @@ -20,6 +20,13 @@ public partial class StepSpecies : VBoxContainer, IStep private GridContainer _sireGrid = null!; private GridContainer _damGrid = null!; + // Phase B species trait + detriment pickers — single-pick per lineage. + private VBoxContainer _pickSection = null!; + private VBoxContainer _sirePickCol = null!; + private VBoxContainer _damPickCol = null!; + private string _sirePicksBuiltFor = ""; + private string _damPicksBuiltFor = ""; + public void Bind(CharacterDraft draft) { _draft = draft; @@ -67,6 +74,30 @@ public partial class StepSpecies : VBoxContainer, IStep _damGrid = MakeGrid(); _hybridSection.AddChild(_damGrid); + // Phase B species pickers: one trait + one detriment per parent. + _pickSection = new VBoxContainer(); + _pickSection.AddThemeConstantOverride("separation", 8); + _hybridSection.AddChild(_pickSection); + _pickSection.AddChild(new Label { Text = "SPECIES TRAITS & DETRIMENTS", ThemeTypeVariation = "Eyebrow" }); + _pickSection.AddChild(new Label + { + Text = "Pick one species trait and one species detriment from each parent. " + + "GMs may rule that traits express partially in hybrids.", + AutowrapMode = TextServer.AutowrapMode.WordSmart, + }); + + var pickGrid = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill }; + pickGrid.AddThemeConstantOverride("separation", 16); + _pickSection.AddChild(pickGrid); + + _sirePickCol = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill }; + _sirePickCol.AddThemeConstantOverride("separation", 6); + pickGrid.AddChild(_sirePickCol); + + _damPickCol = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill }; + _damPickCol.AddThemeConstantOverride("separation", 6); + pickGrid.AddChild(_damPickCol); + Refresh(); } @@ -88,9 +119,14 @@ public partial class StepSpecies : VBoxContainer, IStep if (hybrid) { RefreshGrid(_sireGrid, _draft.SireCladeId, _draft.SireSpeciesId, - spId => _draft.Patch(new Godot.Collections.Dictionary { { "sire_species_id", spId } })); + spId => OnLineageSpeciesPicked("sire", spId)); RefreshGrid(_damGrid, _draft.DamCladeId, _draft.DamSpeciesId, - spId => _draft.Patch(new Godot.Collections.Dictionary { { "dam_species_id", spId } })); + spId => OnLineageSpeciesPicked("dam", spId)); + + SyncSpeciesPicks(_sirePickCol, ref _sirePicksBuiltFor, "sire", + _draft.SireSpeciesId, _draft.SireChosenSpeciesTrait, _draft.SireChosenSpeciesDetriment); + SyncSpeciesPicks(_damPickCol, ref _damPicksBuiltFor, "dam", + _draft.DamSpeciesId, _draft.DamChosenSpeciesTrait, _draft.DamChosenSpeciesDetriment); } else { @@ -99,6 +135,126 @@ public partial class StepSpecies : VBoxContainer, IStep } } + private void OnLineageSpeciesPicked(string lineage, string speciesId) + { + _draft.Patch(new Godot.Collections.Dictionary + { + { lineage + "_species_id", speciesId }, + // Species swap invalidates the previously-picked species trait/detriment. + { lineage + "_chosen_species_trait", "" }, + { lineage + "_chosen_species_detriment", "" }, + }); + } + + /// + /// Mutate-in-place sync for the species-pick column (one trait button + /// group + one detriment button group, radio-style). Same Free()-defer + /// hazard as the bonus rows in StepClade — only rebuild when the + /// species id changes. + /// + private void SyncSpeciesPicks(VBoxContainer col, ref string builtFor, + string lineage, string speciesId, + string chosenTrait, string chosenDetriment) + { + if (speciesId == builtFor) + { + UpdateRadioGroup(col, "trait", chosenTrait); + UpdateRadioGroup(col, "detriment", chosenDetriment); + return; + } + + foreach (var c in col.GetChildren()) c.Free(); + builtFor = speciesId; + + var sp = CodexContent.SpeciesById(speciesId); + col.AddChild(new Label + { + Text = lineage.ToUpperInvariant() + (sp is null ? "" : " — " + sp.Name), + ThemeTypeVariation = "Eyebrow", + }); + if (sp is null) + { + col.AddChild(new Label { Text = "(pick a species above)" }); + return; + } + + col.AddChild(new Label { Text = "Trait", ThemeTypeVariation = "Eyebrow" }); + BuildRadioGroup(col, "trait", lineage, sp.Traits, chosenTrait, + (lin, id) => OnSpeciesPickToggled(lin, "trait", id), isDetriment: false); + + col.AddChild(new Label { Text = "Detriment", ThemeTypeVariation = "Eyebrow" }); + if (sp.Detriments.Length == 0) + { + col.AddChild(new Label { Text = "(none)" }); + } + else + { + BuildRadioGroup(col, "detriment", lineage, sp.Detriments, chosenDetriment, + (lin, id) => OnSpeciesPickToggled(lin, "detriment", id), isDetriment: true); + } + } + + private static void BuildRadioGroup(VBoxContainer parent, string kind, string lineage, + Theriapolis.Core.Data.TraitDef[] options, string selected, + System.Action onPicked, bool isDetriment) + { + var holder = new VBoxContainer { Name = $"{kind}_group" }; + holder.AddThemeConstantOverride("separation", 4); + parent.AddChild(holder); + + foreach (var t in options) + { + string captured = t.Id; + string capturedName = t.Name; + string capturedDesc = t.Description; + var btn = new Button + { + Text = t.Name, + ToggleMode = true, + ButtonPressed = t.Id == selected, + FocusMode = Control.FocusModeEnum.None, + SizeFlagsHorizontal = SizeFlags.ExpandFill, + Name = t.Id, + }; + // Single Pressed signal handles both directions: if the + // button is now pressed (its own click flipped it on), + // commit the pick; if it's now off, clear the field. + // Reading btn.ButtonPressed lets us avoid the stale-closure + // bug from capturing `selected` at build time. + var btnRef = btn; + btn.Pressed += () => + onPicked(lineage, btnRef.ButtonPressed ? captured : ""); + btn.MouseEntered += () => + PopoverLayer.Instance?.ShowFor(btnRef, capturedName, capturedDesc, + isDetriment ? "" : "trait", isDetriment); + btn.MouseExited += () => + PopoverLayer.Instance?.ScheduleClose(); + holder.AddChild(btn); + } + } + + private static void UpdateRadioGroup(VBoxContainer col, string kind, string selected) + { + // Find the holder by Name. Bail silently if missing — a species + // with no detriments has no detriment_group node. + var holder = col.GetNodeOrNull($"{kind}_group"); + if (holder is null) return; + foreach (var child in holder.GetChildren()) + { + if (child is Button btn) + { + bool want = btn.Name == selected; + if (btn.ButtonPressed != want) btn.SetPressedNoSignal(want); + } + } + } + + private void OnSpeciesPickToggled(string lineage, string kind, string traitId) + { + string field = lineage + "_chosen_species_" + kind; + _draft.Patch(new Godot.Collections.Dictionary { { field, traitId } }); + } + private static void RefreshGrid(GridContainer grid, string cladeId, string selectedSpecies, System.Action onClick) { foreach (var c in grid.GetChildren()) c.Free(); diff --git a/Theriapolis.Godot/UI/CharacterDraft.cs b/Theriapolis.Godot/UI/CharacterDraft.cs index 5d3a6d9..1397f32 100644 --- a/Theriapolis.Godot/UI/CharacterDraft.cs +++ b/Theriapolis.Godot/UI/CharacterDraft.cs @@ -47,6 +47,26 @@ public partial class CharacterDraft : Resource /// Ability key picked from dam's clade mods. [Export] public string DamChosenAbility { get; set; } = ""; + // Phase B trait pickers, per theriapolis-rpg-clades.md "Building a Hybrid": + // Clade traits: 2 from dominant parent + 1 from non-dominant (player's + // split). Detriments are inherited fully from both clades + // — no pick. + // Species trait: 1 per parent. + // Species detriment: 1 per parent. + /// Clade trait IDs picked from sire's clade. Length depends on + /// dominant: 2 if sire is dominant, 1 if dam is. + [Export] public Godot.Collections.Array SireChosenCladeTraits { get; set; } = new(); + [Export] public Godot.Collections.Array DamChosenCladeTraits { get; set; } = new(); + [Export] public string SireChosenSpeciesTrait { get; set; } = ""; + [Export] public string DamChosenSpeciesTrait { get; set; } = ""; + [Export] public string SireChosenSpeciesDetriment { get; set; } = ""; + [Export] public string DamChosenSpeciesDetriment { get; set; } = ""; + + /// Number of clade traits the named lineage is allowed to pick: + /// 2 if it's the dominant parent, 1 otherwise. + public int CladeTraitLimit(string lineage) => + lineage == DominantParent ? 2 : 1; + /// /// Resolves the "active" clade for downstream steps (Class / Subclass /// / Background). Purebred uses ; hybrids use @@ -123,6 +143,12 @@ public partial class CharacterDraft : Resource 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; + case "sire_chosen_clade_traits": SireChosenCladeTraits = (Godot.Collections.Array)patch[key]; break; + case "dam_chosen_clade_traits": DamChosenCladeTraits = (Godot.Collections.Array)patch[key]; break; + case "sire_chosen_species_trait": SireChosenSpeciesTrait = (string)patch[key]; break; + case "dam_chosen_species_trait": DamChosenSpeciesTrait = (string)patch[key]; break; + case "sire_chosen_species_detriment":SireChosenSpeciesDetriment = (string)patch[key]; break; + case "dam_chosen_species_detriment": DamChosenSpeciesDetriment = (string)patch[key]; break; default: GD.PushWarning($"[CharacterDraft] unknown patch key: {k}"); break; diff --git a/Theriapolis.Godot/UI/WizardValidation.cs b/Theriapolis.Godot/UI/WizardValidation.cs index eb85dd0..065d371 100644 --- a/Theriapolis.Godot/UI/WizardValidation.cs +++ b/Theriapolis.Godot/UI/WizardValidation.cs @@ -48,6 +48,14 @@ public static class WizardValidation if (string.IsNullOrEmpty(draft.DamChosenAbility)) return "Pick a lineage bonus from the dam clade."; + // Phase B: 2 clade traits from dominant + 1 from non-dominant. + int sireNeed = draft.CladeTraitLimit("sire"); + int damNeed = draft.CladeTraitLimit("dam"); + if (draft.SireChosenCladeTraits.Count != sireNeed) + return $"Pick {sireNeed} sire clade trait{(sireNeed == 1 ? "" : "s")} ({draft.SireChosenCladeTraits.Count}/{sireNeed})."; + if (draft.DamChosenCladeTraits.Count != damNeed) + return $"Pick {damNeed} dam clade trait{(damNeed == 1 ? "" : "s")} ({draft.DamChosenCladeTraits.Count}/{damNeed})."; + return null; } return string.IsNullOrEmpty(draft.CladeId) ? "Pick a clade." : null; @@ -59,6 +67,24 @@ public static class WizardValidation { if (string.IsNullOrEmpty(draft.SireSpeciesId)) return "Pick a sire species."; if (string.IsNullOrEmpty(draft.DamSpeciesId)) return "Pick a dam species."; + + // Phase B: one species trait + one species detriment per lineage. + // A species with an empty detriment list still requires explicit + // confirmation — UI shows "(none)" affordance. + if (string.IsNullOrEmpty(draft.SireChosenSpeciesTrait)) + return "Pick a sire species trait."; + if (string.IsNullOrEmpty(draft.DamChosenSpeciesTrait)) + return "Pick a dam species trait."; + + var sireSp = CodexContent.SpeciesById(draft.SireSpeciesId); + var damSp = CodexContent.SpeciesById(draft.DamSpeciesId); + if (sireSp is not null && sireSp.Detriments.Length > 0 + && string.IsNullOrEmpty(draft.SireChosenSpeciesDetriment)) + return "Pick a sire species detriment."; + if (damSp is not null && damSp.Detriments.Length > 0 + && string.IsNullOrEmpty(draft.DamChosenSpeciesDetriment)) + return "Pick a dam species detriment."; + return null; } return string.IsNullOrEmpty(draft.SpeciesId) ? "Pick a species." : null;