using Godot; using System.Collections.Generic; using System.Linq; using Theriapolis.GodotHost.Scenes.Widgets; using Theriapolis.GodotHost.UI; namespace Theriapolis.GodotHost.Scenes.Steps; /// /// Step V — Abilities. Direct port of StepStats in /// src/steps.jsx per GODOT_PORTING_GUIDE.md §7.4: pool of 6 /// dice, 6 ability slots, drag-and-drop assignment with pool→slot, /// slot→slot (swap), and slot→pool semantics. /// /// Two methods supported (CharacterDraft.StatMethod): "array" uses the /// standard array [15, 14, 13, 12, 10, 8]; "roll" generates each pool /// value as a 4d6-drop-lowest roll. A Reroll button is exposed in roll /// mode. Auto-assign sorts the remaining pool descending and places the /// largest values into the empty slots ordered by class primary /// abilities first. /// /// The step rebuilds the entire token tree on each Changed signal, so /// drag handlers just compute the new state and call Patch — no manual /// reparenting. /// public partial class StepStats : VBoxContainer, IStep { private static readonly string[] Abilities = { "STR", "DEX", "CON", "INT", "WIS", "CHA" }; private static readonly System.Random Rng = new(); private CharacterDraft _draft = null!; private AbilityPool _pool = null!; private readonly Dictionary _slots = new(); private readonly Dictionary _bonusChips = new(); private readonly Dictionary _finalLabels = new(); private readonly Dictionary _dModLabels = new(); private Button _arrayBtn = null!; private Button _rollBtn = null!; private Button _rerollBtn = null!; private Button _autoBtn = null!; public void Bind(CharacterDraft draft) { _draft = draft; _draft.Changed += Refresh; Build(); } public string? Validate() { int n = _draft?.StatAssign.Count ?? 0; return n == 6 ? null : $"Assign all six abilities ({n}/6)."; } private void Build() { AddThemeConstantOverride("separation", 18); var intro = new VBoxContainer(); intro.AddThemeConstantOverride("separation", 6); AddChild(intro); intro.AddChild(new Label { Text = "FOLIO VI · ABILITIES", ThemeTypeVariation = "Eyebrow" }); intro.AddChild(new Label { Text = "Assign your Ability Scores", ThemeTypeVariation = "H2" }); intro.AddChild(new Label { Text = "Pick a method, then drag a value from the pool into one of the six " + "ability slots. Drag between slots to swap, or drag back to the " + "pool to unassign. Auto Assign places the remaining pool into the " + "empty slots, prioritising your calling's primary abilities.", AutowrapMode = TextServer.AutowrapMode.WordSmart, }); // Method + action toolbar var toolbar = new HBoxContainer(); toolbar.AddThemeConstantOverride("separation", 12); AddChild(toolbar); _arrayBtn = new Button { Text = "Standard Array" }; _arrayBtn.Pressed += () => SwitchMethod("array"); toolbar.AddChild(_arrayBtn); _rollBtn = new Button { Text = "Roll 4d6 (drop lowest)" }; _rollBtn.Pressed += () => SwitchMethod("roll"); toolbar.AddChild(_rollBtn); var spacer = new Control { SizeFlagsHorizontal = SizeFlags.ExpandFill }; toolbar.AddChild(spacer); _rerollBtn = new Button { Text = "Reroll" }; _rerollBtn.Pressed += Reroll; toolbar.AddChild(_rerollBtn); _autoBtn = new Button { Text = "Auto Assign" }; _autoBtn.Pressed += AutoAssign; toolbar.AddChild(_autoBtn); var poolBox = new VBoxContainer(); poolBox.AddThemeConstantOverride("separation", 6); AddChild(poolBox); poolBox.AddChild(new Label { Text = "AVAILABLE" }); _pool = new AbilityPool(); _pool.Dropped += OnPoolDropped; poolBox.AddChild(_pool); var slotsBox = new VBoxContainer(); slotsBox.AddThemeConstantOverride("separation", 8); AddChild(slotsBox); slotsBox.AddChild(new Label { Text = "ABILITIES" }); foreach (var ab in Abilities) { string captured = ab; // capture for the closure below var row = new HBoxContainer(); row.AddThemeConstantOverride("separation", 12); slotsBox.AddChild(row); row.AddChild(new Label { Text = captured, CustomMinimumSize = new Vector2(64, 0), VerticalAlignment = VerticalAlignment.Center, }); var slot = new AbilitySlot { Ability = captured }; slot.Dropped += (Godot.Collections.Dictionary payload) => HandleSlotDrop(payload, captured); _slots[captured] = slot; row.AddChild(slot); // Bonus chip — hover for the per-source breakdown. var bonus = new TraitChip { TraitName = "+0", Description = "" }; bonus.CustomMinimumSize = new Vector2(56, 0); _bonusChips[captured] = bonus; row.AddChild(bonus); // Final score (= base + total bonus). Sized to match the // AbilityToken numeric label so the 'before / after' values // read at the same visual weight. var finalLbl = new Label { Text = "—", CustomMinimumSize = new Vector2(56, 0), HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center, }; finalLbl.AddThemeFontSizeOverride("font_size", 22); _finalLabels[captured] = finalLbl; row.AddChild(finalLbl); // d20 modifier from final score. var dModLbl = new Label { Text = "", CustomMinimumSize = new Vector2(48, 0), HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center, }; _dModLabels[captured] = dModLbl; row.AddChild(dModLbl); } Refresh(); } // ────────────────────────────────────────────────────────────────────── // Refresh — rebuild token children from CharacterDraft state. private void Refresh() { if (_pool is null) return; // Scroll preservation now handled centrally by Wizard.UpdateChrome // / Wizard._Process, which snapshots before this Refresh fires. // Toolbar reflects current method and assignment state. bool isRoll = _draft.StatMethod == "roll"; _rerollBtn.Visible = isRoll; _arrayBtn.Disabled = !isRoll; // greyed when already on Array _rollBtn.Disabled = isRoll; // greyed when already on Roll _autoBtn.Disabled = _draft.StatPool.Count == 0; // Pool: one token per StatPool entry. Free() (synchronous) so old // children disappear *before* AddChild runs and the layout pass // doesn't see a transient doubled-content state. foreach (var c in _pool.GetChildren()) c.Free(); for (int i = 0; i < _draft.StatPool.Count; i++) { var token = new AbilityToken { Value = _draft.StatPool[i], Origin = "pool", OriginPoolIdx = i, }; _pool.AddChild(token); } // Bonus / final / d20 mod per ability — updated in place from the // computed mod sources so the row stays stable across drops. foreach (var ab in Abilities) { int bonus = AbilityCalc.TotalBonus(ab, _draft); int baseScore = AbilityCalc.BaseScore(ab, _draft); int final = baseScore + bonus; int dMod = AbilityCalc.D20Modifier(final); _bonusChips[ab].SetTrait( AbilityCalc.FormatSigned(bonus), AbilityCalc.FormatBreakdown(AbilityCalc.Sources(ab, _draft)), tag: "bonus"); _finalLabels[ab].Text = baseScore == 0 ? "—" : final.ToString(); _dModLabels[ab].Text = baseScore == 0 ? "" : AbilityCalc.FormatSigned(dMod); } // Slots: token if assigned, otherwise dash placeholder. foreach (var (ab, slot) in _slots) { foreach (var c in slot.GetChildren()) c.Free(); if (_draft.StatAssign.ContainsKey(ab)) { int v = (int)_draft.StatAssign[ab]; var token = new AbilityToken { Value = v, Origin = "slot", OriginAbility = ab, }; slot.AddChild(token); } else { slot.AddChild(new Label { Text = "—", HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, }); } } } // ────────────────────────────────────────────────────────────────────── // Drop handlers — compute new state and Patch. // Mirrors handleDrop / dropToPool in steps.jsx exactly. private void HandleSlotDrop(Godot.Collections.Dictionary payload, string destAbility) { var pool = _draft.StatPool.Duplicate(); var assign = (Godot.Collections.Dictionary)_draft.StatAssign.Duplicate(); string from = payload["from"].AsString(); int value = payload["value"].AsInt32(); if (from == "pool") { // pool → slot int idx = payload["idx"].AsInt32(); if (assign.ContainsKey(destAbility)) { // Bump the existing slot value back to the pool. pool.Add((int)assign[destAbility]); } if (idx >= 0 && idx < pool.Count) pool.RemoveAt(idx); assign[destAbility] = value; } else if (from == "slot") { string srcAbility = payload["ability"].AsString(); if (srcAbility == destAbility) return; // Swap if dest is filled, otherwise move. if (assign.ContainsKey(destAbility)) { var dstV = assign[destAbility]; assign[srcAbility] = dstV; } else { assign.Remove(srcAbility); } assign[destAbility] = value; } _draft.Patch(new Godot.Collections.Dictionary { { "stat_pool", pool }, { "stat_assign", assign }, }); } // ────────────────────────────────────────────────────────────────────── // Method switch + roll + auto-assign private void SwitchMethod(string method) { var pool = method == "roll" ? RollSixSet() : ArrayPool(); _draft.Patch(new Godot.Collections.Dictionary { { "stat_method", method }, { "stat_pool", pool }, { "stat_assign", new Godot.Collections.Dictionary() }, }); } private void Reroll() { if (_draft.StatMethod != "roll") return; _draft.Patch(new Godot.Collections.Dictionary { { "stat_pool", RollSixSet() }, { "stat_assign", new Godot.Collections.Dictionary() }, }); } private void AutoAssign() { if (_draft.StatPool.Count == 0) return; // Empty slots, ordered DESCENDING by their clade+species bonus — // highest pool value goes to the ability with the highest bonus // already coming in, maximising the final score on each. Ties // broken by class.PrimaryAbility order. var cls = CodexContent.Class(_draft.ClassId); var primary = cls?.PrimaryAbility ?? System.Array.Empty(); var emptyAbilities = Abilities .Where(a => !_draft.StatAssign.ContainsKey(a)) .OrderByDescending(a => AbilityCalc.TotalBonus(a, _draft)) .ThenBy(a => { int idx = System.Array.IndexOf(primary, a); return idx < 0 ? 99 : idx; }) .ToList(); // Pool sorted descending — biggest values go to highest priority. var sortedPool = _draft.StatPool.OrderByDescending(v => v).ToList(); var assign = (Godot.Collections.Dictionary)_draft.StatAssign.Duplicate(); var newPool = new Godot.Collections.Array(); for (int i = 0; i < sortedPool.Count; i++) { if (i < emptyAbilities.Count) assign[emptyAbilities[i]] = sortedPool[i]; else newPool.Add(sortedPool[i]); } _draft.Patch(new Godot.Collections.Dictionary { { "stat_pool", newPool }, { "stat_assign", assign }, }); } private static Godot.Collections.Array ArrayPool() => new() { 15, 14, 13, 12, 10, 8 }; private static Godot.Collections.Array RollSixSet() { var pool = new Godot.Collections.Array(); for (int i = 0; i < 6; i++) pool.Add(RollFourDropLowest()); return pool; } private static int RollFourDropLowest() { // 4d6, drop lowest, sum the rest. Range [3, 18]. int[] rolls = new int[4]; for (int i = 0; i < 4; i++) rolls[i] = Rng.Next(1, 7); System.Array.Sort(rolls); return rolls[1] + rolls[2] + rolls[3]; } // ────────────────────────────────────────────────────────────────────── private void OnPoolDropped(Godot.Collections.Dictionary payload) { if (payload["from"].AsString() != "slot") return; var assign = (Godot.Collections.Dictionary)_draft.StatAssign.Duplicate(); var pool = _draft.StatPool.Duplicate(); string ability = payload["ability"].AsString(); if (!assign.ContainsKey(ability)) return; int v = (int)assign[ability]; assign.Remove(ability); pool.Add(v); _draft.Patch(new Godot.Collections.Dictionary { { "stat_pool", pool }, { "stat_assign", assign }, }); } }