From e1fb9889698cb6c8e4896b6dc99d4fbd0a46677c Mon Sep 17 00:00:00 2001 From: Christopher Wiebe Date: Mon, 4 May 2026 21:03:56 -0700 Subject: [PATCH] M6.13: Sex picker + species variant schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds character Sex (male/female) as a top-level CharacterDraft field required for every character, and species variants as a layer on top of the base species record. Two variant axes: - "sex": auto-resolves from CharacterDraft.Sex for purebreds; for hybrids, pinned by parent role (sire = male, dam = female) by definition. No picker needed beyond Step 0. - "lineage": explicit per-species pick (Ram-Folk's sheep/goat). Has its own picker on Step 1 (purebred path under the grid; hybrid path embedded into the per-parent pick column). Schema (Theriapolis.Core/Data): - SpeciesDef gains VariantAxis (string) and Variants (array of SpeciesVariantDef { Id, Name, Traits, Detriments }). - JSON content not yet populated — that's M6.14. CharacterDraft adds: - Sex (required by Step 0 validation) - SpeciesVariant / SireSpeciesVariant / DamSpeciesVariant - ResolveVariantId(species, role) that returns the active variant id for a given context — used by Aside to layer variant traits onto the base species traits. Step 0 (StepClade): sex picker row above the hybrid toggle. Two toggle buttons radio-style. Step 1 (StepSpecies): purebred path renders a lineage picker below the grid when the picked species has VariantAxis == "lineage"; hybrid path embeds a lineage picker at the top of each parent's pick column. Hover popovers summarise each variant's contents. Validation: Sex is required at Step 0. Lineage variant required at Step 1 for any picked species (purebred or per-hybrid-parent) with VariantAxis == "lineage". Aside: AddVariantContent layers the resolved variant's extra traits/detriments onto the base species rendering, for both purebred and hybrid paths. Co-Authored-By: Claude Opus 4.7 --- Theriapolis.Core/Data/SpeciesDef.cs | 39 +++++ Theriapolis.Godot/Scenes/Aside.cs | 28 +++- Theriapolis.Godot/Scenes/Steps/StepClade.cs | 31 ++++ Theriapolis.Godot/Scenes/Steps/StepSpecies.cs | 144 +++++++++++++++++- Theriapolis.Godot/UI/CharacterDraft.cs | 51 +++++++ Theriapolis.Godot/UI/WizardValidation.cs | 17 ++- 6 files changed, 301 insertions(+), 9 deletions(-) diff --git a/Theriapolis.Core/Data/SpeciesDef.cs b/Theriapolis.Core/Data/SpeciesDef.cs index 2512b6d..6b30d90 100644 --- a/Theriapolis.Core/Data/SpeciesDef.cs +++ b/Theriapolis.Core/Data/SpeciesDef.cs @@ -35,4 +35,43 @@ public sealed record SpeciesDef [JsonPropertyName("detriments")] public TraitDef[] Detriments { get; init; } = Array.Empty(); + + /// + /// "sex" (male/female) or "lineage" (e.g. sheep/goat for Ram-Folk). + /// Empty when the species has no variants. Determines how the variant + /// is resolved: sex-axis is auto-keyed off character Sex (purebred) or + /// parent role for hybrids (sire = male, dam = female); lineage-axis + /// requires an explicit per-species pick. + /// + [JsonPropertyName("variant_axis")] + public string VariantAxis { get; init; } = ""; + + /// + /// Variant entries layered onto the species' base traits/detriments. + /// The variant's id keys it: for sex-axis variants, "male" or "female"; + /// for lineage variants, the lineage tag (e.g. "sheep", "goat"). + /// Empty when the species has no variants. + /// + [JsonPropertyName("variants")] + public SpeciesVariantDef[] Variants { get; init; } = Array.Empty(); +} + +/// +/// One sex- or lineage-keyed variant of a species. Layered on top of the +/// base species' traits and detriments — the player ends up with both +/// sets, not one or the other. +/// +public sealed record SpeciesVariantDef +{ + [JsonPropertyName("id")] + public string Id { get; init; } = ""; + + [JsonPropertyName("name")] + public string Name { get; init; } = ""; + + [JsonPropertyName("traits")] + public TraitDef[] Traits { get; init; } = Array.Empty(); + + [JsonPropertyName("detriments")] + public TraitDef[] Detriments { get; init; } = Array.Empty(); } diff --git a/Theriapolis.Godot/Scenes/Aside.cs b/Theriapolis.Godot/Scenes/Aside.cs index 08217c4..fd20c80 100644 --- a/Theriapolis.Godot/Scenes/Aside.cs +++ b/Theriapolis.Godot/Scenes/Aside.cs @@ -269,10 +269,14 @@ public partial class Aside : MarginContainer // (single-pick each, per doc) plus the four universal detriments. if (_draft.IsHybrid) { - AddPickedSpeciesPick(flow, CodexContent.SpeciesById(_draft.SireSpeciesId), + var sireSp = CodexContent.SpeciesById(_draft.SireSpeciesId); + var damSp = CodexContent.SpeciesById(_draft.DamSpeciesId); + AddPickedSpeciesPick(flow, sireSp, _draft.SireChosenSpeciesTrait, _draft.SireChosenSpeciesDetriment); - AddPickedSpeciesPick(flow, CodexContent.SpeciesById(_draft.DamSpeciesId), + AddPickedSpeciesPick(flow, damSp, _draft.DamChosenSpeciesTrait, _draft.DamChosenSpeciesDetriment); + AddVariantContent(flow, sireSp, _draft.ResolveVariantId(sireSp, "sire")); + AddVariantContent(flow, damSp, _draft.ResolveVariantId(damSp, "dam")); // Universal hybrid detriments — every hybrid has all four. foreach (var (name, desc) in UniversalHybridDetriments) @@ -280,7 +284,9 @@ public partial class Aside : MarginContainer } else { - AddSpeciesTraits(flow, CodexContent.SpeciesById(_draft.SpeciesId)); + var sp = CodexContent.SpeciesById(_draft.SpeciesId); + AddSpeciesTraits(flow, sp); + AddVariantContent(flow, sp, _draft.ResolveVariantId(sp, "")); } // Class level-1 features. @@ -362,6 +368,22 @@ public partial class Aside : MarginContainer flow.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true }); } + /// Render the resolved variant's extra traits/detriments, + /// if any. is the variant key (e.g. "male" + /// or "sheep"); empty when no resolution applies. + private static void AddVariantContent(HFlowContainer flow, + Theriapolis.Core.Data.SpeciesDef? species, + string variantId) + { + if (species is null || string.IsNullOrEmpty(variantId)) return; + var variant = System.Array.Find(species.Variants, v => v.Id == variantId); + if (variant is null) return; + foreach (var t in variant.Traits) + flow.AddChild(new TraitChip { TraitName = t.Name, Description = t.Description }); + foreach (var d in variant.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) diff --git a/Theriapolis.Godot/Scenes/Steps/StepClade.cs b/Theriapolis.Godot/Scenes/Steps/StepClade.cs index 4bb8f3a..01183a7 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepClade.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepClade.cs @@ -22,6 +22,8 @@ public partial class StepClade : VBoxContainer, IStep { private CharacterDraft _draft = null!; private Button _hybridToggle = null!; + private Button _sexMaleBtn = null!; + private Button _sexFemaleBtn = null!; private VBoxContainer _purebredSection = null!; private VBoxContainer _hybridSection = null!; private OptionButton _dominantToggle = null!; @@ -83,6 +85,27 @@ public partial class StepClade : VBoxContainer, IStep AutowrapMode = TextServer.AutowrapMode.WordSmart, }); + // Sex picker — required for every character. For purebreds with + // sex-axis variants (Elk, Lion) this drives variant resolution; + // for hybrids it's identity-only since sire/dam are male/female + // by parent-role definition. + var sexRow = new HBoxContainer(); + sexRow.AddThemeConstantOverride("separation", 8); + AddChild(sexRow); + sexRow.AddChild(new Label { Text = "SEX", ThemeTypeVariation = "Eyebrow" }); + _sexMaleBtn = new Button + { + Text = "Male", ToggleMode = true, FocusMode = Control.FocusModeEnum.None, + }; + _sexFemaleBtn = new Button + { + Text = "Female", ToggleMode = true, FocusMode = Control.FocusModeEnum.None, + }; + _sexMaleBtn.Pressed += () => OnSexPicked("male"); + _sexFemaleBtn.Pressed += () => OnSexPicked("female"); + sexRow.AddChild(_sexMaleBtn); + sexRow.AddChild(_sexFemaleBtn); + // Toggle Button (not CheckBox) so the inverted-on-press button style // from the codex theme handles selection visually — no checkbox glyph // needed, the bg colour shift is the affordance. @@ -224,6 +247,9 @@ public partial class StepClade : VBoxContainer, IStep _draft.Patch(patch); } + private void OnSexPicked(string sex) => + _draft.Patch(new Godot.Collections.Dictionary { { "sex", sex } }); + private void OnDominantSelected(long index) { string newDominant = index == 0 ? "sire" : "dam"; @@ -259,6 +285,11 @@ public partial class StepClade : VBoxContainer, IStep _purebredSection.Visible = !hybrid; _hybridSection.Visible = hybrid; + bool isMale = _draft.Sex == "male"; + bool isFemale = _draft.Sex == "female"; + if (_sexMaleBtn.ButtonPressed != isMale) _sexMaleBtn.SetPressedNoSignal(isMale); + if (_sexFemaleBtn.ButtonPressed != isFemale) _sexFemaleBtn.SetPressedNoSignal(isFemale); + UpdateSelection(_purebredCards, _draft.CladeId); UpdateSelection(_sireCards, _draft.SireCladeId); UpdateSelection(_damCards, _draft.DamCladeId); diff --git a/Theriapolis.Godot/Scenes/Steps/StepSpecies.cs b/Theriapolis.Godot/Scenes/Steps/StepSpecies.cs index 7f89a57..677b026 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepSpecies.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepSpecies.cs @@ -27,6 +27,11 @@ public partial class StepSpecies : VBoxContainer, IStep private string _sirePicksBuiltFor = ""; private string _damPicksBuiltFor = ""; + // Lineage-axis variant picker for purebred path (Ram-Folk sheep/goat etc.). + // Hybrid path embeds its own lineage picker into the per-parent col. + private VBoxContainer _purebredVariantSection = null!; + private string _purebredVariantBuiltFor = ""; + public void Bind(CharacterDraft draft) { _draft = draft; @@ -62,6 +67,12 @@ public partial class StepSpecies : VBoxContainer, IStep _purebredGrid = MakeGrid(); _purebredSection.AddChild(_purebredGrid); + // Lineage picker (Ram-Folk sheep/goat). Visible only when the + // selected species has VariantAxis == "lineage". + _purebredVariantSection = new VBoxContainer { Visible = false }; + _purebredVariantSection.AddThemeConstantOverride("separation", 6); + _purebredSection.AddChild(_purebredVariantSection); + _hybridSection = new VBoxContainer(); _hybridSection.AddThemeConstantOverride("separation", 16); AddChild(_hybridSection); @@ -131,26 +142,110 @@ public partial class StepSpecies : VBoxContainer, IStep else { RefreshGrid(_purebredGrid, _draft.CladeId, _draft.SpeciesId, - spId => _draft.Patch(new Godot.Collections.Dictionary { { "species_id", spId } })); + spId => OnPurebredSpeciesPicked(spId)); + SyncPurebredVariant(); } } + private void OnPurebredSpeciesPicked(string speciesId) + { + _draft.Patch(new Godot.Collections.Dictionary + { + { "species_id", speciesId }, + // Species swap invalidates lineage variant. + { "species_variant", "" }, + }); + } + 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. + // Species swap invalidates the previously-picked species trait/detriment + variant. { lineage + "_chosen_species_trait", "" }, { lineage + "_chosen_species_detriment", "" }, + { lineage + "_species_variant", "" }, }); } + /// Sync the purebred lineage picker row. Visible iff the + /// picked species declares a lineage-axis variant. + private void SyncPurebredVariant() + { + var sp = CodexContent.SpeciesById(_draft.SpeciesId); + bool show = sp is not null && sp.VariantAxis == "lineage" && sp.Variants.Length > 0; + _purebredVariantSection.Visible = show; + if (!show) + { + _purebredVariantBuiltFor = ""; + foreach (var c in _purebredVariantSection.GetChildren()) c.Free(); + return; + } + + if (_purebredVariantBuiltFor == _draft.SpeciesId) + { + // Same species — just update which lineage button is pressed. + foreach (var child in _purebredVariantSection.GetChildren()) + { + if (child is Button btn) + { + bool want = btn.Name == _draft.SpeciesVariant; + if (btn.ButtonPressed != want) btn.SetPressedNoSignal(want); + } + } + return; + } + + foreach (var c in _purebredVariantSection.GetChildren()) c.Free(); + _purebredVariantBuiltFor = _draft.SpeciesId; + + _purebredVariantSection.AddChild(new Label { Text = "LINEAGE", ThemeTypeVariation = "Eyebrow" }); + var row = new HBoxContainer(); + row.AddThemeConstantOverride("separation", 8); + _purebredVariantSection.AddChild(row); + foreach (var v in sp!.Variants) + { + string captured = v.Id; + var btn = new Button + { + Text = v.Name, + ToggleMode = true, + ButtonPressed = v.Id == _draft.SpeciesVariant, + FocusMode = Control.FocusModeEnum.None, + Name = v.Id, + }; + var btnRef = btn; + btn.Pressed += () => + _draft.Patch(new Godot.Collections.Dictionary + { + { "species_variant", btnRef.ButtonPressed ? captured : "" }, + }); + // Hover popover summarizes the variant's traits + detriments. + string capturedName = v.Name; + string capturedDesc = SummarizeVariant(v); + btn.MouseEntered += () => + PopoverLayer.Instance?.ShowFor(btnRef, capturedName, capturedDesc, "lineage", false); + btn.MouseExited += () => + PopoverLayer.Instance?.ScheduleClose(); + row.AddChild(btn); + } + } + + private static string SummarizeVariant(Theriapolis.Core.Data.SpeciesVariantDef v) + { + var parts = new System.Collections.Generic.List(); + foreach (var t in v.Traits) parts.Add($"• {t.Name}: {t.Description}"); + foreach (var d in v.Detriments) parts.Add($"• {d.Name} (detriment): {d.Description}"); + return parts.Count == 0 ? "(no extra traits)" : string.Join("\n", parts); + } + /// /// 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. + /// group + one detriment button group, radio-style; plus a lineage + /// picker when the species declares a lineage-axis variant). 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, @@ -160,6 +255,8 @@ public partial class StepSpecies : VBoxContainer, IStep { UpdateRadioGroup(col, "trait", chosenTrait); UpdateRadioGroup(col, "detriment", chosenDetriment); + UpdateRadioGroup(col, "lineage", + lineage == "sire" ? _draft.SireSpeciesVariant : _draft.DamSpeciesVariant); return; } @@ -178,6 +275,22 @@ public partial class StepSpecies : VBoxContainer, IStep return; } + // Lineage picker first when applicable, so the player picks + // lineage before reading the trait/detriment list (variant + // content layers on top). + if (sp.VariantAxis == "lineage" && sp.Variants.Length > 0) + { + col.AddChild(new Label { Text = "Lineage", ThemeTypeVariation = "Eyebrow" }); + string currentVariant = lineage == "sire" ? _draft.SireSpeciesVariant : _draft.DamSpeciesVariant; + BuildRadioGroup(col, "lineage", lineage, VariantsAsTraits(sp.Variants), + currentVariant, + (lin, id) => _draft.Patch(new Godot.Collections.Dictionary + { + { lin + "_species_variant", id }, + }), + isDetriment: false); + } + col.AddChild(new Label { Text = "Trait", ThemeTypeVariation = "Eyebrow" }); BuildRadioGroup(col, "trait", lineage, sp.Traits, chosenTrait, (lin, id) => OnSpeciesPickToggled(lin, "trait", id), isDetriment: false); @@ -194,6 +307,27 @@ public partial class StepSpecies : VBoxContainer, IStep } } + /// + /// Adapter — BuildRadioGroup operates on TraitDef[]; project the variant + /// list into TraitDef-shape so it can drive the same radio renderer. + /// Description summarises the variant's contents for the hover popover. + /// + private static Theriapolis.Core.Data.TraitDef[] VariantsAsTraits( + Theriapolis.Core.Data.SpeciesVariantDef[] variants) + { + var arr = new Theriapolis.Core.Data.TraitDef[variants.Length]; + for (int i = 0; i < variants.Length; i++) + { + arr[i] = new Theriapolis.Core.Data.TraitDef + { + Id = variants[i].Id, + Name = variants[i].Name, + Description = SummarizeVariant(variants[i]), + }; + } + return arr; + } + private static void BuildRadioGroup(VBoxContainer parent, string kind, string lineage, Theriapolis.Core.Data.TraitDef[] options, string selected, System.Action onPicked, bool isDetriment) diff --git a/Theriapolis.Godot/UI/CharacterDraft.cs b/Theriapolis.Godot/UI/CharacterDraft.cs index 1397f32..e945ea0 100644 --- a/Theriapolis.Godot/UI/CharacterDraft.cs +++ b/Theriapolis.Godot/UI/CharacterDraft.cs @@ -28,6 +28,23 @@ public partial class CharacterDraft : Resource [Export] public string SubclassId { get; set; } = ""; [Export] public string BackgroundId { get; set; } = ""; + /// "male" or "female". Required for every character. For + /// purebreds with sex-axis variants (Elk, Lion), drives variant + /// resolution. For hybrids, sire variant always resolves to "male" + /// and dam variant to "female" by parent-role definition — the + /// character's own Sex remains an identity field. + [Export] public string Sex { get; set; } = ""; + + /// Lineage-axis variant id picked for the purebred species + /// (e.g. "sheep" or "goat" for Ram-Folk). Empty if the picked species + /// has no lineage variant. + [Export] public string SpeciesVariant { get; set; } = ""; + + /// Hybrid: lineage-axis variant for sire's species. + [Export] public string SireSpeciesVariant { get; set; } = ""; + /// Hybrid: lineage-axis variant for dam's species. + [Export] public string DamSpeciesVariant { get; set; } = ""; + // ── Phase 6.5 hybrid origin ──────────────────────────────────────── /// True when the PC is a hybrid (two parent lineages). [Export] public bool IsHybrid { get; set; } @@ -67,6 +84,36 @@ public partial class CharacterDraft : Resource public int CladeTraitLimit(string lineage) => lineage == DominantParent ? 2 : 1; + /// + /// Resolves the active variant id for a species. + /// is "" for purebred, "sire" or "dam" for hybrid lineages. Returns + /// empty when the species has no variants or the relevant pick/sex is + /// missing. + /// + public string ResolveVariantId(Theriapolis.Core.Data.SpeciesDef? species, string role) + { + if (species is null || string.IsNullOrEmpty(species.VariantAxis)) return ""; + return species.VariantAxis switch + { + // Sex-axis: purebred uses character Sex; hybrid lineages are + // pinned by parent role (sire = male, dam = female). + "sex" => role switch + { + "sire" => "male", + "dam" => "female", + _ => Sex, + }, + // Lineage-axis: explicit per-species pick. + "lineage" => role switch + { + "sire" => SireSpeciesVariant, + "dam" => DamSpeciesVariant, + _ => SpeciesVariant, + }, + _ => "", + }; + } + /// /// Resolves the "active" clade for downstream steps (Class / Subclass /// / Background). Purebred uses ; hybrids use @@ -149,6 +196,10 @@ public partial class CharacterDraft : Resource 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; + case "sex": Sex = (string)patch[key]; break; + case "species_variant": SpeciesVariant = (string)patch[key]; break; + case "sire_species_variant": SireSpeciesVariant = (string)patch[key]; break; + case "dam_species_variant": DamSpeciesVariant = (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 065d371..4e481d4 100644 --- a/Theriapolis.Godot/UI/WizardValidation.cs +++ b/Theriapolis.Godot/UI/WizardValidation.cs @@ -33,6 +33,7 @@ public static class WizardValidation private static string? ValidateClade(CharacterDraft draft) { + if (string.IsNullOrEmpty(draft.Sex)) return "Pick a sex."; if (draft.IsHybrid) { if (string.IsNullOrEmpty(draft.SireCladeId)) return "Pick a sire clade."; @@ -85,9 +86,23 @@ public static class WizardValidation && string.IsNullOrEmpty(draft.DamChosenSpeciesDetriment)) return "Pick a dam species detriment."; + // Lineage-axis variants: each parent species needs an + // explicit lineage pick when applicable. + if (sireSp is not null && sireSp.VariantAxis == "lineage" + && string.IsNullOrEmpty(draft.SireSpeciesVariant)) + return "Pick a sire species lineage."; + if (damSp is not null && damSp.VariantAxis == "lineage" + && string.IsNullOrEmpty(draft.DamSpeciesVariant)) + return "Pick a dam species lineage."; + return null; } - return string.IsNullOrEmpty(draft.SpeciesId) ? "Pick a species." : null; + if (string.IsNullOrEmpty(draft.SpeciesId)) return "Pick a species."; + var purebredSp = CodexContent.SpeciesById(draft.SpeciesId); + if (purebredSp is not null && purebredSp.VariantAxis == "lineage" + && string.IsNullOrEmpty(draft.SpeciesVariant)) + return "Pick a species lineage."; + return null; } private static string? ValidateSkills(CharacterDraft draft)