diff --git a/Theriapolis.Godot/Scenes/Aside.cs b/Theriapolis.Godot/Scenes/Aside.cs index a7553e0..97e8859 100644 --- a/Theriapolis.Godot/Scenes/Aside.cs +++ b/Theriapolis.Godot/Scenes/Aside.cs @@ -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 }); + } + + /// + /// 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. + /// + 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); diff --git a/Theriapolis.Godot/Scenes/Steps/StepClade.cs b/Theriapolis.Godot/Scenes/Steps/StepClade.cs index 779df89..e0c5458 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepClade.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepClade.cs @@ -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 _purebredCards = new(); private readonly Dictionary _sireCards = new(); private readonly Dictionary _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 _sireBonusButtons = new(); + private readonly Dictionary _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(); + } + + /// + /// 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. + /// + 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 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 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 }, + }); + } + /// /// 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/StepReview.cs b/Theriapolis.Godot/Scenes/Steps/StepReview.cs new file mode 100644 index 0000000..83f5077 --- /dev/null +++ b/Theriapolis.Godot/Scenes/Steps/StepReview.cs @@ -0,0 +1,160 @@ +using Godot; +using Theriapolis.GodotHost.UI; + +namespace Theriapolis.GodotHost.Scenes.Steps; + +/// +/// Step VIII — Sign. Direct port of StepReview in +/// src/steps.jsx: 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. +/// +public partial class StepReview : VBoxContainer, IStep +{ + /// 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.). + [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(), + }; +} diff --git a/Theriapolis.Godot/Scenes/Steps/StepSkills.cs b/Theriapolis.Godot/Scenes/Steps/StepSkills.cs index 72873c4..a6e607d 100644 --- a/Theriapolis.Godot/Scenes/Steps/StepSkills.cs +++ b/Theriapolis.Godot/Scenes/Steps/StepSkills.cs @@ -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(cls?.SkillOptions ?? System.Array.Empty()); var chosen = new HashSet(_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(); diff --git a/Theriapolis.Godot/Scenes/Wizard.cs b/Theriapolis.Godot/Scenes/Wizard.cs index c18823f..e818803 100644 --- a/Theriapolis.Godot/Scenes/Wizard.cs +++ b/Theriapolis.Godot/Scenes/Wizard.cs @@ -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() diff --git a/Theriapolis.Godot/UI/AbilityCalc.cs b/Theriapolis.Godot/UI/AbilityCalc.cs index bf8b79b..393eb0c 100644 --- a/Theriapolis.Godot/UI/AbilityCalc.cs +++ b/Theriapolis.Godot/UI/AbilityCalc.cs @@ -24,20 +24,35 @@ public static class AbilityCalc var list = new List(); 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); } - var sp = CodexContent.SpeciesById(draft.EffectiveSpeciesId); - if (sp is not null) AddDictSource(list, ability, sp.Name, sp.AbilityMods); - return list; } + /// 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). + private static void AddCladeChoice(List 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); diff --git a/Theriapolis.Godot/UI/CharacterDraft.cs b/Theriapolis.Godot/UI/CharacterDraft.cs index 3368edc..5d3a6d9 100644 --- a/Theriapolis.Godot/UI/CharacterDraft.cs +++ b/Theriapolis.Godot/UI/CharacterDraft.cs @@ -38,6 +38,15 @@ public partial class CharacterDraft : Resource /// "sire" or "dam" — which parent the PC presents as. [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. + /// Ability key picked from sire's clade mods (e.g. "STR"). + [Export] public string SireChosenAbility { get; set; } = ""; + /// Ability key picked from dam's clade mods. + [Export] public string DamChosenAbility { get; set; } = ""; + /// /// Resolves the "active" clade for downstream steps (Class / Subclass /// / Background). Purebred uses ; hybrids use @@ -106,12 +115,14 @@ public partial class CharacterDraft : Resource case "stat_assign": StatAssign = (Godot.Collections.Dictionary)patch[key]; break; case "chosen_skills": ChosenSkills = (Godot.Collections.Array)patch[key]; break; case "character_name": CharacterName = (string)patch[key]; break; - case "is_hybrid": IsHybrid = (bool)patch[key]; break; - case "sire_clade_id": SireCladeId = (string)patch[key]; break; - case "sire_species_id": SireSpeciesId = (string)patch[key]; break; - 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 "is_hybrid": IsHybrid = (bool)patch[key]; break; + case "sire_clade_id": SireCladeId = (string)patch[key]; break; + case "sire_species_id": SireSpeciesId = (string)patch[key]; break; + 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; diff --git a/Theriapolis.Godot/UI/WizardValidation.cs b/Theriapolis.Godot/UI/WizardValidation.cs index ac48e89..eb85dd0 100644 --- a/Theriapolis.Godot/UI/WizardValidation.cs +++ b/Theriapolis.Godot/UI/WizardValidation.cs @@ -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;