using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; using Myra.Graphics2D; using Myra.Graphics2D.Brushes; using Myra.Graphics2D.UI; using Theriapolis.Core.Data; using Theriapolis.Core.Entities; using Theriapolis.Core.Items; using Theriapolis.Core.Rules.Character; using Theriapolis.Core.Rules.Dialogue; using Theriapolis.Core.Rules.Reputation; namespace Theriapolis.Game.Screens; /// /// Phase 6 M3 — buy/sell modal pushed from /// when a dialogue option fires the open_shop effect. /// /// Pricing applies the disposition modifier from /// : a friendly merchant sells for cheaper, an /// antagonistic one marks up. Hostile / Nemesis merchants refuse service /// outright (the screen shows a refusal line and only the close button). /// /// Phase 6 M3 ships a hand-curated stock list per merchant role /// (innkeeper / shopkeeper / smith / alchemist) — Phase 6 M5 swaps this /// for trade-route-driven inventories. /// public sealed class ShopScreen : IScreen { private static readonly string[] InnkeeperStock = { "rations_predator", "rations_prey", "poultice_universal" }; private static readonly string[] ShopkeeperStock = { "rope_claw_braid", "torch_scent_neutral", "scent_mask_basic", "rations_predator", "poultice_universal", "healers_kit" }; private static readonly string[] SmithStock = { "fang_knife", "rend_sword", "thorn_blade", "paw_axe", "hide_vest", "leather_harness", "studded_leather", "chain_shirt", "buckler", "standard_shield" }; private static readonly string[] AlchemistStock = { "poultice_universal", "poultice_canid", "healers_kit", "scent_mask_basic", "pheromone_vial_calm", "pheromone_vial_fear" }; private readonly NpcActor _npc; private readonly Character _pc; private readonly ContentResolver _content; private readonly PlayScreen _playScreen; private Game1 _game = null!; private Desktop _desktop = null!; private VerticalStackPanel _root = null!; private Label _statusLabel = null!; private VerticalStackPanel _stockList = null!; private VerticalStackPanel _bagList = null!; private bool _escWasDown = true; private bool _enterWasDown = true; public ShopScreen(NpcActor npc, Character pc, ContentResolver content, PlayScreen playScreen) { _npc = npc; _pc = pc; _content = content; _playScreen = playScreen; } public void Initialize(Game1 game) { _game = game; BuildLayout(); } private int Disposition() => EffectiveDisposition.For(_npc, _pc, _playScreen.Reputation, _content, _playScreen.World(), _playScreen.WorldSeed()); private string[] StockForRole(string roleTag) { if (string.IsNullOrEmpty(roleTag)) return ShopkeeperStock; string suffix = roleTag; int dot = roleTag.LastIndexOf('.'); if (dot >= 0) suffix = roleTag[(dot + 1)..]; return suffix.ToLowerInvariant() switch { "innkeeper" => InnkeeperStock, "smith" => SmithStock, "alchemist" => AlchemistStock, _ => ShopkeeperStock, }; } private void BuildLayout() { _root = new VerticalStackPanel { Spacing = 8, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, Padding = new Thickness(40, 24, 40, 24), Background = new SolidBrush(new Color(15, 12, 8, 240)), Width = 760, }; _root.Widgets.Add(new Label { Text = $"{_npc.DisplayName} — wares", HorizontalAlignment = HorizontalAlignment.Center, TextColor = new Color(255, 230, 170), }); int disp = Disposition(); var label = DispositionLabels.For(disp); if (!ShopPricing.ServiceAvailable(disp)) { _root.Widgets.Add(new Label { Text = $"\"I'll not deal with you. Get out before I call the constabulary.\"", HorizontalAlignment = HorizontalAlignment.Center, TextColor = new Color(220, 120, 120), Wrap = true, Width = 660, }); _root.Widgets.Add(new Label { Text = " " }); var closeRefused = new TextButton { Text = "Leave", Width = 240, HorizontalAlignment = HorizontalAlignment.Center }; closeRefused.Click += (_, _) => _game.Screens.Pop(); _root.Widgets.Add(closeRefused); _desktop = new Desktop { Root = _root }; return; } _root.Widgets.Add(new Label { Text = $"[{DispositionLabels.DisplayName(label)}] · buy ×{ShopPricing.BuyMultiplier(disp):0.00} · sell ×{ShopPricing.SellMultiplier(disp):0.00}", HorizontalAlignment = HorizontalAlignment.Center, TextColor = new Color(120, 140, 180), }); _statusLabel = new Label { Text = $"Your fangs: {_pc.CurrencyFang}", HorizontalAlignment = HorizontalAlignment.Center, TextColor = new Color(220, 200, 140), }; _root.Widgets.Add(_statusLabel); _root.Widgets.Add(new Label { Text = " " }); var twoCol = new HorizontalStackPanel { Spacing = 24 }; _stockList = new VerticalStackPanel { Spacing = 2, Width = 360 }; _stockList.Widgets.Add(new Label { Text = "BUY", TextColor = new Color(200, 180, 130) }); twoCol.Widgets.Add(_stockList); _bagList = new VerticalStackPanel { Spacing = 2, Width = 360 }; _bagList.Widgets.Add(new Label { Text = "SELL (your bag)", TextColor = new Color(200, 180, 130) }); twoCol.Widgets.Add(_bagList); _root.Widgets.Add(twoCol); _root.Widgets.Add(new Label { Text = " " }); _root.Widgets.Add(new Label { Text = "(Click an item to buy/sell · Esc / Enter to close)", HorizontalAlignment = HorizontalAlignment.Center, TextColor = new Color(120, 110, 100), }); RefreshLists(); _desktop = new Desktop { Root = _root }; } private void RefreshLists() { // Rebuild buy list. for (int i = _stockList.Widgets.Count - 1; i >= 1; i--) _stockList.Widgets.RemoveAt(i); int disp = Disposition(); var stock = StockForRole(_npc.RoleTag); foreach (var id in stock) { if (!_content.Items.TryGetValue(id, out var def)) continue; int price = ShopPricing.BuyPriceFor((int)System.Math.Round(def.CostFang), disp); var btn = new TextButton { Text = $" {def.Name,-26} {price,4}f", Width = 350, HorizontalAlignment = HorizontalAlignment.Left, }; string capturedId = def.Id; btn.Click += (_, _) => TryBuy(capturedId); _stockList.Widgets.Add(btn); } // Rebuild sell list — group by item name + count, sell one at a time. for (int i = _bagList.Widgets.Count - 1; i >= 1; i--) _bagList.Widgets.RemoveAt(i); var grouped = _pc.Inventory.Items .Where(it => it.EquippedAt is null) // can't sell equipped gear without unequipping first .GroupBy(it => it.Def.Id) .OrderBy(g => g.Key, System.StringComparer.Ordinal); foreach (var grp in grouped) { var def = grp.First().Def; int totalQty = grp.Sum(it => it.Qty); int price = ShopPricing.SellPriceFor((int)System.Math.Round(def.CostFang), disp); var btn = new TextButton { Text = $" {def.Name,-22} ×{totalQty,2} @ {price,4}f", Width = 350, HorizontalAlignment = HorizontalAlignment.Left, }; string capturedId = def.Id; btn.Click += (_, _) => TrySell(capturedId); _bagList.Widgets.Add(btn); } _statusLabel.Text = $"Your fangs: {_pc.CurrencyFang}"; } private void TryBuy(string itemId) { if (!_content.Items.TryGetValue(itemId, out var def)) return; int disp = Disposition(); int price = ShopPricing.BuyPriceFor((int)System.Math.Round(def.CostFang), disp); if (_pc.CurrencyFang < price) { _statusLabel.Text = $"Not enough fangs ({_pc.CurrencyFang} / {price})."; return; } _pc.CurrencyFang -= price; _pc.Inventory.Add(def, 1); _playScreen.Reputation.Submit(new RepEvent { Kind = RepEventKind.Trade, FactionId = _npc.FactionId, RoleTag = _npc.RoleTag, Magnitude = 1, // small positive bump per successful purchase Note = $"bought {def.Id}", TimestampSeconds = _playScreen.ClockSeconds(), }, _content.Factions); RefreshLists(); } private void TrySell(string itemId) { if (!_content.Items.TryGetValue(itemId, out var def)) return; int disp = Disposition(); int price = ShopPricing.SellPriceFor((int)System.Math.Round(def.CostFang), disp); // Remove ONE unit (smallest stack first to keep stacks tidy). var stack = _pc.Inventory.Items.Where(it => it.Def.Id == itemId && it.EquippedAt is null) .OrderBy(it => it.Qty) .FirstOrDefault(); if (stack is null) return; stack.Qty--; if (stack.Qty <= 0) _pc.Inventory.Remove(stack); _pc.CurrencyFang += price; RefreshLists(); } public void Update(GameTime gt) { var ks = Keyboard.GetState(); bool esc = ks.IsKeyDown(Keys.Escape); bool ent = ks.IsKeyDown(Keys.Enter); bool escPressed = esc && !_escWasDown; bool entPressed = ent && !_enterWasDown; _escWasDown = esc; _enterWasDown = ent; if (escPressed || entPressed) _game.Screens.Pop(); } public void Draw(GameTime gt, SpriteBatch sb) => _desktop.Render(); public void Deactivate() { } public void Reactivate() { RefreshLists(); } }