Trading mod: shops as data and the trade window
This commit is contained in:
commit
89ee23f1e6
19 files changed
+1595
No files matched your search
@@ -0,0 +1,30 @@
|
|||||||
|
# Line endings: LF everywhere, on every OS, whatever core.autocrlf says.
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# cmd.exe misreads labels and goto in LF batch files, so they stay CRLF.
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
|
||||||
|
# Binaries: never converted, never diffed as text.
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.webp binary
|
||||||
|
*.svg text
|
||||||
|
*.ogg binary
|
||||||
|
*.wav binary
|
||||||
|
*.mp3 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.otf binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.dll binary
|
||||||
|
*.so binary
|
||||||
|
*.dylib binary
|
||||||
|
*.pdb binary
|
||||||
|
*.exe binary
|
||||||
|
*.zip binary
|
||||||
|
*.pck binary
|
||||||
|
*.moc3 binary
|
||||||
|
*.res binary
|
||||||
|
*.scn binary
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"Kind": "item",
|
||||||
|
"Items": [
|
||||||
|
{
|
||||||
|
"Id": "money/coin",
|
||||||
|
"Name": "trading/item/coin/name",
|
||||||
|
"Description": "trading/item/coin/desc",
|
||||||
|
"Cost": 1,
|
||||||
|
"Sprite": "money/gold_coin",
|
||||||
|
"ItemType": "Resource,CantDrop",
|
||||||
|
"ItemAction": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
mod_trading_name = Trading
|
||||||
|
mod_trading_desc = Merchants' shops: what they sell, what they buy back and at which price ("Kind": "shop" data), and a trade window of two inventories, the merchant's goods and the player's bag. Money is an ordinary item, the gold coin.
|
||||||
|
trading/item/coin/name = Gold coin
|
||||||
|
trading/item/coin/desc = Common coin. Merchants take and pay nothing else.
|
||||||
|
trading/ui/merchant_goods = Merchant's goods
|
||||||
|
trading/ui/your_bag = Your bag
|
||||||
|
trading/ui/hint = Pick goods on the left to buy or an item on the right to sell. Esc closes.
|
||||||
|
trading/ui/select = Pick goods of the merchant or an item in your bag.
|
||||||
|
trading/ui/badge_merchant = Merchant's
|
||||||
|
trading/ui/badge_yours = Yours
|
||||||
|
trading/ui/unit_price = Price
|
||||||
|
trading/ui/wallet = Wallet
|
||||||
|
trading/ui/you_have = You have
|
||||||
|
trading/ui/not_bought = The merchant does not buy this.
|
||||||
|
trading/ui/no_money = Not enough coins.
|
||||||
|
trading/ui/nothing_to_sell = Nothing to sell.
|
||||||
|
trading/ui/quantity = Quantity
|
||||||
|
trading/ui/all = All
|
||||||
|
trading/ui/total = Total: {0} coins
|
||||||
|
trading/ui/buy = Buy
|
||||||
|
trading/ui/sell = Sell
|
||||||
|
trading/ui/bought = Bought: {0} ×{1} for {2} coins
|
||||||
|
trading/ui/sold = Sold: {0} ×{1} for {2} coins
|
||||||
|
trading/ui/deal_failed = The deal did not go through.
|
||||||
|
trading/ui/price_tag = {0} c
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
mod_trading_name = Торговля
|
||||||
|
mod_trading_desc = Лавки торговцев: что продают, что скупают и почём (данные «Kind»: «shop»), и окно торговли из двух инвентарей — товары торговца и сумка игрока. Деньги — обычный предмет, золотая монета.
|
||||||
|
trading/item/coin/name = Золотая монета
|
||||||
|
trading/item/coin/desc = Ходовая монета. Торговцы берут и платят только ею.
|
||||||
|
trading/ui/merchant_goods = Товары торговца
|
||||||
|
trading/ui/your_bag = Ваша сумка
|
||||||
|
trading/ui/hint = Выберите товар слева, чтобы купить, или вещь справа, чтобы продать. Esc — закрыть.
|
||||||
|
trading/ui/select = Выберите товар у торговца или вещь в своей сумке.
|
||||||
|
trading/ui/badge_merchant = У торговца
|
||||||
|
trading/ui/badge_yours = Ваше
|
||||||
|
trading/ui/unit_price = Цена
|
||||||
|
trading/ui/wallet = В кошельке
|
||||||
|
trading/ui/you_have = У вас
|
||||||
|
trading/ui/not_bought = Торговец это не покупает.
|
||||||
|
trading/ui/no_money = Не хватает монет.
|
||||||
|
trading/ui/nothing_to_sell = Продавать нечего.
|
||||||
|
trading/ui/quantity = Количество
|
||||||
|
trading/ui/all = Все
|
||||||
|
trading/ui/total = Итого: {0} мон.
|
||||||
|
trading/ui/buy = Купить
|
||||||
|
trading/ui/sell = Продать
|
||||||
|
trading/ui/bought = Куплено: {0} ×{1} за {2} мон.
|
||||||
|
trading/ui/sold = Продано: {0} ×{1} за {2} мон.
|
||||||
|
trading/ui/deal_failed = Сделка не состоялась.
|
||||||
|
trading/ui/price_tag = {0} мон.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"id": "trading",
|
||||||
|
"name": "Trading",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "ct",
|
||||||
|
"description": "Shops: merchants defined as data (\"Kind\": \"shop\": what they sell, what they buy back and at which price) and a trade window of two inventories side by side, the merchant's goods and the player's bag, with a deal card between them. Money is an ordinary inventory item, the gold coin money/coin by default. Scenario action: trade.open shop=<id>.",
|
||||||
|
"category": "gameplay",
|
||||||
|
"sources": [
|
||||||
|
"src/**/*.cs"
|
||||||
|
],
|
||||||
|
"test_sources": [
|
||||||
|
"tests/*.cs"
|
||||||
|
],
|
||||||
|
"depends": ["ui_base>=1", "items_base>=1"],
|
||||||
|
"test_suites": [
|
||||||
|
{"id": "trade", "activate": ["--itest=trade"]}
|
||||||
|
],
|
||||||
|
"ui": [
|
||||||
|
{
|
||||||
|
"id": "trade",
|
||||||
|
"kind": "overlay",
|
||||||
|
"slot": "ModLastPanel",
|
||||||
|
"scene_path": "scenes/trade_panel.tscn",
|
||||||
|
"once": true,
|
||||||
|
"show_on_start": false,
|
||||||
|
"priority": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
[gd_scene format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://mods/trading/src/TradePanel.cs" id="1_script"]
|
||||||
|
|
||||||
|
[node name="TradePanel" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
mouse_filter = 1
|
||||||
|
script = ExtResource("1_script")
|
||||||
|
|
||||||
|
[node name="Backdrop" type="ColorRect" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
mouse_filter = 2
|
||||||
|
|
||||||
|
[node name="Panel" type="Panel" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
offset_left = 40.0
|
||||||
|
offset_top = 78.0
|
||||||
|
offset_right = -40.0
|
||||||
|
offset_bottom = -30.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
mouse_filter = 1
|
||||||
|
|
||||||
|
[node name="Margin" type="MarginContainer" parent="Panel"]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
theme_override_constants/margin_left = 24
|
||||||
|
theme_override_constants/margin_top = 16
|
||||||
|
theme_override_constants/margin_right = 24
|
||||||
|
theme_override_constants/margin_bottom = 14
|
||||||
|
mouse_filter = 1
|
||||||
|
|
||||||
|
[node name="Root" type="VBoxContainer" parent="Panel/Margin"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 12
|
||||||
|
mouse_filter = 1
|
||||||
|
|
||||||
|
[node name="Header" type="HBoxContainer" parent="Panel/Margin/Root"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 18
|
||||||
|
|
||||||
|
[node name="Title" type="Label" parent="Panel/Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 24
|
||||||
|
|
||||||
|
[node name="HeaderSpacer" type="Control" parent="Panel/Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
mouse_filter = 2
|
||||||
|
|
||||||
|
[node name="Wallet" type="HBoxContainer" parent="Panel/Margin/Root/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="WalletIcon" type="TextureRect" parent="Panel/Margin/Root/Header/Wallet"]
|
||||||
|
custom_minimum_size = Vector2(30, 30)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
expand_mode = 1
|
||||||
|
stretch_mode = 5
|
||||||
|
mouse_filter = 2
|
||||||
|
|
||||||
|
[node name="WalletLabel" type="Label" parent="Panel/Margin/Root/Header/Wallet"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 20
|
||||||
|
|
||||||
|
[node name="CloseButton" type="Button" parent="Panel/Margin/Root/Header"]
|
||||||
|
custom_minimum_size = Vector2(38, 38)
|
||||||
|
layout_mode = 2
|
||||||
|
focus_mode = 0
|
||||||
|
text = "✕"
|
||||||
|
|
||||||
|
[node name="Rule" type="Panel" parent="Panel/Margin/Root"]
|
||||||
|
custom_minimum_size = Vector2(0, 1)
|
||||||
|
layout_mode = 2
|
||||||
|
mouse_filter = 2
|
||||||
|
|
||||||
|
[node name="Body" type="HBoxContainer" parent="Panel/Margin/Root"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
theme_override_constants/separation = 14
|
||||||
|
|
||||||
|
[node name="Merchant" type="VBoxContainer" parent="Panel/Margin/Root/Body"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="MerchantTitle" type="Label" parent="Panel/Margin/Root/Body/Merchant"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 13
|
||||||
|
|
||||||
|
[node name="MerchantScroll" type="ScrollContainer" parent="Panel/Margin/Root/Body/Merchant"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
horizontal_scroll_mode = 0
|
||||||
|
|
||||||
|
[node name="MerchantGrid" type="Control" parent="Panel/Margin/Root/Body/Merchant/MerchantScroll"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
mouse_filter = 0
|
||||||
|
|
||||||
|
[node name="Deal" type="PanelContainer" parent="Panel/Margin/Root/Body"]
|
||||||
|
custom_minimum_size = Vector2(400, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="DealScroll" type="ScrollContainer" parent="Panel/Margin/Root/Body/Deal"]
|
||||||
|
layout_mode = 2
|
||||||
|
horizontal_scroll_mode = 0
|
||||||
|
|
||||||
|
[node name="DealCard" type="VBoxContainer" parent="Panel/Margin/Root/Body/Deal/DealScroll"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_constants/separation = 14
|
||||||
|
|
||||||
|
[node name="Player" type="VBoxContainer" parent="Panel/Margin/Root/Body"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="PlayerTitle" type="Label" parent="Panel/Margin/Root/Body/Player"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 13
|
||||||
|
|
||||||
|
[node name="PlayerScroll" type="ScrollContainer" parent="Panel/Margin/Root/Body/Player"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
horizontal_scroll_mode = 0
|
||||||
|
|
||||||
|
[node name="PlayerGrid" type="Control" parent="Panel/Margin/Root/Body/Player/PlayerScroll"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
mouse_filter = 0
|
||||||
|
|
||||||
|
[node name="Footer" type="Label" parent="Panel/Margin/Root"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 12
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System;
|
||||||
|
using Cthangover.Core.Factories;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Trading
|
||||||
|
{
|
||||||
|
/// <summary>Shops collected by the shared entity resolver (<c>"Kind": "shop"</c>).</summary>
|
||||||
|
public class ShopFactory : FileFactory<ShopInfo>
|
||||||
|
{
|
||||||
|
private static readonly Lazy<ShopFactory> instance = new(() => new ShopFactory());
|
||||||
|
|
||||||
|
/// <summary>Shared instance.</summary>
|
||||||
|
public static ShopFactory Instance => instance.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Cthangover.Core.Factories;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Trading
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A merchant as data (<c>"Kind": "shop"</c>), opened by
|
||||||
|
/// <c>action trade.open shop=<id></c>. The stock of <see cref="Sells"/> is
|
||||||
|
/// unlimited and the shop has no state of its own: what the player sells
|
||||||
|
/// disappears into the merchant's pocket, so nothing needs saving except the
|
||||||
|
/// player's inventory. Money is an ordinary inventory item
|
||||||
|
/// (<see cref="Currency"/>), which keeps it in the items save slot and lets
|
||||||
|
/// scenarios pay with <c>item.add</c>.
|
||||||
|
/// </summary>
|
||||||
|
[EntityKind("shop")]
|
||||||
|
public class ShopInfo : IIdentifiable
|
||||||
|
{
|
||||||
|
/// <summary>Currency item used when a shop does not name one.</summary>
|
||||||
|
public const string DefaultCurrency = "money/coin";
|
||||||
|
|
||||||
|
/// <summary>Shop id, the <c>shop=</c> argument of <c>trade.open</c>.</summary>
|
||||||
|
[JsonPropertyName("Id")]
|
||||||
|
public string ID { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Locale key of the window title (the merchant's name).</summary>
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Item id of the money this shop takes and pays.</summary>
|
||||||
|
public string Currency { get; set; } = DefaultCurrency;
|
||||||
|
|
||||||
|
/// <summary>What the merchant sells to the player, in display order.</summary>
|
||||||
|
public List<ShopOffer> Sells { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>What the merchant buys from the player; anything else is refused.</summary>
|
||||||
|
public List<ShopOffer> Buys { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>The offer for buying <paramref name="itemId"/> from the merchant, or <c>null</c>.</summary>
|
||||||
|
public ShopOffer FindSell(string itemId) => Find(Sells, itemId);
|
||||||
|
|
||||||
|
/// <summary>The offer for selling <paramref name="itemId"/> to the merchant, or <c>null</c>.</summary>
|
||||||
|
public ShopOffer FindBuy(string itemId) => Find(Buys, itemId);
|
||||||
|
|
||||||
|
private static ShopOffer Find(List<ShopOffer> offers, string itemId)
|
||||||
|
{
|
||||||
|
if (offers == null || string.IsNullOrEmpty(itemId))
|
||||||
|
return null;
|
||||||
|
foreach (var offer in offers)
|
||||||
|
{
|
||||||
|
if (offer != null && offer.Price > 0 && string.Equals(offer.Item, itemId, StringComparison.Ordinal))
|
||||||
|
return offer;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace Cthangover.Mods.Trading
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One line of a shop's price list: an item id and its unit price in the
|
||||||
|
/// shop's currency. The same type serves both directions — in
|
||||||
|
/// <see cref="ShopInfo.Sells"/> it is what the player pays, in
|
||||||
|
/// <see cref="ShopInfo.Buys"/> what the merchant pays. An offer with a price
|
||||||
|
/// below 1 is ignored by <see cref="TradeLedger"/>: a free deal is a gift and
|
||||||
|
/// belongs in a scenario.
|
||||||
|
/// </summary>
|
||||||
|
public class ShopOffer
|
||||||
|
{
|
||||||
|
/// <summary>Item id (<c>"Kind": "item"</c>).</summary>
|
||||||
|
public string Item { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Unit price in the shop's currency.</summary>
|
||||||
|
public int Price { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using Cthangover.Core.Items;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Trading
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The arithmetic of a deal, apart from the window so it can be tested and
|
||||||
|
/// reused by a scenario action later. A deal is all or nothing: it is checked
|
||||||
|
/// first (offer exists, positive quantity, enough money or goods) and only
|
||||||
|
/// then both stacks change, so a refused deal leaves the inventory untouched.
|
||||||
|
/// </summary>
|
||||||
|
public static class TradeLedger
|
||||||
|
{
|
||||||
|
/// <summary>The player's money in the shop's currency.</summary>
|
||||||
|
public static int Money(IInventory inventory, ShopInfo shop)
|
||||||
|
{
|
||||||
|
return inventory == null || shop == null ? 0 : inventory.CheckCount(shop.Currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How many units of <paramref name="itemId"/> the player can afford; 0 when the shop does not sell it.</summary>
|
||||||
|
public static int MaxBuy(IInventory inventory, ShopInfo shop, string itemId)
|
||||||
|
{
|
||||||
|
var offer = shop?.FindSell(itemId);
|
||||||
|
return offer == null ? 0 : Money(inventory, shop) / offer.Price;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How many units of <paramref name="itemId"/> the player can sell; 0 when the merchant does not buy it.</summary>
|
||||||
|
public static int MaxSell(IInventory inventory, ShopInfo shop, string itemId)
|
||||||
|
{
|
||||||
|
if (inventory == null || shop?.FindBuy(itemId) == null || itemId == shop.Currency)
|
||||||
|
return 0;
|
||||||
|
return inventory.CheckCount(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Buys <paramref name="count"/> units: takes their price in money and adds
|
||||||
|
/// the items. Returns <c>false</c> and changes nothing when the shop does not
|
||||||
|
/// sell the item, the count is not positive or the money is short.
|
||||||
|
/// </summary>
|
||||||
|
public static bool Buy(IInventory inventory, ShopInfo shop, string itemId, int count)
|
||||||
|
{
|
||||||
|
if (count <= 0 || count > MaxBuy(inventory, shop, itemId))
|
||||||
|
return false;
|
||||||
|
if (!inventory.Remove(shop.Currency, shop.FindSell(itemId).Price * count))
|
||||||
|
return false;
|
||||||
|
inventory.Add(itemId, count);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sells <paramref name="count"/> units: removes the items and pays their
|
||||||
|
/// price. Returns <c>false</c> and changes nothing when the merchant does not
|
||||||
|
/// buy the item, the count is not positive or the player has fewer units.
|
||||||
|
/// </summary>
|
||||||
|
public static bool Sell(IInventory inventory, ShopInfo shop, string itemId, int count)
|
||||||
|
{
|
||||||
|
if (count <= 0 || count > MaxSell(inventory, shop, itemId))
|
||||||
|
return false;
|
||||||
|
if (!inventory.Remove(itemId, count))
|
||||||
|
return false;
|
||||||
|
inventory.Add(shop.Currency, shop.FindBuy(itemId).Price * count);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Cthangover.Core.Actions;
|
||||||
|
using Cthangover.Core.UI;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Trading
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <c>action trade.open shop=village_merchant</c> — opens the trade window with
|
||||||
|
/// that merchant. Usually the last line of a merchant's dialog. The window is a
|
||||||
|
/// hosted overlay of the UI registry (it lives above every scene and is built
|
||||||
|
/// once), so the action hands it the shop and then shows it through the
|
||||||
|
/// registry, which keeps the host's record of open entries right.
|
||||||
|
/// </summary>
|
||||||
|
public class TradeOpenAction : IScenarioAction
|
||||||
|
{
|
||||||
|
/// <summary>DSL name.</summary>
|
||||||
|
public string Name => "trade.open";
|
||||||
|
|
||||||
|
/// <summary>Resolves the shop and shows the window for it.</summary>
|
||||||
|
public void Run(IActionContext ctx)
|
||||||
|
{
|
||||||
|
var id = ctx.GetParam("shop");
|
||||||
|
var shop = string.IsNullOrWhiteSpace(id) ? null : ShopFactory.Instance.Get(id.Trim());
|
||||||
|
if (shop == null)
|
||||||
|
{
|
||||||
|
ctx.LogWarning("TRADE", $"trade.open: unknown shop '{id}'");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = UiRegistry.Get(TradePanel.EntryId);
|
||||||
|
if (entry == null || UiHost.Instance?.EnsureInstance(entry) is not TradePanel panel)
|
||||||
|
{
|
||||||
|
ctx.LogWarning("TRADE", "trade.open: the trade window is not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.SetShop(shop);
|
||||||
|
if (!panel.Visible)
|
||||||
|
UiRegistry.Toggle(TradePanel.EntryId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
namespace Cthangover.Mods.Trading
|
||||||
|
{
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.Audio;
|
||||||
|
using Cthangover.Core.Factories.Impls;
|
||||||
|
using Cthangover.Core.Items;
|
||||||
|
using Cthangover.Core.Scenes;
|
||||||
|
using Cthangover.Core.Settings;
|
||||||
|
using Cthangover.Core.UI.Inventory;
|
||||||
|
using Cthangover.Core.UI.Theming;
|
||||||
|
using Cthangover.Mods.Interface.Base.Lists;
|
||||||
|
using Cthangover.Mods.Interface.Theming;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trade window: two inventories side by side, the merchant's goods on the left
|
||||||
|
/// and the player's bag on the right, with a deal card between them. It follows
|
||||||
|
/// the inventory panel (same cells, same frame, same passport idea), so trading
|
||||||
|
/// feels like a second bag rather than a new screen.
|
||||||
|
/// <para/>
|
||||||
|
/// The window is a hosted "once" overlay (<see cref="EntryId"/> in the manifest),
|
||||||
|
/// built on first use and reused for every merchant: <see cref="TradeOpenAction"/>
|
||||||
|
/// hands it a <see cref="ShopInfo"/> and shows it. Clicking a cell selects the item
|
||||||
|
/// and fills the deal card with its price, a quantity slider limited by money (when
|
||||||
|
/// buying) or by the stack (when selling) and the deal button. The arithmetic is
|
||||||
|
/// <see cref="TradeLedger"/>; the window only redraws on
|
||||||
|
/// <see cref="IInventory.Change"/>, so it also follows changes made elsewhere.
|
||||||
|
/// The money item is hidden from the player's grid and shown in the header.
|
||||||
|
/// <para/>
|
||||||
|
/// The inventory subscription is made on show and dropped on hide, against the
|
||||||
|
/// inventory instance of that moment: a new game or a load replaces the runtime
|
||||||
|
/// and its inventory while this window lives on.
|
||||||
|
/// </summary>
|
||||||
|
public partial class TradePanel : Widget
|
||||||
|
{
|
||||||
|
/// <summary>Id of the UI registry entry (manifest <c>ui</c>).</summary>
|
||||||
|
public const string EntryId = "trade";
|
||||||
|
|
||||||
|
private enum Side
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Merchant,
|
||||||
|
Player,
|
||||||
|
}
|
||||||
|
|
||||||
|
private const int Columns = 4;
|
||||||
|
private const float CellGap = 10f;
|
||||||
|
private const float DealWidth = 400f;
|
||||||
|
|
||||||
|
private static readonly List<ShopOffer> NoOffers = new();
|
||||||
|
|
||||||
|
private readonly List<InventoryItemBehaviour> _merchantCells = new();
|
||||||
|
private readonly List<InventoryItemBehaviour> _playerCells = new();
|
||||||
|
|
||||||
|
private ShopInfo _shop;
|
||||||
|
private IInventory _inventory;
|
||||||
|
private Side _side = Side.None;
|
||||||
|
private string _selectedId;
|
||||||
|
private int _quantity = 1;
|
||||||
|
private string _message;
|
||||||
|
private bool _messageOk;
|
||||||
|
|
||||||
|
private Label _title;
|
||||||
|
private TextureRect _walletIcon;
|
||||||
|
private Label _walletLabel;
|
||||||
|
private Button _closeButton;
|
||||||
|
private Label _merchantTitle;
|
||||||
|
private Label _playerTitle;
|
||||||
|
private Label _footer;
|
||||||
|
private Control _merchantGrid;
|
||||||
|
private Control _playerGrid;
|
||||||
|
private VBoxContainer _dealCard;
|
||||||
|
private Label _quantityLabel;
|
||||||
|
private Label _totalLabel;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the merchant to trade with. Called before the window is shown; if it is
|
||||||
|
/// already open (another merchant's dialog ended in it), it switches at once.
|
||||||
|
/// </summary>
|
||||||
|
public void SetShop(ShopInfo shop)
|
||||||
|
{
|
||||||
|
_shop = shop;
|
||||||
|
ResetSelection();
|
||||||
|
if (Visible)
|
||||||
|
RebuildAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnceConstruct()
|
||||||
|
{
|
||||||
|
MouseFilter = MouseFilterEnum.Stop;
|
||||||
|
_title = GetNode<Label>("Panel/Margin/Root/Header/Title");
|
||||||
|
_walletIcon = GetNode<TextureRect>("Panel/Margin/Root/Header/Wallet/WalletIcon");
|
||||||
|
_walletLabel = GetNode<Label>("Panel/Margin/Root/Header/Wallet/WalletLabel");
|
||||||
|
_closeButton = GetNode<Button>("Panel/Margin/Root/Header/CloseButton");
|
||||||
|
_merchantTitle = GetNode<Label>("Panel/Margin/Root/Body/Merchant/MerchantTitle");
|
||||||
|
_merchantGrid = GetNode<Control>("Panel/Margin/Root/Body/Merchant/MerchantScroll/MerchantGrid");
|
||||||
|
_playerTitle = GetNode<Label>("Panel/Margin/Root/Body/Player/PlayerTitle");
|
||||||
|
_playerGrid = GetNode<Control>("Panel/Margin/Root/Body/Player/PlayerScroll/PlayerGrid");
|
||||||
|
_dealCard = GetNode<VBoxContainer>("Panel/Margin/Root/Body/Deal/DealScroll/DealCard");
|
||||||
|
_footer = GetNode<Label>("Panel/Margin/Root/Footer");
|
||||||
|
|
||||||
|
ApplyTheme();
|
||||||
|
_closeButton.Pressed += OnClosePressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void ShowConstruct()
|
||||||
|
{
|
||||||
|
if (_inventory != null)
|
||||||
|
_inventory.Change -= OnInventoryChanged;
|
||||||
|
_inventory = GameData.Instance.Runtime.Inventory;
|
||||||
|
_inventory.Change += OnInventoryChanged;
|
||||||
|
if (_side == Side.None)
|
||||||
|
SelectFirstOffer();
|
||||||
|
RebuildAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void HideDestruct()
|
||||||
|
{
|
||||||
|
if (_inventory != null)
|
||||||
|
_inventory.Change -= OnInventoryChanged;
|
||||||
|
_inventory = null;
|
||||||
|
ClearCells(_merchantCells);
|
||||||
|
ClearCells(_playerCells);
|
||||||
|
ClearChildren(_dealCard);
|
||||||
|
ResetSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnInput(InputEvent @event)
|
||||||
|
{
|
||||||
|
if (Visible && @event.IsActionPressed("ui_cancel"))
|
||||||
|
{
|
||||||
|
OnClosePressed();
|
||||||
|
GetViewport().SetInputAsHandled();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyTheme()
|
||||||
|
{
|
||||||
|
UiKit.ApplyPanelBackdrop(GetNode<ColorRect>("Backdrop"));
|
||||||
|
|
||||||
|
var panelStyle = UiKit.Frame(8, UiTokens.Surface0, UiTokens.Line);
|
||||||
|
panelStyle.ShadowColor = new Color(0f, 0f, 0f, 0.6f);
|
||||||
|
panelStyle.ShadowSize = 18;
|
||||||
|
panelStyle.ShadowOffset = new Vector2(0f, 8f);
|
||||||
|
GetNode<Panel>("Panel").AddThemeStyleboxOverride("panel", panelStyle);
|
||||||
|
GetNode<Panel>("Panel/Margin/Root/Rule").AddThemeStyleboxOverride("panel", UiKit.Frame(0, UiTokens.Line));
|
||||||
|
|
||||||
|
var dealStyle = UiKit.Frame(6, UiTokens.SideBg, UiTokens.Line);
|
||||||
|
dealStyle.ContentMarginLeft = 18;
|
||||||
|
dealStyle.ContentMarginRight = 18;
|
||||||
|
dealStyle.ContentMarginTop = 16;
|
||||||
|
dealStyle.ContentMarginBottom = 16;
|
||||||
|
GetNode<PanelContainer>("Panel/Margin/Root/Body/Deal").AddThemeStyleboxOverride("panel", dealStyle);
|
||||||
|
|
||||||
|
_title.AddThemeColorOverride("font_color", UiTokens.InkBright);
|
||||||
|
_walletLabel.AddThemeColorOverride("font_color", UiTokens.AccentBright);
|
||||||
|
_merchantTitle.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||||
|
_playerTitle.AddThemeColorOverride("font_color", UiTokens.Muted);
|
||||||
|
_footer.AddThemeColorOverride("font_color", UiTokens.Faint);
|
||||||
|
CthangoverTheme.ApplyIcon(_closeButton);
|
||||||
|
|
||||||
|
_merchantTitle.Text = Tr("trading/ui/merchant_goods").ToUpperInvariant();
|
||||||
|
_playerTitle.Text = Tr("trading/ui/your_bag").ToUpperInvariant();
|
||||||
|
_footer.Text = Tr("trading/ui/hint");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildAll()
|
||||||
|
{
|
||||||
|
if (_shop == null || _inventory == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_title.Text = Tr(_shop.Name ?? _shop.ID).ToUpperInvariant();
|
||||||
|
_walletIcon.Texture = ItemFactory.Instance.Get(_shop.Currency)?.Sprite;
|
||||||
|
_walletLabel.Text = TradeLedger.Money(_inventory, _shop).ToString();
|
||||||
|
|
||||||
|
var cellSize = CalculateCellSize();
|
||||||
|
ClearCells(_merchantCells);
|
||||||
|
foreach (var offer in _shop.Sells ?? NoOffers)
|
||||||
|
{
|
||||||
|
var item = offer == null ? null : ItemFactory.Instance.Get(offer.Item);
|
||||||
|
if (item == null || offer.Price <= 0)
|
||||||
|
continue;
|
||||||
|
var cell = AddCell(_merchantGrid, _merchantCells, new ItemContainer { Item = item, Count = 1 }, cellSize);
|
||||||
|
cell.Decorate(PriceTag(offer.Price), TradeLedger.MaxBuy(_inventory, _shop, item.ID) == 0);
|
||||||
|
cell.Clicked += OnMerchantCellClicked;
|
||||||
|
}
|
||||||
|
FitGrid(_merchantGrid, _merchantCells);
|
||||||
|
|
||||||
|
ClearCells(_playerCells);
|
||||||
|
foreach (var container in _inventory.Items)
|
||||||
|
{
|
||||||
|
if (container?.Item == null || container.Item.ID == _shop.Currency)
|
||||||
|
continue;
|
||||||
|
var cell = AddCell(_playerGrid, _playerCells, container, cellSize);
|
||||||
|
var offer = _shop.FindBuy(container.Item.ID);
|
||||||
|
cell.Decorate(offer == null ? null : PriceTag(offer.Price), offer == null);
|
||||||
|
cell.Clicked += OnPlayerCellClicked;
|
||||||
|
}
|
||||||
|
FitGrid(_playerGrid, _playerCells);
|
||||||
|
|
||||||
|
if (_side == Side.Player && _inventory.CheckCount(_selectedId) == 0)
|
||||||
|
ResetSelectionKeepMessage();
|
||||||
|
MarkSelected();
|
||||||
|
BuildDeal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private InventoryItemBehaviour AddCell(Control grid, List<InventoryItemBehaviour> cells, IItemContainer container, float cellSize)
|
||||||
|
{
|
||||||
|
var cell = new InventoryItemBehaviour();
|
||||||
|
cell.Construct(container);
|
||||||
|
grid.AddChild(cell);
|
||||||
|
var index = cells.Count;
|
||||||
|
cell.Position = new Vector2(index % Columns * (cellSize + CellGap), index / Columns * (cellSize + CellGap));
|
||||||
|
cell.Size = new Vector2(cellSize, cellSize);
|
||||||
|
cells.Add(cell);
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void FitGrid(Control grid, List<InventoryItemBehaviour> cells)
|
||||||
|
{
|
||||||
|
if (cells.Count == 0)
|
||||||
|
{
|
||||||
|
grid.CustomMinimumSize = Vector2.Zero;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var last = cells[cells.Count - 1];
|
||||||
|
grid.CustomMinimumSize = new Vector2(0, last.Position.Y + last.Size.Y + CellGap);
|
||||||
|
}
|
||||||
|
|
||||||
|
private float CalculateCellSize()
|
||||||
|
{
|
||||||
|
var viewportWidth = GetViewportRect().Size.X;
|
||||||
|
if (viewportWidth <= 0)
|
||||||
|
viewportWidth = 1920f;
|
||||||
|
var gridWidth = (viewportWidth - 80f - 48f - DealWidth - 28f) / 2f;
|
||||||
|
return Mathf.Clamp((gridWidth - (Columns - 1) * CellGap - 14f) / Columns, 64f, 150f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MarkSelected()
|
||||||
|
{
|
||||||
|
foreach (var cell in _merchantCells)
|
||||||
|
cell.SetSelected(_side == Side.Merchant && cell.Model.Item.ID == _selectedId);
|
||||||
|
foreach (var cell in _playerCells)
|
||||||
|
cell.SetSelected(_side == Side.Player && cell.Model.Item.ID == _selectedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildDeal()
|
||||||
|
{
|
||||||
|
ClearChildren(_dealCard);
|
||||||
|
_quantityLabel = null;
|
||||||
|
_totalLabel = null;
|
||||||
|
|
||||||
|
var item = _side == Side.None ? null : ItemFactory.Instance.Get(_selectedId);
|
||||||
|
if (item == null)
|
||||||
|
{
|
||||||
|
_dealCard.AddChild(UiKit.EmptyState(Tr("trading/ui/select"), 14));
|
||||||
|
AddMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var buying = _side == Side.Merchant;
|
||||||
|
var offer = buying ? _shop.FindSell(item.ID) : _shop.FindBuy(item.ID);
|
||||||
|
var max = buying ? TradeLedger.MaxBuy(_inventory, _shop, item.ID) : TradeLedger.MaxSell(_inventory, _shop, item.ID);
|
||||||
|
_quantity = Mathf.Clamp(_quantity, 1, Mathf.Max(1, max));
|
||||||
|
|
||||||
|
var header = UiKit.Row(16);
|
||||||
|
var iconFrame = new PanelContainer { MouseFilter = MouseFilterEnum.Ignore };
|
||||||
|
iconFrame.AddThemeStyleboxOverride("panel", UiKit.Frame(8, UiTokens.Surface1, UiTokens.LineSoft));
|
||||||
|
iconFrame.AddChild(UiKit.Icon(item.Sprite, 88));
|
||||||
|
header.AddChild(iconFrame);
|
||||||
|
var titles = UiKit.Column(8);
|
||||||
|
titles.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||||
|
var name = UiKit.Label(Tr(item.Name), 22, UiTokens.InkBright);
|
||||||
|
name.AutowrapMode = TextServer.AutowrapMode.WordSmart;
|
||||||
|
titles.AddChild(name);
|
||||||
|
var badge = UiKit.Badge(Tr(buying ? "trading/ui/badge_merchant" : "trading/ui/badge_yours"),
|
||||||
|
buying ? UiTokens.Accent : UiTokens.Ok);
|
||||||
|
badge.SizeFlagsHorizontal = SizeFlags.ShrinkBegin;
|
||||||
|
titles.AddChild(badge);
|
||||||
|
header.AddChild(titles);
|
||||||
|
_dealCard.AddChild(header);
|
||||||
|
|
||||||
|
var description = UiKit.Label(Tr(item.Description), 14, UiTokens.Body);
|
||||||
|
description.AutowrapMode = TextServer.AutowrapMode.WordSmart;
|
||||||
|
_dealCard.AddChild(description);
|
||||||
|
|
||||||
|
var stats = UiKit.Row(8);
|
||||||
|
if (offer != null)
|
||||||
|
stats.AddChild(UiKit.StatChip(Tr("trading/ui/unit_price"), offer.Price.ToString(), UiTokens.AccentBright));
|
||||||
|
stats.AddChild(buying
|
||||||
|
? UiKit.StatChip(Tr("trading/ui/wallet"), TradeLedger.Money(_inventory, _shop).ToString())
|
||||||
|
: UiKit.StatChip(Tr("trading/ui/you_have"), _inventory.CheckCount(item.ID).ToString()));
|
||||||
|
_dealCard.AddChild(stats);
|
||||||
|
|
||||||
|
if (offer == null || max == 0)
|
||||||
|
{
|
||||||
|
var reason = offer == null ? "trading/ui/not_bought" : (buying ? "trading/ui/no_money" : "trading/ui/nothing_to_sell");
|
||||||
|
var hint = UiKit.Label(Tr(reason), 13, UiTokens.Muted);
|
||||||
|
hint.AutowrapMode = TextServer.AutowrapMode.WordSmart;
|
||||||
|
_dealCard.AddChild(hint);
|
||||||
|
AddMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_dealCard.AddChild(BuildQuantity(max));
|
||||||
|
_totalLabel = UiKit.Label(string.Empty, 18, UiTokens.AccentBright);
|
||||||
|
_dealCard.AddChild(_totalLabel);
|
||||||
|
UpdateQuantityLabels(max, offer.Price);
|
||||||
|
|
||||||
|
var deal = MakeButton(Tr(buying ? "trading/ui/buy" : "trading/ui/sell"), true);
|
||||||
|
deal.CustomMinimumSize = new Vector2(0, 44);
|
||||||
|
deal.Pressed += OnDealPressed;
|
||||||
|
_dealCard.AddChild(deal);
|
||||||
|
AddMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Control BuildQuantity(int max)
|
||||||
|
{
|
||||||
|
var column = UiKit.Column(8);
|
||||||
|
var head = UiKit.Row(8);
|
||||||
|
var caption = UiKit.Label(Tr("trading/ui/quantity"), 12, UiTokens.Muted);
|
||||||
|
caption.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||||
|
head.AddChild(caption);
|
||||||
|
_quantityLabel = UiKit.Label(string.Empty, 12, UiTokens.AccentBright);
|
||||||
|
head.AddChild(_quantityLabel);
|
||||||
|
column.AddChild(head);
|
||||||
|
|
||||||
|
var slider = new HSlider
|
||||||
|
{
|
||||||
|
MinValue = 1,
|
||||||
|
MaxValue = Mathf.Max(1, max),
|
||||||
|
Step = 1,
|
||||||
|
Value = _quantity,
|
||||||
|
Editable = max > 1,
|
||||||
|
SizeFlagsHorizontal = SizeFlags.ExpandFill,
|
||||||
|
FocusMode = FocusModeEnum.None,
|
||||||
|
};
|
||||||
|
slider.ValueChanged += OnSliderChanged;
|
||||||
|
column.AddChild(slider);
|
||||||
|
|
||||||
|
var buttons = UiKit.Row(6);
|
||||||
|
buttons.AddChild(MakeSmallButton("−", false));
|
||||||
|
buttons.AddChild(MakeSmallButton("+", true));
|
||||||
|
var all = MakeButton(Tr("trading/ui/all"), false);
|
||||||
|
all.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||||
|
all.Pressed += OnAllPressed;
|
||||||
|
buttons.AddChild(all);
|
||||||
|
column.AddChild(buttons);
|
||||||
|
return column;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Button MakeSmallButton(string text, bool plus)
|
||||||
|
{
|
||||||
|
var button = MakeButton(text, false);
|
||||||
|
button.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||||
|
if (plus)
|
||||||
|
button.Pressed += OnPlusPressed;
|
||||||
|
else
|
||||||
|
button.Pressed += OnMinusPressed;
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The inventory passport's button look (same tokens), so both windows match.
|
||||||
|
private static Button MakeButton(string text, bool primary)
|
||||||
|
{
|
||||||
|
var button = new Button { Text = text, FocusMode = FocusModeEnum.None };
|
||||||
|
button.AddThemeFontSizeOverride("font_size", 14);
|
||||||
|
button.AddThemeStyleboxOverride("normal", ButtonStyle(primary, false));
|
||||||
|
button.AddThemeStyleboxOverride("hover", ButtonStyle(primary, true));
|
||||||
|
button.AddThemeStyleboxOverride("pressed", ButtonStyle(primary, true));
|
||||||
|
button.AddThemeStyleboxOverride("focus", new StyleBoxEmpty());
|
||||||
|
button.AddThemeColorOverride("font_color", primary ? UiTokens.AccentBright : UiTokens.Body);
|
||||||
|
button.AddThemeColorOverride("font_hover_color", primary ? UiTokens.AccentHover : UiTokens.InkBright);
|
||||||
|
button.AddThemeColorOverride("font_pressed_color", primary ? UiTokens.AccentHover : UiTokens.InkBright);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StyleBoxFlat ButtonStyle(bool primary, bool hover)
|
||||||
|
{
|
||||||
|
var background = primary
|
||||||
|
? (hover ? UiTokens.BtnPrimaryHover : UiTokens.BtnPrimary)
|
||||||
|
: (hover ? UiTokens.BtnHover : UiTokens.Surface3);
|
||||||
|
var border = primary
|
||||||
|
? (hover ? UiTokens.AccentLineStrong : UiTokens.Alpha(UiTokens.Accent, 0.55f))
|
||||||
|
: (hover ? UiTokens.Alpha(UiTokens.Accent, 0.55f) : UiTokens.Line);
|
||||||
|
var style = UiKit.Frame(4, background, border);
|
||||||
|
style.ContentMarginLeft = 12;
|
||||||
|
style.ContentMarginRight = 12;
|
||||||
|
style.ContentMarginTop = 10;
|
||||||
|
style.ContentMarginBottom = 10;
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddMessage()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_message))
|
||||||
|
return;
|
||||||
|
var accent = _messageOk ? UiTokens.Ok : UiTokens.DangerText;
|
||||||
|
var frame = UiKit.Frame(5, UiTokens.Alpha(accent, 0.1f), UiTokens.Alpha(accent, 0.5f));
|
||||||
|
frame.ContentMarginLeft = 12;
|
||||||
|
frame.ContentMarginRight = 12;
|
||||||
|
frame.ContentMarginTop = 9;
|
||||||
|
frame.ContentMarginBottom = 9;
|
||||||
|
var wrapper = new PanelContainer { MouseFilter = MouseFilterEnum.Ignore };
|
||||||
|
wrapper.AddThemeStyleboxOverride("panel", frame);
|
||||||
|
var label = UiKit.Label(_message, 13, accent);
|
||||||
|
label.AutowrapMode = TextServer.AutowrapMode.WordSmart;
|
||||||
|
wrapper.AddChild(label);
|
||||||
|
_dealCard.AddChild(wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateQuantityLabels(int max, int price)
|
||||||
|
{
|
||||||
|
if (_quantityLabel != null)
|
||||||
|
_quantityLabel.Text = _quantity + " / " + max;
|
||||||
|
if (_totalLabel != null)
|
||||||
|
_totalLabel.Text = string.Format(Tr("trading/ui/total"), price * _quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int CurrentMax()
|
||||||
|
{
|
||||||
|
return _side == Side.Merchant
|
||||||
|
? TradeLedger.MaxBuy(_inventory, _shop, _selectedId)
|
||||||
|
: TradeLedger.MaxSell(_inventory, _shop, _selectedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetQuantity(int quantity)
|
||||||
|
{
|
||||||
|
var max = CurrentMax();
|
||||||
|
var clamped = Mathf.Clamp(quantity, 1, Mathf.Max(1, max));
|
||||||
|
if (clamped == _quantity)
|
||||||
|
return;
|
||||||
|
_quantity = clamped;
|
||||||
|
BuildDeal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSliderChanged(double value)
|
||||||
|
{
|
||||||
|
_quantity = Mathf.RoundToInt((float)value);
|
||||||
|
var offer = _side == Side.Merchant ? _shop.FindSell(_selectedId) : _shop.FindBuy(_selectedId);
|
||||||
|
UpdateQuantityLabels(CurrentMax(), offer?.Price ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMinusPressed() => SetQuantity(_quantity - 1);
|
||||||
|
|
||||||
|
private void OnPlusPressed() => SetQuantity(_quantity + 1);
|
||||||
|
|
||||||
|
private void OnAllPressed() => SetQuantity(CurrentMax());
|
||||||
|
|
||||||
|
private void OnDealPressed()
|
||||||
|
{
|
||||||
|
var item = ItemFactory.Instance.Get(_selectedId);
|
||||||
|
if (item == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var buying = _side == Side.Merchant;
|
||||||
|
var offer = buying ? _shop.FindSell(item.ID) : _shop.FindBuy(item.ID);
|
||||||
|
var count = _quantity;
|
||||||
|
var ok = buying
|
||||||
|
? TradeLedger.Buy(_inventory, _shop, item.ID, count)
|
||||||
|
: TradeLedger.Sell(_inventory, _shop, item.ID, count);
|
||||||
|
|
||||||
|
_messageOk = ok;
|
||||||
|
_message = ok
|
||||||
|
? string.Format(Tr(buying ? "trading/ui/bought" : "trading/ui/sold"), Tr(item.Name), count, (offer?.Price ?? 0) * count)
|
||||||
|
: Tr("trading/ui/deal_failed");
|
||||||
|
_quantity = 1;
|
||||||
|
PlaySound(ok ? "items/use" : "ui/close_click");
|
||||||
|
// The grids were already redrawn by OnInventoryChanged during the deal,
|
||||||
|
// before the message existed; the card is redrawn again to show it.
|
||||||
|
BuildDeal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMerchantCellClicked(InventoryItemBehaviour cell) => Select(Side.Merchant, cell);
|
||||||
|
|
||||||
|
private void OnPlayerCellClicked(InventoryItemBehaviour cell) => Select(Side.Player, cell);
|
||||||
|
|
||||||
|
private void Select(Side side, InventoryItemBehaviour cell)
|
||||||
|
{
|
||||||
|
_side = side;
|
||||||
|
_selectedId = cell.Model.Item.ID;
|
||||||
|
_quantity = 1;
|
||||||
|
_message = null;
|
||||||
|
MarkSelected();
|
||||||
|
BuildDeal();
|
||||||
|
PlaySound("ui/skip");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectFirstOffer()
|
||||||
|
{
|
||||||
|
foreach (var offer in _shop?.Sells ?? NoOffers)
|
||||||
|
{
|
||||||
|
if (offer != null && offer.Price > 0 && ItemFactory.Instance.Get(offer.Item) != null)
|
||||||
|
{
|
||||||
|
_side = Side.Merchant;
|
||||||
|
_selectedId = offer.Item;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnInventoryChanged(string id, int delta)
|
||||||
|
{
|
||||||
|
if (Visible)
|
||||||
|
RebuildAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClosePressed()
|
||||||
|
{
|
||||||
|
PlaySound("ui/close_click");
|
||||||
|
Hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetSelection()
|
||||||
|
{
|
||||||
|
ResetSelectionKeepMessage();
|
||||||
|
_message = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetSelectionKeepMessage()
|
||||||
|
{
|
||||||
|
_side = Side.None;
|
||||||
|
_selectedId = null;
|
||||||
|
_quantity = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string PriceTag(int price) => string.Format(Tr("trading/ui/price_tag"), price);
|
||||||
|
|
||||||
|
private static void ClearCells(List<InventoryItemBehaviour> cells)
|
||||||
|
{
|
||||||
|
foreach (var cell in cells)
|
||||||
|
cell.QueueFree();
|
||||||
|
cells.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ClearChildren(Node container)
|
||||||
|
{
|
||||||
|
if (container == null)
|
||||||
|
return;
|
||||||
|
foreach (var child in container.GetChildren())
|
||||||
|
{
|
||||||
|
container.RemoveChild(child);
|
||||||
|
child.QueueFree();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PlaySound(string id)
|
||||||
|
{
|
||||||
|
var audio = SceneContextNode.FindNode<Node>("AudioService") as IAudioService;
|
||||||
|
audio?.PlaySound(id, SoundType.UI);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "Trade Test",
|
||||||
|
"description": "Integration test of trading on the rpg_demo game: gathers brushwood, stones and a mushroom in the forest by walking onto them, heals the hero with the mushroom, talks to the village merchant whose dialog opens the trade window, sells the junk and buys an elixir by clicking the two inventories, checks that walking is blocked while the window is open and that Esc closes it. Activated by --itest=trade with --game=rpg_demo --new; --shots=<dir> saves screenshots (windowed runs only).",
|
||||||
|
"sources": [
|
||||||
|
"src/*.cs"
|
||||||
|
],
|
||||||
|
"depends": [
|
||||||
|
"rpg_demo_game>=1"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Cthangover.Core.Bootstrap;
|
||||||
|
using Cthangover.Core.Factories.Impls;
|
||||||
|
using Cthangover.Core.Items;
|
||||||
|
using Cthangover.Core.Scenes;
|
||||||
|
using Cthangover.Core.Settings;
|
||||||
|
using Cthangover.Core.UI;
|
||||||
|
using Cthangover.Core.UI.Inventory;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
using Cthangover.Mods.Items;
|
||||||
|
using Cthangover.Mods.Rpg;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Trading.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Plays the gathering and trading loop of rpg_demo like a player: the hero
|
||||||
|
/// walks onto things in the forest (key events through
|
||||||
|
/// <see cref="Input.ParseInputEvent"/>), eats a mushroom, talks to the
|
||||||
|
/// merchant with E and trades in the window by clicking its cells and
|
||||||
|
/// buttons (the signals a mouse would raise).
|
||||||
|
/// <para/>
|
||||||
|
/// One step per timer tick; a step either waits (returns without advancing)
|
||||||
|
/// or checks and moves on. A failed check is recorded and the run goes on; an
|
||||||
|
/// exception ends it. Result line: <c>[TEST_RESULT] name=trade passed=N
|
||||||
|
/// failed=M</c>, exit code 0/1, and a 90 s watchdog.
|
||||||
|
/// </summary>
|
||||||
|
public partial class TradeTestDriver : Node
|
||||||
|
{
|
||||||
|
private const float TickSeconds = 0.1f;
|
||||||
|
private const float TimeoutSeconds = 90f;
|
||||||
|
private const string Village = "rpg_village";
|
||||||
|
private const string Forest = "rpg_forest";
|
||||||
|
private const string Branches = "rpg_demo/branches";
|
||||||
|
private const string Stones = "rpg_demo/stones";
|
||||||
|
private const string Mushroom = "rpg_demo/mushroom";
|
||||||
|
private const string Elixir = "rpg_demo/elixir";
|
||||||
|
private const string Coin = ShopInfo.DefaultCurrency;
|
||||||
|
|
||||||
|
private readonly string shotsDir;
|
||||||
|
private readonly List<string> failures = new();
|
||||||
|
private int passed;
|
||||||
|
private int step;
|
||||||
|
private int waitTicks;
|
||||||
|
private double elapsed;
|
||||||
|
|
||||||
|
/// <summary>Creates the driver; <paramref name="shotsDir"/> null disables screenshots.</summary>
|
||||||
|
public TradeTestDriver(string shotsDir)
|
||||||
|
{
|
||||||
|
this.shotsDir = shotsDir;
|
||||||
|
Name = "TradeTestDriver";
|
||||||
|
var timer = new Timer { WaitTime = TickSeconds, Autostart = true };
|
||||||
|
AddChild(timer);
|
||||||
|
timer.Timeout += Tick;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RpgWorldView View => RpgWorldHost.Current?.View;
|
||||||
|
|
||||||
|
private static IInventory Inventory => GameData.Instance.Runtime.Inventory;
|
||||||
|
|
||||||
|
private void Tick()
|
||||||
|
{
|
||||||
|
if (step < 0)
|
||||||
|
return;
|
||||||
|
elapsed += TickSeconds;
|
||||||
|
if (elapsed > TimeoutSeconds)
|
||||||
|
{
|
||||||
|
Fail($"timeout at step {step}");
|
||||||
|
Finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RunStep();
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
Fail($"step {step}: {ex.GetType().Name}: {ex.Message}");
|
||||||
|
Finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RunStep()
|
||||||
|
{
|
||||||
|
switch (step)
|
||||||
|
{
|
||||||
|
case 0:
|
||||||
|
if (Boot.IsReady && View?.Map?.ID == Village)
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 1:
|
||||||
|
if (AdvanceDialog())
|
||||||
|
return;
|
||||||
|
Check(Inventory.CheckCount(Coin) == 0, "a new game starts without money");
|
||||||
|
RpgWorldHost.Teleport(new RpgTeleport { Map = Forest, X = 6, Y = 4, Dir = RpgDirection.Down });
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 2:
|
||||||
|
if (!Settled(Forest) || AdvanceDialog())
|
||||||
|
return;
|
||||||
|
Check(View.FindEvent("pick_branches_1")?.Visible == true, "brushwood lies in the forest");
|
||||||
|
Press(Key.S, true);
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 3:
|
||||||
|
if (View.Player.Cell.Y < 5)
|
||||||
|
return;
|
||||||
|
Press(Key.S, false);
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 4:
|
||||||
|
if (!Settled(Forest) || AdvanceDialog() || ++waitTicks < 3)
|
||||||
|
return;
|
||||||
|
Check(Inventory.CheckCount(Branches) == 2, $"stepping on brushwood picks up 2, got {Inventory.CheckCount(Branches)}");
|
||||||
|
Check(View.FindEvent("pick_branches_1")?.Visible == false, "picked brushwood disappears");
|
||||||
|
View.PlacePlayer(11, 2, RpgDirection.Down);
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 5:
|
||||||
|
if (StepDown(3))
|
||||||
|
return;
|
||||||
|
Check(Inventory.CheckCount(Stones) == 2, $"stones picked up, got {Inventory.CheckCount(Stones)}");
|
||||||
|
View.PlacePlayer(10, 2, RpgDirection.Down);
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 6:
|
||||||
|
if (StepDown(3))
|
||||||
|
return;
|
||||||
|
Check(Inventory.CheckCount(Mushroom) == 1, $"a mushroom picked up, got {Inventory.CheckCount(Mushroom)}");
|
||||||
|
Shot("forest");
|
||||||
|
CheckMushroomHeals();
|
||||||
|
RpgWorldHost.Teleport(new RpgTeleport { Map = Village, X = 21, Y = 8, Dir = RpgDirection.Right });
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 7:
|
||||||
|
if (!Settled(Village) || AdvanceDialog())
|
||||||
|
return;
|
||||||
|
Tap(Key.E);
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 8:
|
||||||
|
Check(RpgInput.FindDialogBox()?.IsRunning == true, "E talks to the merchant");
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 9:
|
||||||
|
if (AdvanceDialog())
|
||||||
|
return;
|
||||||
|
Check(Panel()?.IsVisibleInTree() == true, "the merchant's dialog opens the trade window");
|
||||||
|
Check(Cells("MerchantGrid").Any(c => c.Model.Item.ID == Elixir), "the merchant offers the elixir");
|
||||||
|
Check(Cells("PlayerGrid").Any(c => c.Model.Item.ID == Branches), "the bag shows the brushwood");
|
||||||
|
Check(Cells("PlayerGrid").All(c => c.Model.Item.ID != Coin), "money is not a cell of the bag");
|
||||||
|
Press(Key.D, true);
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 10:
|
||||||
|
if (++waitTicks < 4)
|
||||||
|
return;
|
||||||
|
Press(Key.D, false);
|
||||||
|
Check(View.Player.Cell == new Vector2I(21, 8), $"the hero does not walk while trading, at {View.Player.Cell}");
|
||||||
|
Click("MerchantGrid", Elixir);
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 11:
|
||||||
|
Check(DealButton("trading/ui/buy") == null, "no buy button without money");
|
||||||
|
Shot("trade_no_money");
|
||||||
|
Click("PlayerGrid", Branches);
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 12:
|
||||||
|
Press(DealButton("trading/ui/all"));
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 13:
|
||||||
|
Press(DealButton("trading/ui/sell"));
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 14:
|
||||||
|
Check(Inventory.CheckCount(Branches) == 0, "all brushwood sold");
|
||||||
|
Check(Inventory.CheckCount(Coin) == 2, $"2 brushwood x 1 coin, got {Inventory.CheckCount(Coin)}");
|
||||||
|
Click("PlayerGrid", Stones);
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 15:
|
||||||
|
Press(DealButton("trading/ui/sell"));
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 16:
|
||||||
|
Check(Inventory.CheckCount(Stones) == 1, "one stone sold, one kept");
|
||||||
|
Check(Inventory.CheckCount(Coin) == 4, $"plus 2 coins for a stone, got {Inventory.CheckCount(Coin)}");
|
||||||
|
Click("PlayerGrid", Mushroom);
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 17:
|
||||||
|
Check(DealButton("trading/ui/sell") == null, "the merchant does not buy mushrooms");
|
||||||
|
Inventory.Add(Coin, 20);
|
||||||
|
Click("MerchantGrid", Elixir);
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 18:
|
||||||
|
Press(DealButton("trading/ui/buy"));
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 19:
|
||||||
|
Check(Inventory.CheckCount(Elixir) == 1, "an elixir bought");
|
||||||
|
Check(Inventory.CheckCount(Coin) == 14, $"24 - 10 coins, got {Inventory.CheckCount(Coin)}");
|
||||||
|
Shot("trade");
|
||||||
|
Press(Key.Escape, true);
|
||||||
|
Press(Key.Escape, false);
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 20:
|
||||||
|
Check(Panel()?.IsVisibleInTree() != true, "Esc closes the trade window");
|
||||||
|
Finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The mushroom's action heals the chosen party member by 5 and refuses at full health.</summary>
|
||||||
|
private void CheckMushroomHeals()
|
||||||
|
{
|
||||||
|
var hero = GameData.Instance.Runtime.CharacterData.Characters["rpg_hero"];
|
||||||
|
var item = ItemFactory.Instance.Get(Mushroom);
|
||||||
|
var heal = item?.ItemAction as ITargetedItemAction;
|
||||||
|
Check(heal != null, "the mushroom has a targeted heal action");
|
||||||
|
if (heal == null)
|
||||||
|
return;
|
||||||
|
var max = hero.Attributes.Health.BaseValue;
|
||||||
|
hero.Attributes.Health.Value = max - 8;
|
||||||
|
Check(heal.UseOn(item, hero) && hero.Attributes.Health.Value == max - 3, $"eating heals 5, health {hero.Attributes.Health.Value}/{max}");
|
||||||
|
hero.Attributes.Health.Value = max;
|
||||||
|
Check(!heal.UseOn(item, hero), "no eating at full health");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Holds S until the hero leaves row 2 (the world reads the held key each
|
||||||
|
/// frame, a tap within one tick would be missed), then waits for the touch
|
||||||
|
/// event's dialog to end; true while busy.
|
||||||
|
/// </summary>
|
||||||
|
private bool StepDown(int settleTicks)
|
||||||
|
{
|
||||||
|
waitTicks++;
|
||||||
|
if (waitTicks == 1)
|
||||||
|
{
|
||||||
|
Press(Key.S, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (View.Player.Cell.Y < 3)
|
||||||
|
return true;
|
||||||
|
Press(Key.S, false);
|
||||||
|
return !Settled(Forest) || View.Player.IsMoving || AdvanceDialog() || waitTicks < settleTicks + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Settled(string map) => View?.Map?.ID == map && !GodotSceneService.IsTransitioning;
|
||||||
|
|
||||||
|
private static TradePanel Panel()
|
||||||
|
{
|
||||||
|
return UiHost.Instance != null && UiHost.Instance.TryGetInstance(TradePanel.EntryId, out var control)
|
||||||
|
? control as TradePanel
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<InventoryItemBehaviour> Cells(string grid)
|
||||||
|
{
|
||||||
|
var parent = Panel()?.FindChild(grid, true, false);
|
||||||
|
return parent == null
|
||||||
|
? new List<InventoryItemBehaviour>()
|
||||||
|
: parent.GetChildren().OfType<InventoryItemBehaviour>().Where(c => !c.IsQueuedForDeletion()).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Click(string grid, string itemId)
|
||||||
|
{
|
||||||
|
var cell = Cells(grid).FirstOrDefault(c => c.Model?.Item?.ID == itemId);
|
||||||
|
Check(cell != null, $"{grid} has a cell of {itemId}");
|
||||||
|
cell?.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { Pressed = true, ButtonIndex = MouseButton.Left });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Button DealButton(string key)
|
||||||
|
{
|
||||||
|
var card = Panel()?.FindChild("DealCard", true, false);
|
||||||
|
var text = TranslationServer.Translate(key).ToString();
|
||||||
|
return card?.FindChildren("*", "", true, false).OfType<Button>()
|
||||||
|
.FirstOrDefault(b => b.Text == text && !b.IsQueuedForDeletion() && !b.Disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Press(Button button)
|
||||||
|
{
|
||||||
|
Check(button != null, "the deal button is there");
|
||||||
|
button?.EmitSignal(BaseButton.SignalName.Pressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Advances a running dialog with the action key; true while it is still running.</summary>
|
||||||
|
private static bool AdvanceDialog()
|
||||||
|
{
|
||||||
|
var box = RpgInput.FindDialogBox();
|
||||||
|
if (box == null || !box.IsRunning)
|
||||||
|
return false;
|
||||||
|
Tap(Key.E);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Press(Key key, bool pressed)
|
||||||
|
{
|
||||||
|
Input.ParseInputEvent(new InputEventKey { PhysicalKeycode = key, Keycode = key, Pressed = pressed });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Tap(Key key)
|
||||||
|
{
|
||||||
|
Press(key, true);
|
||||||
|
Press(key, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Next()
|
||||||
|
{
|
||||||
|
step++;
|
||||||
|
waitTicks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Check(bool condition, string what)
|
||||||
|
{
|
||||||
|
if (condition)
|
||||||
|
{
|
||||||
|
passed++;
|
||||||
|
GameLogger.Log("TEST", $"[PASS] {what}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Fail(what);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Fail(string what)
|
||||||
|
{
|
||||||
|
failures.Add(what);
|
||||||
|
GameLogger.Log("TEST", $"[FAIL] {what}", LogLevel.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Shot(string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(shotsDir) || DisplayServer.GetName() == "headless")
|
||||||
|
return;
|
||||||
|
DirAccess.MakeDirRecursiveAbsolute(shotsDir);
|
||||||
|
GetViewport().GetTexture().GetImage()?.SavePng(shotsDir.TrimEnd('/') + "/trade_" + name + ".png");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Finish()
|
||||||
|
{
|
||||||
|
step = -1;
|
||||||
|
GameLogger.Log("TEST", $"[TEST_RESULT] name=trade passed={passed} failed={failures.Count}");
|
||||||
|
GetTree().Quit(failures.Count == 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Trading.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Entry point of the trade integration test: adds the driver to the tree root
|
||||||
|
/// when the exact flag <c>--itest=trade</c> is given (the driver quits the
|
||||||
|
/// process, so it must never run in another test). Optional
|
||||||
|
/// <c>--shots=<dir></c> makes the driver save screenshots.
|
||||||
|
/// </summary>
|
||||||
|
public class TradeTestInitializer : IModInitializer
|
||||||
|
{
|
||||||
|
private static bool driverQueued;
|
||||||
|
|
||||||
|
/// <summary>Nothing per mod.</summary>
|
||||||
|
public void OnModLoaded(string modId)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Queues the driver once.</summary>
|
||||||
|
public void OnModResourcesReady()
|
||||||
|
{
|
||||||
|
var args = OS.GetCmdlineUserArgs();
|
||||||
|
if (driverQueued || !args.Any(a => a == "--itest=trade"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
driverQueued = true;
|
||||||
|
var shots = args.Where(a => a.StartsWith("--shots=", System.StringComparison.Ordinal))
|
||||||
|
.Select(a => a.Substring("--shots=".Length))
|
||||||
|
.FirstOrDefault();
|
||||||
|
GameLogger.Log("TEST", $"TradeTestInitializer: activating trade, shots={shots ?? "off"}");
|
||||||
|
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new TradeTestDriver(shots));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
namespace Cthangover.Mods.Trading.Tests
|
||||||
|
{
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Cthangover.Core.Mods.Testing;
|
||||||
|
|
||||||
|
/// <summary>A shop is hand-written JSON: it must survive a round trip and its lookups must skip broken lines.</summary>
|
||||||
|
public class ShopInfoTests
|
||||||
|
{
|
||||||
|
[ModTest("shop/json_round_trip")]
|
||||||
|
static void JsonRoundTrip(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var original = new ShopInfo
|
||||||
|
{
|
||||||
|
ID = "rpg_demo/village_merchant",
|
||||||
|
Name = "rpg_demo/shop/village_merchant",
|
||||||
|
Currency = "money/shell",
|
||||||
|
Sells = new List<ShopOffer> { new() { Item = "rpg_demo/elixir", Price = 10 } },
|
||||||
|
Buys = new List<ShopOffer> { new() { Item = "rpg_demo/stones", Price = 2 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
var restored = JsonSerializer.Deserialize<ShopInfo>(JsonSerializer.Serialize(original));
|
||||||
|
|
||||||
|
ModAssert.Equal(original.ID, restored.ID, "Id");
|
||||||
|
ModAssert.Equal(original.Name, restored.Name, "Name");
|
||||||
|
ModAssert.Equal(original.Currency, restored.Currency, "Currency");
|
||||||
|
ModAssert.Equal(1, restored.Sells.Count, "Sells");
|
||||||
|
ModAssert.Equal("rpg_demo/elixir", restored.Sells[0].Item, "Sells item");
|
||||||
|
ModAssert.Equal(10, restored.Sells[0].Price, "Sells price");
|
||||||
|
ModAssert.Equal(1, restored.Buys.Count, "Buys");
|
||||||
|
ModAssert.Equal("rpg_demo/stones", restored.Buys[0].Item, "Buys item");
|
||||||
|
ModAssert.Equal(2, restored.Buys[0].Price, "Buys price");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("shop/defaults_from_minimal_json")]
|
||||||
|
static void DefaultsFromMinimalJson(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var shop = JsonSerializer.Deserialize<ShopInfo>("{\"Id\":\"bare\"}");
|
||||||
|
|
||||||
|
ModAssert.Equal("bare", shop.ID, "Id");
|
||||||
|
ModAssert.Equal(ShopInfo.DefaultCurrency, shop.Currency, "default currency");
|
||||||
|
ModAssert.Equal(0, shop.Sells.Count, "no goods");
|
||||||
|
ModAssert.Null(shop.FindBuy("anything"), "buys nothing");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("shop/find_skips_broken_offers")]
|
||||||
|
static void FindSkipsBrokenOffers(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var shop = new ShopInfo
|
||||||
|
{
|
||||||
|
ID = "s",
|
||||||
|
Sells = new List<ShopOffer> { null, new() { Item = "a", Price = 0 }, new() { Item = "a", Price = 4 } },
|
||||||
|
Buys = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
ModAssert.Equal(4, shop.FindSell("a")?.Price ?? -1, "the first offer with a real price wins");
|
||||||
|
ModAssert.Null(shop.FindSell("b"), "unknown item");
|
||||||
|
ModAssert.Null(shop.FindSell(null), "null id");
|
||||||
|
ModAssert.Null(shop.FindBuy("a"), "null list buys nothing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
namespace Cthangover.Mods.Trading.Tests
|
||||||
|
{
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Cthangover.Core.Items;
|
||||||
|
using Cthangover.Core.Mods.Resolvers;
|
||||||
|
using Cthangover.Core.Mods.Testing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deals are all or nothing: a refused buy or sell must leave both the money
|
||||||
|
/// and the goods exactly as they were.
|
||||||
|
/// </summary>
|
||||||
|
public class TradeLedgerTests
|
||||||
|
{
|
||||||
|
private static string Goods() =>
|
||||||
|
Entities.Collect<ItemInfo>().Keys.First(k => k != ShopInfo.DefaultCurrency);
|
||||||
|
|
||||||
|
private static ShopInfo Shop(string goods) => new()
|
||||||
|
{
|
||||||
|
ID = "test_shop",
|
||||||
|
Sells = new List<ShopOffer> { new() { Item = goods, Price = 3 } },
|
||||||
|
Buys = new List<ShopOffer> { new() { Item = goods, Price = 2 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
[ModTest("ledger/buy_takes_money_and_adds_goods")]
|
||||||
|
static void BuyTakesMoneyAndAddsGoods(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var goods = Goods();
|
||||||
|
var shop = Shop(goods);
|
||||||
|
var inventory = new Inventory();
|
||||||
|
inventory.Add(shop.Currency, 10);
|
||||||
|
var before = inventory.CheckCount(goods);
|
||||||
|
|
||||||
|
ModAssert.True(TradeLedger.Buy(inventory, shop, goods, 3), "affordable buy succeeds");
|
||||||
|
ModAssert.Equal(1, inventory.CheckCount(shop.Currency), "10 - 3 x 3 coins left");
|
||||||
|
ModAssert.Equal(before + 3, inventory.CheckCount(goods), "three units bought");
|
||||||
|
ModAssert.Equal(1, TradeLedger.Money(inventory, shop), "money reads the currency stack");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("ledger/buy_refused_changes_nothing")]
|
||||||
|
static void BuyRefusedChangesNothing(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var goods = Goods();
|
||||||
|
var shop = Shop(goods);
|
||||||
|
var inventory = new Inventory();
|
||||||
|
inventory.Add(shop.Currency, 5);
|
||||||
|
var before = inventory.CheckCount(goods);
|
||||||
|
|
||||||
|
ModAssert.False(TradeLedger.Buy(inventory, shop, goods, 2), "6 coins needed, 5 owned");
|
||||||
|
ModAssert.False(TradeLedger.Buy(inventory, shop, goods, 0), "zero units");
|
||||||
|
ModAssert.False(TradeLedger.Buy(inventory, shop, goods, -1), "negative units");
|
||||||
|
ModAssert.False(TradeLedger.Buy(inventory, shop, "no/such_item", 1), "not on sale");
|
||||||
|
ModAssert.Equal(5, inventory.CheckCount(shop.Currency), "money untouched");
|
||||||
|
ModAssert.Equal(before, inventory.CheckCount(goods), "goods untouched");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("ledger/sell_pays_and_removes_goods")]
|
||||||
|
static void SellPaysAndRemovesGoods(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var goods = Goods();
|
||||||
|
var shop = Shop(goods);
|
||||||
|
var inventory = new Inventory();
|
||||||
|
inventory.Add(goods, 4);
|
||||||
|
var before = inventory.CheckCount(goods);
|
||||||
|
var money = inventory.CheckCount(shop.Currency);
|
||||||
|
|
||||||
|
ModAssert.True(TradeLedger.Sell(inventory, shop, goods, 3), "owned units sell");
|
||||||
|
ModAssert.Equal(money + 6, inventory.CheckCount(shop.Currency), "3 x 2 coins paid");
|
||||||
|
ModAssert.Equal(before - 3, inventory.CheckCount(goods), "three units gone");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("ledger/sell_refused_changes_nothing")]
|
||||||
|
static void SellRefusedChangesNothing(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var goods = Goods();
|
||||||
|
var shop = Shop(goods);
|
||||||
|
var inventory = new Inventory();
|
||||||
|
inventory.Add(goods, 2);
|
||||||
|
inventory.Add(shop.Currency, 7);
|
||||||
|
var before = inventory.CheckCount(goods);
|
||||||
|
shop.Buys.Add(new ShopOffer { Item = shop.Currency, Price = 1 });
|
||||||
|
|
||||||
|
ModAssert.False(TradeLedger.Sell(inventory, shop, goods, before + 1), "more than owned");
|
||||||
|
ModAssert.False(TradeLedger.Sell(inventory, shop, goods, 0), "zero units");
|
||||||
|
ModAssert.False(TradeLedger.Sell(inventory, shop, shop.Currency, 1), "money is never sold");
|
||||||
|
ModAssert.False(TradeLedger.Sell(inventory, new ShopInfo { ID = "picky" }, goods, 1), "not bought by this shop");
|
||||||
|
ModAssert.Equal(before, inventory.CheckCount(goods), "goods untouched");
|
||||||
|
ModAssert.Equal(7, inventory.CheckCount(shop.Currency), "money untouched");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("ledger/max_counts")]
|
||||||
|
static void MaxCounts(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var goods = Goods();
|
||||||
|
var shop = Shop(goods);
|
||||||
|
var inventory = new Inventory();
|
||||||
|
inventory.Add(shop.Currency, 11);
|
||||||
|
inventory.Add(goods, 2);
|
||||||
|
|
||||||
|
ModAssert.Equal(3, TradeLedger.MaxBuy(inventory, shop, goods), "11 coins buy three at 3");
|
||||||
|
ModAssert.Equal(inventory.CheckCount(goods), TradeLedger.MaxSell(inventory, shop, goods), "the whole stack sells");
|
||||||
|
ModAssert.Equal(0, TradeLedger.MaxBuy(inventory, shop, "no/such_item"), "not on sale");
|
||||||
|
ModAssert.Equal(0, TradeLedger.MaxSell(inventory, new ShopInfo { ID = "picky" }, goods), "not bought");
|
||||||
|
ModAssert.Equal(0, TradeLedger.Money(null, shop), "no inventory, no money");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user