From d96c4ffc7d893d6ca4fe18a819612ca3fc55a71c Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sun, 20 Sep 2026 15:49:53 +0800 Subject: [PATCH 01/10] Fix: Count shop stock and limits in items A stock and a per-player limit counted purchases, so an entry selling sixteen at a time with a limit of ten handed over a hundred and sixty items, and one click spent one of the ten however much it moved. Both now count items. A trade spends bundleSize * bundles of each, and maxBuyable divides what is left by the bundle, because half a bundle is not a purchase. Existing shops are converted as the file is read, so every entry keeps trading exactly as it did. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 17 ++++++ README.md | 5 +- docs/DEVELOPER_GUIDE.md | 17 +++++- .../plugins/tritown/guis/shop/ShopEntryGUI.kt | 2 + .../mc/plugins/tritown/shops/ShopLimit.kt | 10 +++- .../mc/plugins/tritown/shops/ShopLimits.kt | 10 ++-- .../mc/plugins/tritown/shops/ShopStock.kt | 11 ++-- .../mc/plugins/tritown/shops/ShopTrade.kt | 20 ++++--- .../tritown/shops/storage/JsonShopStorage.kt | 7 ++- .../tritown/shops/storage/ShopMigrations.kt | 48 +++++++++++++++ .../tritown/shops/storage/ShopSchema.kt | 11 +++- src/main/resources/lang/en_US.yml | 5 +- src/main/resources/lang/zh_CN.yml | 9 +-- .../shops/storage/ShopMigrationsTest.kt | 58 +++++++++++++++++++ 14 files changed, 197 insertions(+), 33 deletions(-) create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopMigrations.kt create mode 100644 src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopMigrationsTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 76aeb47..70e1451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ## Unreleased +### Fixes + +#### Shops + ++ A stock and a per-player limit are now counted in items rather than in purchases, so they mean what they say. An + entry selling 16 at a time with a limit of 10 used to hand over 160 items, and a click spent one of the ten whether + it moved one item or a hundred and twenty-eight. A limit of 64 is now sixty-four items, and stays sixty-four if you + change the bundle afterwards. Existing shops are converted on first start, so every entry keeps trading exactly as + it did; only the number you see in the editor changes unit. + +### Technical Details + +#### Shops + ++ An older shop file is now brought forward by `ShopMigrations` as it is read rather than being misread against the + current shape. Schema 2 is stock and limits in items. + ## Version 1.1.0 ### New Features diff --git a/README.md b/README.md index c568ba4..25d5569 100644 --- a/README.md +++ b/README.md @@ -183,8 +183,9 @@ A shop is created with `/tt shop create `, which opens its editor. Everythin can ask for money, for items, or for both at once. Money is typed in chat when you click the price; items are added by clicking them in your inventory, and the stack size is the quantity. - **Limiting it.** *Stock* is shared by everybody and refills to full on a timer. A *limit* is per player and resets - daily, weekly, or never. Both are optional, and an entry with neither is unlimited, which is what an admin shop - usually wants. + daily, weekly, or never. Both are counted in items rather than in purchases — a limit of 64 on an entry that sells + 16 at a time is four purchases — and both are optional. An entry with neither is unlimited, which is what an admin + shop usually wants. - **Locking it.** A shop, and each entry inside it, can require a permission node or a standing in Towny — being in a town, being without one, being in a nation, being a mayor or being a king. A locked entry shows the reason by default, or can be hidden entirely. diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index c8e6a33..aec9d7a 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -2552,8 +2552,8 @@ manager has to be alive before the registrars build the menus and commands that | `ShopEntry` | One line of goods: the `ItemStack`, a buy `ShopCost`, a sell `ShopCost`, gate, limit, stock | | `ShopCost` | A price or a payout: an amount of money, a list of `ItemStack`s, or both | | `ShopGate` | A permission node and a `TownyRequirement`, plus whether a locked entry hides | -| `ShopLimit` | How much one player may buy per `LimitPeriod` window | -| `ShopStock` | A shared supply that refills to full on a timer | +| `ShopLimit` | How many items one player may buy per `LimitPeriod` window | +| `ShopStock` | A shared supply of items that refills to full on a timer | | `ShopStats` | Bundles traded and currency moved, per entry | `id` is stable and is what NPC bindings and purchase counters are keyed by; `displayName` is administrator-written @@ -2564,6 +2564,11 @@ exceed what a stack holds, and an `ItemStack` is not a safe place to keep a coun `displayStack()` and `goodsStacks(bundles)` are the three ways to ask about it — the last splits into stacks the game allows, which is what is both measured for room and handed over, while the first two are for drawing. +**Stock and limits are counted in items, not in purchases.** An administrator writing "64 a day" means sixty-four +items however large the bundle is, which is the only reading that stays true when the bundle is edited afterwards. A +trade therefore spends `bundleSize * bundles` of each, and `maxBuyable` divides what is left by `bundleSize` — a +remainder too small for one more bundle is not offered, because half a bundle is not a purchase. + ### Preserving an item `ItemCodec` wraps Paper's `ItemStack.serializeAsBytes()` / `deserializeBytes()` and Base64s the result. That is the only @@ -2582,6 +2587,12 @@ The storage layer works on `StoredShop` / `StoredEntry` / `StoredCost`, which ho `ItemStack`s. That keeps it free of Bukkit and therefore testable without a server; `ShopManager` converts between the stored and live shapes. +The file records the `ShopSchema` version it was written with. A newer file is refused outright; an older one is +brought forward by `ShopMigrations.upgrade` **as it is read**, so a build that loads the shops and then fails to enable +leaves the original untouched and the upgraded shape only reaches disk at the first ordinary save. Each step takes the +shape one version forward, so a file several versions old walks through them in turn. Adding a step means bumping +`ShopSchema.CURRENT`, adding a line to its history and a branch to `upgrade`. + Saving has two speeds, and the difference matters: | Call | When | Cost | @@ -2636,7 +2647,7 @@ nodes are. They are checked as they stand and defined in the server's permission ### Per-player limits Counters live in the buyer's own `PlayerData` under `shop-limits`, keyed `"/"`, each holding a count -and the window it belongs to. A count from a window that has turned over is ignored rather than cleared, so nothing has +of **items** and the window it belongs to. A count from a window that has turned over is ignored rather than cleared, so nothing has to sweep counters at midnight. `PlayerDataManager` only serves online players, which is the only case a purchase needs. ### FancyNpcs diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEntryGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEntryGUI.kt index 9f43d9c..fe5e261 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEntryGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEntryGUI.kt @@ -327,6 +327,7 @@ class ShopEntryGUI : PluginGUI( lore( LoreUtil.wrapLore( player.tr("gui.shop-entry.amount", "amount" to value) + + "" + player.tr("gui.shop-entry.count-lore") + "" + player.tr("gui.shop-entry.click-set") + "" + player.tr("gui.shop-entry.middle-click-period") + "" + player.tr("gui.shop-entry.right-click-clear") @@ -353,6 +354,7 @@ class ShopEntryGUI : PluginGUI( lore( LoreUtil.wrapLore( player.tr("gui.shop-entry.amount", "amount" to value) + + "" + player.tr("gui.shop-entry.count-lore") + "" + player.tr("gui.shop-entry.click-set") + "" + player.tr("gui.shop-entry.right-click-clear") ) diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimit.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimit.kt index 1d2df95..6de67ab 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimit.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimit.kt @@ -3,13 +3,17 @@ package net.trilleo.mc.plugins.tritown.shops import net.trilleo.mc.plugins.tritown.enums.LimitPeriod /** - * A cap on how much of one entry a single player may buy. + * A cap on how many items of one entry a single player may trade. + * + * Counted in items rather than in purchases, so an entry that hands over a + * bundle of sixteen spends sixteen of the allowance per click: an administrator + * who writes "64 a day" means sixty-four items however the bundle is sized. * * Windows are counted from the epoch rather than from each player's first * purchase, so everyone's daily limit rolls over at the same moment and a * player cannot stagger their buying to get more than the cap allows. * - * @param amount how many bundles a player may buy per window + * @param amount how many items a player may trade per window * @param period how often the window starts over */ data class ShopLimit(val amount: Int, val period: LimitPeriod) { @@ -22,7 +26,7 @@ data class ShopLimit(val amount: Int, val period: LimitPeriod) { } /** - * How many bundles are still available to a player who has bought [used] of + * How many items are still available to a player who has traded [used] of * them in window [usedWindow]. * * A count from an earlier window is spent, so it is ignored rather than diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimits.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimits.kt index 56e5a11..df92d84 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimits.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimits.kt @@ -5,7 +5,7 @@ import net.trilleo.mc.plugins.tritown.data.PlayerDataManager import org.bukkit.entity.Player /** - * How much of a limited entry each player has already bought. + * How many items of a limited entry each player has already bought. * * Counters live in the buyer's own player data rather than with the shop: they * are read and written only while that player is online, and keeping them there @@ -20,7 +20,7 @@ object ShopLimits { private const val COUNT = "n" private const val WINDOW = "w" - /** How many more bundles of [entry] in [shop] the player may buy, or `null` when it is unlimited. */ + /** How many more items of [entry] in [shop] the player may buy, or `null` when it is unlimited. */ fun remaining( player: Player, shop: ShopDefinition, @@ -32,18 +32,18 @@ object ShopLimits { return limit.remaining(record.first, record.second, now) } - /** Records [bundles] bought, resetting the count first when the window has turned over. */ + /** Records [items] bought, resetting the count first when the window has turned over. */ fun record( player: Player, shop: ShopDefinition, entry: ShopEntry, - bundles: Int, + items: Int, now: Long = System.currentTimeMillis(), ) { val limit = entry.limit ?: return val window = limit.windowAt(now) val previous = record(player, shop, entry) - val count = if (previous != null && previous.second == window) previous.first + bundles else bundles + val count = if (previous != null && previous.second == window) previous.first + items else items val root = root(player) root.add(key(shop, entry), JsonObject().apply { diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStock.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStock.kt index 1109d3c..08f617d 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStock.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStock.kt @@ -3,6 +3,9 @@ package net.trilleo.mc.plugins.tritown.shops /** * A finite supply of one entry, shared by everybody, that refills on a timer. * + * Counted in items rather than in purchases, the same way a per-player limit is, + * so a stock of 64 is sixty-four items however large a bundle one purchase is. + * * Restocking is lazy: nothing counts down in the background, and the supply is * brought up to date the moment somebody looks at it. A shop nobody visits for * a week therefore costs nothing and is still correct when they do. @@ -10,7 +13,7 @@ package net.trilleo.mc.plugins.tritown.shops * A restock fills back to [max] rather than adding one unit per period, so a * long absence cannot bank an unbounded supply. * - * @param max the supply a restock fills back to + * @param max how many items a restock fills back to * @param restockSeconds how long a restock takes; 0 never restocks, making the supply one-off */ data class ShopStock( @@ -20,7 +23,7 @@ data class ShopStock( var lastRestock: Long = 0L, ) { - /** How many bundles can be bought right now, restocking first if one is due. */ + /** How many items can be bought right now, restocking first if one is due. */ fun available(now: Long): Int { restock(now) return remaining @@ -43,14 +46,14 @@ data class ShopStock( lastRestock += elapsed / period * period } - /** Takes [count] bundles, or returns `false` and takes nothing when the supply is short. */ + /** Takes [count] items, or returns `false` and takes nothing when the supply is short. */ fun take(count: Int, now: Long): Boolean { if (count <= 0 || available(now) < count) return false remaining -= count return true } - /** Puts [count] bundles back, for a purchase that was rolled back or an item sold to the shop. */ + /** Puts [count] items back, for a purchase that was rolled back. */ fun restore(count: Int) { remaining = (remaining + count).coerceAtMost(max) } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopTrade.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopTrade.kt index 53f1396..1f7a0e6 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopTrade.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopTrade.kt @@ -75,6 +75,9 @@ object ShopTrade { * the supply left, their own limit and [MAX_BUNDLES] — whichever runs out * first. Used by a shift-click, which buys as many as it can rather than * refusing outright. + * + * Stock and limits are counted in items, so a bundle that does not fit + * whole into what is left is not offered: half a bundle is not a purchase. */ fun maxBuyable( player: Player, @@ -83,10 +86,11 @@ object ShopTrade { standing: Set = ShopAccess.standing(player), ): Int { val cost = entry.buy ?: return 0 + val bundle = entry.bundleSize var max = MAX_BUNDLES - entry.stock?.let { max = minOf(max, it.available(System.currentTimeMillis())) } - ShopLimits.remaining(player, shop, entry)?.let { max = minOf(max, it) } + entry.stock?.let { max = minOf(max, it.available(System.currentTimeMillis()) / bundle) } + ShopLimits.remaining(player, shop, entry)?.let { max = minOf(max, it / bundle) } for (item in cost.items) { val held = ShopInventory.count(player, item, entry.matchMode) @@ -126,13 +130,15 @@ object ShopTrade { ShopAccess.refusalKey(player, shop.gate, standing)?.let { return Result.Failure(it) } ShopAccess.refusalKey(player, entry.gate, standing)?.let { return Result.Failure(it) } + val items = entry.bundleSize * bundles + ShopLimits.remaining(player, shop, entry)?.let { left -> - if (left < bundles) return Result.Failure("shop.error.limit-reached", listOf("amount" to left)) + if (left < items) return Result.Failure("shop.error.limit-reached", listOf("amount" to left)) } val stock = entry.stock val now = System.currentTimeMillis() - if (stock != null && stock.available(now) < bundles) { + if (stock != null && stock.available(now) < items) { return Result.Failure("shop.error.out-of-stock", listOf("amount" to stock.remaining)) } @@ -146,7 +152,7 @@ object ShopTrade { val taken = takeItems(player, quote.items, entry.matchMode) ?: return Result.Failure("shop.error.missing-items") - if (stock != null && !stock.take(bundles, now)) { + if (stock != null && !stock.take(items, now)) { ShopInventory.give(player, taken) return Result.Failure("shop.error.out-of-stock", listOf("amount" to stock.remaining)) } @@ -154,14 +160,14 @@ object ShopTrade { if (quote.hasMoney) { val reason = TransactionReason.of(TransactionReason.SHOP_BUY, "shop" to shop.displayName) if (!EconomyUtil.withdraw(player, quote.money, EconomyContext.SOURCE_SHOP, reason)) { - stock?.restore(bundles) + stock?.restore(items) ShopInventory.give(player, taken) return Result.Failure("shop.error.cannot-afford", listOf("price" to format(quote.money))) } } ShopInventory.give(player, goods) - ShopLimits.record(player, shop, entry, bundles, now) + ShopLimits.record(player, shop, entry, items, now) entry.stats.recordBuy(bundles, quote.money) ShopManager.markDirty() diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/JsonShopStorage.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/JsonShopStorage.kt index aef564e..df94670 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/JsonShopStorage.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/JsonShopStorage.kt @@ -48,7 +48,7 @@ class JsonShopStorage(directory: File, private val logger: Logger) : ShopStorage ShopSchema.checkReadable(version, shopsFile.name) val array = document.getAsJsonArray(KEY_SHOPS) ?: return emptyList() - array.mapNotNull { element -> + val shops = array.mapNotNull { element -> runCatching { gson.fromJson(element, StoredShop::class.java) } .getOrNull() ?.takeIf { it.id.isNotBlank() } @@ -57,6 +57,11 @@ class JsonShopStorage(directory: File, private val logger: Logger) : ShopStorage null } } + + if (version < ShopSchema.CURRENT) { + logger.info("Upgrading ${shopsFile.name} from schema $version to ${ShopSchema.CURRENT}") + } + ShopMigrations.upgrade(shops, version) } override fun saveAll(shops: List) = synchronized(ioLock) { diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopMigrations.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopMigrations.kt new file mode 100644 index 0000000..06c3f42 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopMigrations.kt @@ -0,0 +1,48 @@ +package net.trilleo.mc.plugins.tritown.shops.storage + +/** + * Brings a shop file forward from an older [ShopSchema] version as it is read. + * + * Migrating on read rather than rewriting the file on start means a build that + * loads a shop file and then fails to enable leaves the original untouched; the + * upgraded shape reaches disk on the first ordinary save. + * + * Each step takes the shape one version forward, so a file several versions old + * walks through them in turn rather than needing a path of its own. + */ +object ShopMigrations { + + /** [shops], read at [version], as this build understands them. */ + fun upgrade(shops: List, version: Int): List { + var current = shops + for (from in version until ShopSchema.CURRENT) { + current = when (from) { + 1 -> toItemCounts(current) + else -> current + } + } + return current + } + + /** + * Schema 1 counted stock and limits in purchases; schema 2 counts them in + * items. + * + * Multiplying by the bundle keeps every shop trading exactly as it did — an + * entry selling sixteen at a time with a stock of four still offers + * sixty-four items — so an administrator's numbers only change unit, never + * meaning. + */ + private fun toItemCounts(shops: List): List = shops.map { shop -> + shop.copy( + entries = shop.entries.map { entry -> + val bundle = entry.bundle.coerceAtLeast(1) + entry.copy( + limitAmount = entry.limitAmount * bundle, + stockMax = entry.stockMax * bundle, + stockRemaining = entry.stockRemaining * bundle, + ) + } + ) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopSchema.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopSchema.kt index 7614e95..e931353 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopSchema.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopSchema.kt @@ -6,12 +6,19 @@ package net.trilleo.mc.plugins.tritown.shops.storage * The file records the version it was written with, so a later change to the * shape is detected rather than silently misread. A newer file is refused: an * older build would drop what it did not understand, and the next save would - * write that loss back over the real data. + * write that loss back over the real data. An older one is brought forward by + * [ShopMigrations] as it is read. + * + * ### History + * + * 1. The first shape shops were written in. Stock and per-player limits counted + * purchases. + * 2. Stock and per-player limits count items rather than purchases. */ object ShopSchema { /** The version this build writes. */ - const val CURRENT: Int = 1 + const val CURRENT: Int = 2 /** The oldest version this build can still read. */ const val OLDEST_SUPPORTED: Int = 1 diff --git a/src/main/resources/lang/en_US.yml b/src/main/resources/lang/en_US.yml index 49dce8d..1a6d53d 100644 --- a/src/main/resources/lang/en_US.yml +++ b/src/main/resources/lang/en_US.yml @@ -539,6 +539,7 @@ gui: shop-entry: title: "Entry" bundle: "One purchase gives {amount}" + count-lore: "Counted in items, not in purchases." bundle-size: "Amount per purchase" bundle-lore: "May be more than one stack; it is handed over as several." state: "Now: {state}" @@ -570,8 +571,8 @@ gui: prompt-price: "Type the new price in chat." prompt-permission: "Type the permission node in chat." prompt-bundle: "Type how many items one purchase should give." - prompt-limit: "Type how many one player may buy. 0 removes the limit." - prompt-stock: "Type the stock and the refill time in seconds, like 64 3600. 0 removes the stock." + prompt-limit: "Type how many items one player may buy per window. 0 removes the limit." + prompt-stock: "Type the stock in items and the refill time in seconds, like 64 3600. 0 removes the stock." back: "Back" back-lore: "Return to the shop editor." diff --git a/src/main/resources/lang/zh_CN.yml b/src/main/resources/lang/zh_CN.yml index 019028c..ec6614b 100644 --- a/src/main/resources/lang/zh_CN.yml +++ b/src/main/resources/lang/zh_CN.yml @@ -540,6 +540,7 @@ gui: shop-entry: title: "商品设置" bundle: "每次购买获得 {amount}" + count-lore: "按物品数计算,而非按购买次数。" bundle-size: "每次购买数量" bundle-lore: "可以超过一组,将分成多组交付。" state: "当前:{state}" @@ -571,8 +572,8 @@ gui: prompt-price: "请在聊天栏输入新的价格。" prompt-permission: "请在聊天栏输入权限节点。" prompt-bundle: "请输入每次购买应获得的数量。" - prompt-limit: "请输入每个玩家最多能买多少。0 表示不限。" - prompt-stock: "请输入库存和补货秒数,例如 64 3600。0 表示不限库存。" + prompt-limit: "请输入每个玩家每个周期最多能买多少个物品。0 表示不限。" + prompt-stock: "请输入库存物品数和补货秒数,例如 64 3600。0 表示不限库存。" back: "返回" back-lore: "回到商店编辑器。" @@ -629,8 +630,8 @@ shop: missing-items: "你缺少所需的物品。" missing-goods: "你身上的数量不够。" no-space: "你的背包放不下。" - out-of-stock: "库存只剩 {amount} 份。" - limit-reached: "你最多还能买 {amount} 份。" + out-of-stock: "库存只剩 {amount} 个。" + limit-reached: "你最多还能买 {amount} 个。" economy-unavailable: "经济系统不可用,无法付款。" payout-refused: "付款失败,交易未完成。" diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopMigrationsTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopMigrationsTest.kt new file mode 100644 index 0000000..3b5922b --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopMigrationsTest.kt @@ -0,0 +1,58 @@ +package net.trilleo.mc.plugins.tritown.shops.storage + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * An upgrade changes the unit an administrator's numbers are written in, never + * what those numbers mean: a shop must trade exactly as it did before the file + * was brought forward. + */ +class ShopMigrationsTest { + + private fun shop(entry: StoredEntry) = StoredShop(id = "market", entries = listOf(entry)) + + private fun upgraded(entry: StoredEntry, from: Int = 1): StoredEntry = + ShopMigrations.upgrade(listOf(shop(entry)), from).single().entries.single() + + @Test + fun `schema 1 counted purchases, so its numbers are multiplied by the bundle`() { + val entry = upgraded( + StoredEntry( + id = "bread", + bundle = 16, + limitAmount = 3, + stockMax = 8, + stockRemaining = 5, + ) + ) + + assertEquals(48, entry.limitAmount) + assertEquals(128, entry.stockMax) + assertEquals(80, entry.stockRemaining) + } + + @Test + fun `an entry with no limit and no stock keeps having neither`() { + val entry = upgraded(StoredEntry(id = "bread", bundle = 16)) + + assertEquals(0, entry.limitAmount) + assertEquals(0, entry.stockMax) + } + + @Test + fun `a bundle that was never written counts as one`() { + val entry = upgraded(StoredEntry(id = "bread", limitAmount = 4, stockMax = 9, stockRemaining = 2)) + + assertEquals(4, entry.limitAmount) + assertEquals(9, entry.stockMax) + assertEquals(2, entry.stockRemaining) + } + + @Test + fun `a file already at the current version is left alone`() { + val entry = StoredEntry(id = "bread", bundle = 16, limitAmount = 3, stockMax = 8) + + assertEquals(entry, upgraded(entry, from = ShopSchema.CURRENT)) + } +} From eca82a44b798f509d2c64e29fdc4e0596e99683c Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sun, 20 Sep 2026 15:51:13 +0800 Subject: [PATCH 02/10] Feature: Add a per-player selling limit to shop entries An entry could cap how much one player bought from it but not how much they sold to it, so the only way to stop a shop buying a farm's whole output was to take the sell side off entirely. The two sides now have a limit each, set apart in the entry editor and counted apart, so neither spends the other's allowance. Buying keeps the counter key it has always had, so what players have already bought today still counts. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 10 +++- README.md | 8 +-- docs/DEVELOPER_GUIDE.md | 28 +++++---- .../mc/plugins/tritown/enums/TradeSide.kt | 16 +++++ .../plugins/tritown/guis/shop/ShopEntryGUI.kt | 58 ++++++++++++------- .../mc/plugins/tritown/guis/shop/ShopGUI.kt | 25 ++++++-- .../mc/plugins/tritown/shops/ShopEntry.kt | 24 +++++++- .../mc/plugins/tritown/shops/ShopLimits.kt | 37 ++++++++---- .../mc/plugins/tritown/shops/ShopManager.kt | 19 +++--- .../mc/plugins/tritown/shops/ShopTrade.kt | 33 ++++++++--- .../tritown/shops/storage/ShopSchema.kt | 3 +- .../tritown/shops/storage/StoredShop.kt | 2 + src/main/resources/lang/en_US.yml | 8 ++- src/main/resources/lang/zh_CN.yml | 6 +- .../shops/storage/ShopMigrationsTest.kt | 8 +++ 15 files changed, 210 insertions(+), 75 deletions(-) create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/TradeSide.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 70e1451..d946c99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### New Features + +#### Shops + ++ An entry can now limit how much each player **sells** to the shop per day, per week or ever, alongside the limit on + how much they buy. The two are set separately in the entry editor and counted separately, so an entry can be "buy 64 + a day, sell 256 a day" without one side spending the other's allowance. + ### Fixes #### Shops @@ -17,7 +25,7 @@ #### Shops + An older shop file is now brought forward by `ShopMigrations` as it is read rather than being misread against the - current shape. Schema 2 is stock and limits in items. + current shape. Schema 2 is stock and limits in items, plus the selling limit. ## Version 1.1.0 diff --git a/README.md b/README.md index 25d5569..0ab5e2b 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ and off with `/tt scoreboard`, and it takes turns with Towny's own plot HUD rath **Shops the server runs.** Admin shops, set up entirely in game: click an item in your own inventory to put it on the shelf and it is sold exactly as you made it, custom name, enchantments and all. An entry can be sold, bought back, or both, and priced in currency, items, or a mix of the two. Give it a stock that refills on a timer, a limit on how much -each player may buy per day or per week, a permission node, or a requirement to be in a town or a nation — and give +each player may buy or sell per day or per week, a permission node, or a requirement to be in a town or a nation — and give town or nation members a discount while you are at it. Players reach a shop by clicking a [FancyNpcs](https://modrinth.com/plugin/fancynpcs) NPC, and every sale is recorded in the transaction log and totalled in a sales view. @@ -183,9 +183,9 @@ A shop is created with `/tt shop create `, which opens its editor. Everythin can ask for money, for items, or for both at once. Money is typed in chat when you click the price; items are added by clicking them in your inventory, and the stack size is the quantity. - **Limiting it.** *Stock* is shared by everybody and refills to full on a timer. A *limit* is per player and resets - daily, weekly, or never. Both are counted in items rather than in purchases — a limit of 64 on an entry that sells - 16 at a time is four purchases — and both are optional. An entry with neither is unlimited, which is what an admin - shop usually wants. + daily, weekly, or never; buying and selling have one each, and they are counted separately. All of them are counted + in items rather than in purchases — a limit of 64 on an entry that sells 16 at a time is four purchases — and all of + them are optional. An entry with none is unlimited, which is what an admin shop usually wants. - **Locking it.** A shop, and each entry inside it, can require a permission node or a standing in Towny — being in a town, being without one, being in a nation, being a mayor or being a king. A locked entry shows the reason by default, or can be hidden entirely. diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index aec9d7a..c98c9e5 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -2549,10 +2549,10 @@ manager has to be alive before the registrars build the menus and commands that | Type | What it is | |:-----------------|:---------------------------------------------------------------------------------------------| | `ShopDefinition` | One shop: `id`, `displayName`, a `ShopGate`, its entries, and the FancyNpcs ids bound to it | -| `ShopEntry` | One line of goods: the `ItemStack`, a buy `ShopCost`, a sell `ShopCost`, gate, limit, stock | +| `ShopEntry` | One line of goods: the `ItemStack`, a buy `ShopCost`, a sell `ShopCost`, gate, limits, stock | | `ShopCost` | A price or a payout: an amount of money, a list of `ItemStack`s, or both | | `ShopGate` | A permission node and a `TownyRequirement`, plus whether a locked entry hides | -| `ShopLimit` | How many items one player may buy per `LimitPeriod` window | +| `ShopLimit` | How many items one player may trade per `LimitPeriod` window | | `ShopStock` | A shared supply of items that refills to full on a timer | | `ShopStats` | Bundles traded and currency moved, per entry | @@ -2566,8 +2566,11 @@ allows, which is what is both measured for room and handed over, while the first **Stock and limits are counted in items, not in purchases.** An administrator writing "64 a day" means sixty-four items however large the bundle is, which is the only reading that stays true when the bundle is edited afterwards. A -trade therefore spends `bundleSize * bundles` of each, and `maxBuyable` divides what is left by `bundleSize` — a -remainder too small for one more bundle is not offered, because half a bundle is not a purchase. +trade therefore spends `bundleSize * bundles` of each, and `maxBuyable` / `maxSellable` divide what is left by +`bundleSize` — a remainder too small for one more bundle is not offered, because half a bundle is not a purchase. + +An entry carries a limit per side: `buyLimit` and `sellLimit`, reached through `limitOn(side)` / `setLimitOn(side, …)` +with a `TradeSide`. They are independent, so neither spends the other's allowance. ### Preserving an item @@ -2608,16 +2611,17 @@ taken, and anything taken is remembered so it can be put back.** A buy, in order: -1. Re-check the gate, the per-player limit and the stock, restocking lazily first. +1. Re-check the gate, the buying limit and the stock, restocking lazily first. 2. Quote the price, applying the best discount the player's standing in Towny earns. 3. Check there is room for the goods. 4. Take the item side of the price, keeping what was removed. 5. Take the stock. 6. `EconomyUtil.withdraw(player, money, EconomyContext.SOURCE_SHOP, reason)` — on refusal, put the stock and the items back and stop. -7. Hand over the goods, record the purchase against the player's limit, and update the statistics. +7. Hand over the goods, record the items against the player's buying limit, and update the statistics. -A sell is the mirror image. Never check `has` and withdraw separately — `EconomyUtil.withdraw` does both in one step. +A sell is the mirror image, and checks and records the selling limit in the same places. Never check `has` and withdraw +separately — `EconomyUtil.withdraw` does both in one step. The pricing, limit and stock arithmetic is deliberately free of Bukkit (`ShopPricing`, `ShopLimit`, `ShopStock`) so it can be unit-tested, in the same way `EconomyLedger` is. @@ -2646,9 +2650,11 @@ nodes are. They are checked as they stand and defined in the server's permission ### Per-player limits -Counters live in the buyer's own `PlayerData` under `shop-limits`, keyed `"/"`, each holding a count -of **items** and the window it belongs to. A count from a window that has turned over is ignored rather than cleared, so nothing has -to sweep counters at midnight. `PlayerDataManager` only serves online players, which is the only case a purchase needs. +Counters live in the trader's own `PlayerData` under `shop-limits`, keyed `"/"` for buying and +`"//sell"` for selling, each holding a count of **items** and the window it belongs to. Buying keeps +the key it has always had, so counters written before the selling limit existed still count against the day they were +written. A count from a window that has turned over is ignored rather than cleared, so nothing has to sweep counters at +midnight. `PlayerDataManager` only serves online players, which is the only case a trade needs. ### FancyNpcs @@ -2677,7 +2683,7 @@ rather than in `getItems`. | `ShopConfirmGUI` | A second look above `shops.confirm-above`; re-quotes on accept | | `ShopListGUI` | Every shop, for an administrator | | `ShopEditorGUI` | One shop's entries; adds one from the administrator's own inventory | -| `ShopEntryGUI` | One entry's prices, limit, stock and gate | +| `ShopEntryGUI` | One entry's prices, limits, stock and gate | | `ShopCostGUI` | The item side of a price or a payout | | `ShopSortGUI` | Puts a whole shop in one order, on an administrator's say-so | | `ShopSettingsGUI` | A shop's name, gate and bound NPCs | diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/TradeSide.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/TradeSide.kt new file mode 100644 index 0000000..e39411f --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/TradeSide.kt @@ -0,0 +1,16 @@ +package net.trilleo.mc.plugins.tritown.enums + +/** + * Which way goods are moving in a shop trade. + * + * Buying and selling are limited independently, so the side is what a per-player + * counter is filed under as well as what a trade is. + */ +enum class TradeSide { + + /** The player is buying from the shop. */ + BUY, + + /** The shop is buying back from the player. */ + SELL, +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEntryGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEntryGUI.kt index fe5e261..67d9d1a 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEntryGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEntryGUI.kt @@ -4,6 +4,7 @@ import net.trilleo.mc.plugins.tritown.config.ShopSettings import net.trilleo.mc.plugins.tritown.enums.FillMode import net.trilleo.mc.plugins.tritown.enums.LimitPeriod import net.trilleo.mc.plugins.tritown.enums.MatchMode +import net.trilleo.mc.plugins.tritown.enums.TradeSide import net.trilleo.mc.plugins.tritown.registration.GUIManager import net.trilleo.mc.plugins.tritown.registration.PluginGUI import net.trilleo.mc.plugins.tritown.shops.* @@ -58,7 +59,8 @@ class ShopEntryGUI : PluginGUI( inventory.setItem(SLOT_SELL_MONEY, money(player, entry.sell, "gui.shop-entry.sell-price")) inventory.setItem(SLOT_SELL_ITEMS, items(player, entry.sell, "gui.shop-entry.sell-items")) - inventory.setItem(SLOT_LIMIT, limit(player, entry)) + inventory.setItem(SLOT_BUY_LIMIT, limit(player, entry, TradeSide.BUY)) + inventory.setItem(SLOT_SELL_LIMIT, limit(player, entry, TradeSide.SELL)) inventory.setItem(SLOT_STOCK, stock(player, entry)) inventory.setItem(SLOT_PERMISSION, permission(player, entry)) inventory.setItem(SLOT_TOWNY, towny(player, entry)) @@ -84,7 +86,8 @@ class ShopEntryGUI : PluginGUI( SLOT_BUY_ITEMS -> return ShopRender.navigate { ShopCostGUI.show(player, shop, entry, buying = true) } SLOT_SELL_ITEMS -> return ShopRender.navigate { ShopCostGUI.show(player, shop, entry, buying = false) } SLOT_BUNDLE -> return editBundle(player, shop, entry) - SLOT_LIMIT -> return editLimit(player, shop, entry, event.click) + SLOT_BUY_LIMIT -> return editLimit(player, shop, entry, TradeSide.BUY, event.click) + SLOT_SELL_LIMIT -> return editLimit(player, shop, entry, TradeSide.SELL, event.click) SLOT_STOCK -> return editStock(player, shop, entry, clear) SLOT_PERMISSION -> return askPermission(player, shop, entry, clear) SLOT_TOWNY -> entry.gate = entry.gate.copy(towny = cycle(entry.gate.towny, event.click)) @@ -168,30 +171,44 @@ class ShopEntryGUI : PluginGUI( } /** Left sets the amount, right clears the limit, and a middle click steps the window. */ - private fun editLimit(player: Player, shop: ShopDefinition, entry: ShopEntry, click: ClickType) { + private fun editLimit( + player: Player, + shop: ShopDefinition, + entry: ShopEntry, + side: TradeSide, + click: ClickType, + ) { when (click) { ClickType.RIGHT, ClickType.SHIFT_RIGHT -> { - entry.limit = null + entry.setLimitOn(side, null) ShopManager.save() ShopRender.navigate { show(player, shop, entry) } } ClickType.MIDDLE -> { - val current = entry.limit ?: ShopLimit(1, LimitPeriod.NONE) - entry.limit = current.copy(period = cycle(current.period, ClickType.LEFT)) + val current = entry.limitOn(side) ?: ShopLimit(1, LimitPeriod.NONE) + entry.setLimitOn(side, current.copy(period = cycle(current.period, ClickType.LEFT))) ShopManager.save() ShopRender.navigate { show(player, shop, entry) } } - else -> prompt(player, shop, entry, player.tr("gui.shop-entry.prompt-limit")) { input -> - val amount = input.toIntOrNull() - if (amount == null || amount < 0) { - player.sendPrefixed(player.tr("common.error", "message" to player.tr("common.invalid-amount"))) - } else { - entry.limit = if (amount == 0) null else { - (entry.limit ?: ShopLimit(amount, LimitPeriod.DAILY)).copy(amount = amount) + else -> { + val question = player.tr( + if (side == TradeSide.BUY) "gui.shop-entry.prompt-limit" else "gui.shop-entry.prompt-sell-limit" + ) + + prompt(player, shop, entry, question) { input -> + val amount = input.toIntOrNull() + if (amount == null || amount < 0) { + player.sendPrefixed(player.tr("common.error", "message" to player.tr("common.invalid-amount"))) + } else { + val current = entry.limitOn(side) + entry.setLimitOn( + side, + if (amount == 0) null else (current ?: ShopLimit(amount, LimitPeriod.DAILY)).copy(amount = amount), + ) + ShopManager.save() } - ShopManager.save() } } } @@ -310,10 +327,10 @@ class ShopEntryGUI : PluginGUI( } } - private fun limit(player: Player, entry: ShopEntry): ItemStack = itemStack(Material.CLOCK) { - name(player.tr("gui.shop-entry.limit")) + private fun limit(player: Player, entry: ShopEntry, side: TradeSide): ItemStack = itemStack(Material.CLOCK) { + name(player.tr(if (side == TradeSide.BUY) "gui.shop-entry.limit" else "gui.shop-entry.sell-limit")) meta { - val current = entry.limit + val current = entry.limitOn(side) val value = if (current == null) { player.tr("common.none") } else { @@ -432,9 +449,10 @@ class ShopEntryGUI : PluginGUI( private const val SLOT_SELL_TOGGLE = 23 private const val SLOT_SELL_MONEY = 24 private const val SLOT_SELL_ITEMS = 25 - private const val SLOT_LIMIT = 29 - private const val SLOT_STOCK = 31 - private const val SLOT_PERMISSION = 33 + private const val SLOT_BUY_LIMIT = 28 + private const val SLOT_SELL_LIMIT = 30 + private const val SLOT_STOCK = 32 + private const val SLOT_PERMISSION = 34 private const val SLOT_TOWNY = 38 private const val SLOT_HIDDEN = 40 private const val SLOT_DISCOUNT = 42 diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt index 13a401c..d3f7b32 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt @@ -7,6 +7,7 @@ import net.trilleo.mc.plugins.tritown.config.ShopSettings import net.trilleo.mc.plugins.tritown.enums.FillMode import net.trilleo.mc.plugins.tritown.enums.PagedLayout import net.trilleo.mc.plugins.tritown.enums.TownyRequirement +import net.trilleo.mc.plugins.tritown.enums.TradeSide import net.trilleo.mc.plugins.tritown.registration.GUIManager import net.trilleo.mc.plugins.tritown.registration.PagedPluginGUI import net.trilleo.mc.plugins.tritown.shops.* @@ -126,9 +127,15 @@ class ShopGUI : PagedPluginGUI( } private fun sellMax(player: Player, shop: ShopDefinition, entry: ShopEntry): ShopTrade.Result { - val bundles = ShopTrade.maxSellable(player, entry) - if (bundles <= 0) return ShopTrade.Result.Failure("shop.error.missing-goods") - return ShopTrade.sell(player, shop, entry, bundles) + val bundles = ShopTrade.maxSellable(player, shop, entry) + if (bundles > 0) return ShopTrade.sell(player, shop, entry, bundles) + + ShopLimits.remaining(player, shop, entry, TradeSide.SELL)?.let { left -> + if (left < entry.bundleSize) { + return ShopTrade.Result.Failure("shop.error.sell-limit-reached", listOf("amount" to left)) + } + } + return ShopTrade.Result.Failure("shop.error.missing-goods") } private fun announce(player: Player, entry: ShopEntry, result: ShopTrade.Result.Success) { @@ -221,10 +228,18 @@ class ShopGUI : PagedPluginGUI( ) } - entry.limit?.let { limit -> + entry.buyLimit?.let { limit -> lines += viewer.tr( "gui.shop.limit", - "amount" to (ShopLimits.remaining(viewer, shop, entry) ?: limit.amount), + "amount" to (ShopLimits.remaining(viewer, shop, entry, TradeSide.BUY) ?: limit.amount), + "period" to ShopRender.periodName(viewer, limit.period), + ) + } + + entry.sellLimit?.let { limit -> + lines += viewer.tr( + "gui.shop.sell-limit", + "amount" to (ShopLimits.remaining(viewer, shop, entry, TradeSide.SELL) ?: limit.amount), "period" to ShopRender.periodName(viewer, limit.period), ) } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopEntry.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopEntry.kt index b7e3c86..0a10c7e 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopEntry.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopEntry.kt @@ -1,6 +1,7 @@ package net.trilleo.mc.plugins.tritown.shops import net.trilleo.mc.plugins.tritown.enums.MatchMode +import net.trilleo.mc.plugins.tritown.enums.TradeSide import org.bukkit.inventory.ItemStack import java.util.* @@ -14,8 +15,10 @@ import java.util.* * selling are independent, so an entry can do either, both, or — with both left * null — act as a display piece. * - * @param id stable across reordering and renaming, because purchase counters are keyed by it - * @param bundle how many items one purchase moves; at least 1, with no upper bound + * @param id stable across reordering and renaming, because trade counters are keyed by it + * @param bundle how many items one purchase moves; at least 1, with no upper bound + * @param buyLimit how many items one player may buy per window, counted in items rather than purchases + * @param sellLimit the same cap on the other side, kept apart so neither spends the other's allowance */ data class ShopEntry( val id: String = UUID.randomUUID().toString(), @@ -24,7 +27,8 @@ data class ShopEntry( var buy: ShopCost? = null, var sell: ShopCost? = null, var gate: ShopGate = ShopGate.OPEN, - var limit: ShopLimit? = null, + var buyLimit: ShopLimit? = null, + var sellLimit: ShopLimit? = null, var stock: ShopStock? = null, var discountable: Boolean = true, var matchMode: MatchMode = MatchMode.EXACT, @@ -40,6 +44,20 @@ data class ShopEntry( /** Whether the shop buys this back. */ val isSellable: Boolean get() = sell != null + /** The per-player cap that applies to [side], or `null` when that side is uncapped. */ + fun limitOn(side: TradeSide): ShopLimit? = when (side) { + TradeSide.BUY -> buyLimit + TradeSide.SELL -> sellLimit + } + + /** Sets the per-player cap that applies to [side]. */ + fun setLimitOn(side: TradeSide, limit: ShopLimit?) { + when (side) { + TradeSide.BUY -> buyLimit = limit + TradeSide.SELL -> sellLimit = limit + } + } + /** * One bundle as a single stack, for a menu slot rather than for handing over. * diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimits.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimits.kt index df92d84..cc1c79d 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimits.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimits.kt @@ -2,15 +2,19 @@ package net.trilleo.mc.plugins.tritown.shops import com.google.gson.JsonObject import net.trilleo.mc.plugins.tritown.data.PlayerDataManager +import net.trilleo.mc.plugins.tritown.enums.TradeSide import org.bukkit.entity.Player /** - * How many items of a limited entry each player has already bought. + * How many items of a limited entry each player has already traded. * - * Counters live in the buyer's own player data rather than with the shop: they + * Counters live in the trader's own player data rather than with the shop: they * are read and written only while that player is online, and keeping them there * means deleting a shop cannot leave a growing table of dead counters behind. * + * Buying and selling are counted apart, so an entry can be "buy 64 a day, sell + * 256 a day" without one side eating the other's allowance. + * * A count is stored with the window it belongs to, so a window that has turned * over is simply ignored — nothing has to sweep old counters at midnight. */ @@ -20,33 +24,35 @@ object ShopLimits { private const val COUNT = "n" private const val WINDOW = "w" - /** How many more items of [entry] in [shop] the player may buy, or `null` when it is unlimited. */ + /** How many more items of [entry] in [shop] the player may trade on [side], or `null` when it is unlimited. */ fun remaining( player: Player, shop: ShopDefinition, entry: ShopEntry, + side: TradeSide, now: Long = System.currentTimeMillis(), ): Int? { - val limit = entry.limit ?: return null - val record = record(player, shop, entry) ?: return limit.amount + val limit = entry.limitOn(side) ?: return null + val record = record(player, shop, entry, side) ?: return limit.amount return limit.remaining(record.first, record.second, now) } - /** Records [items] bought, resetting the count first when the window has turned over. */ + /** Records [items] traded on [side], resetting the count first when the window has turned over. */ fun record( player: Player, shop: ShopDefinition, entry: ShopEntry, + side: TradeSide, items: Int, now: Long = System.currentTimeMillis(), ) { - val limit = entry.limit ?: return + val limit = entry.limitOn(side) ?: return val window = limit.windowAt(now) - val previous = record(player, shop, entry) + val previous = record(player, shop, entry, side) val count = if (previous != null && previous.second == window) previous.first + items else items val root = root(player) - root.add(key(shop, entry), JsonObject().apply { + root.add(key(shop, entry, side), JsonObject().apply { addProperty(COUNT, count) addProperty(WINDOW, window) }) @@ -61,8 +67,8 @@ object ShopLimits { PlayerDataManager.get(player).set(ROOT_KEY, root) } - private fun record(player: Player, shop: ShopDefinition, entry: ShopEntry): Pair? { - val stored = root(player).getAsJsonObject(key(shop, entry)) ?: return null + private fun record(player: Player, shop: ShopDefinition, entry: ShopEntry, side: TradeSide): Pair? { + val stored = root(player).getAsJsonObject(key(shop, entry, side)) ?: return null val count = stored.get(COUNT)?.asInt ?: return null val window = stored.get(WINDOW)?.asLong ?: return null return count to window @@ -70,5 +76,12 @@ object ShopLimits { private fun root(player: Player): JsonObject = PlayerDataManager.get(player).getJsonObject(ROOT_KEY) - private fun key(shop: ShopDefinition, entry: ShopEntry): String = "${shop.id}/${entry.id}" + /** + * Buying keeps the key it has always had, so counters written before the + * sell limit existed still count against the day they were written. + */ + private fun key(shop: ShopDefinition, entry: ShopEntry, side: TradeSide): String = when (side) { + TradeSide.BUY -> "${shop.id}/${entry.id}" + TradeSide.SELL -> "${shop.id}/${entry.id}/sell" + } } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopManager.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopManager.kt index ba4fc9b..ccc89bf 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopManager.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopManager.kt @@ -150,11 +150,8 @@ object ShopManager { return null } - val limit = if (entry.limitAmount > 0) { - ShopLimit(entry.limitAmount, enumOrDefault(entry.limitPeriod, LimitPeriod.NONE)) - } else { - null - } + val buyLimit = toLimit(entry.limitAmount, entry.limitPeriod) + val sellLimit = toLimit(entry.sellLimitAmount, entry.sellLimitPeriod) val stock = if (entry.stockMax > 0) { ShopStock( @@ -175,7 +172,8 @@ object ShopManager { buy = toCost(entry.buy), sell = toCost(entry.sell), gate = ShopGate(entry.permission, requirement(entry.towny), entry.hideWhenLocked), - limit = limit, + buyLimit = buyLimit, + sellLimit = sellLimit, stock = stock, discountable = entry.discountable, matchMode = enumOrDefault(entry.matchMode, MatchMode.EXACT), @@ -200,8 +198,10 @@ object ShopManager { permission = entry.gate.permission, towny = entry.gate.towny.name, hideWhenLocked = entry.gate.hideWhenLocked, - limitAmount = entry.limit?.amount ?: 0, - limitPeriod = (entry.limit?.period ?: LimitPeriod.NONE).name, + limitAmount = entry.buyLimit?.amount ?: 0, + limitPeriod = (entry.buyLimit?.period ?: LimitPeriod.NONE).name, + sellLimitAmount = entry.sellLimit?.amount ?: 0, + sellLimitPeriod = (entry.sellLimit?.period ?: LimitPeriod.NONE).name, stockMax = entry.stock?.max ?: 0, stockRestockSeconds = entry.stock?.restockSeconds ?: 0L, stockRemaining = entry.stock?.remaining ?: 0, @@ -216,6 +216,9 @@ object ShopManager { }, ) + private fun toLimit(amount: Int, period: String): ShopLimit? = + if (amount > 0) ShopLimit(amount, enumOrDefault(period, LimitPeriod.NONE)) else null + private fun toCost(stored: StoredCost?): ShopCost? = stored?.let { ShopCost(it.money, ItemCodec.decodeAll(it.items)) } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopTrade.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopTrade.kt index 1f7a0e6..2dd21ed 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopTrade.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopTrade.kt @@ -6,6 +6,7 @@ import net.trilleo.mc.plugins.tritown.economy.EconomyContext import net.trilleo.mc.plugins.tritown.economy.TransactionReason import net.trilleo.mc.plugins.tritown.enums.MatchMode import net.trilleo.mc.plugins.tritown.enums.TownyRequirement +import net.trilleo.mc.plugins.tritown.enums.TradeSide import net.trilleo.mc.plugins.tritown.utils.EconomyUtil import org.bukkit.entity.Player import org.bukkit.inventory.ItemStack @@ -90,7 +91,7 @@ object ShopTrade { var max = MAX_BUNDLES entry.stock?.let { max = minOf(max, it.available(System.currentTimeMillis()) / bundle) } - ShopLimits.remaining(player, shop, entry)?.let { max = minOf(max, it / bundle) } + ShopLimits.remaining(player, shop, entry, TradeSide.BUY)?.let { max = minOf(max, it / bundle) } for (item in cost.items) { val held = ShopInventory.count(player, item, entry.matchMode) @@ -110,11 +111,21 @@ object ShopTrade { return max.coerceAtLeast(0) } - /** How many bundles of [entry] [player] is holding, for a shift-click that sells the lot. */ - fun maxSellable(player: Player, entry: ShopEntry): Int { + /** + * How many bundles of [entry] [player] could sell right now. + * + * Bounded by what they are carrying, their own selling limit and + * [MAX_BUNDLES], for a shift-click that sells the lot. + */ + fun maxSellable(player: Player, shop: ShopDefinition, entry: ShopEntry): Int { if (!entry.isSellable) return 0 + val bundle = entry.bundleSize val held = ShopInventory.count(player, entry.item, entry.matchMode) - return minOf(MAX_BUNDLES, held / entry.bundleSize) + var max = minOf(MAX_BUNDLES, held / bundle) + + ShopLimits.remaining(player, shop, entry, TradeSide.SELL)?.let { max = minOf(max, it / bundle) } + + return max.coerceAtLeast(0) } // ── Buying ────────────────────────────────────────────────────────── @@ -132,7 +143,7 @@ object ShopTrade { val items = entry.bundleSize * bundles - ShopLimits.remaining(player, shop, entry)?.let { left -> + ShopLimits.remaining(player, shop, entry, TradeSide.BUY)?.let { left -> if (left < items) return Result.Failure("shop.error.limit-reached", listOf("amount" to left)) } @@ -167,7 +178,7 @@ object ShopTrade { } ShopInventory.give(player, goods) - ShopLimits.record(player, shop, entry, items, now) + ShopLimits.record(player, shop, entry, TradeSide.BUY, items, now) entry.stats.recordBuy(bundles, quote.money) ShopManager.markDirty() @@ -186,11 +197,18 @@ object ShopTrade { ShopAccess.refusalKey(player, shop.gate, standing)?.let { return Result.Failure(it) } ShopAccess.refusalKey(player, entry.gate, standing)?.let { return Result.Failure(it) } + val items = entry.bundleSize * bundles + val now = System.currentTimeMillis() + + ShopLimits.remaining(player, shop, entry, TradeSide.SELL, now)?.let { left -> + if (left < items) return Result.Failure("shop.error.sell-limit-reached", listOf("amount" to left)) + } + val quote = quoteSell(entry, bundles) ?: return Result.Failure("shop.error.not-bought") if (!ShopInventory.hasSpaceFor(player, quote.items)) return Result.Failure("shop.error.no-space") if (quote.hasMoney && !EconomyUtil.isAvailable) return Result.Failure("shop.error.economy-unavailable") - val handedOver = ShopInventory.remove(player, entry.item, entry.matchMode, entry.bundleSize * bundles) + val handedOver = ShopInventory.remove(player, entry.item, entry.matchMode, items) ?: return Result.Failure("shop.error.missing-goods") if (quote.hasMoney) { @@ -202,6 +220,7 @@ object ShopTrade { } ShopInventory.give(player, quote.items) + ShopLimits.record(player, shop, entry, TradeSide.SELL, items, now) entry.stats.recordSell(bundles, quote.money) ShopManager.markDirty() diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopSchema.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopSchema.kt index e931353..01a6d20 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopSchema.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopSchema.kt @@ -13,7 +13,8 @@ package net.trilleo.mc.plugins.tritown.shops.storage * * 1. The first shape shops were written in. Stock and per-player limits counted * purchases. - * 2. Stock and per-player limits count items rather than purchases. + * 2. Stock and per-player limits count items, and an entry carries a separate + * limit for selling. */ object ShopSchema { diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/StoredShop.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/StoredShop.kt index c5badb1..6b8cdd6 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/StoredShop.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/StoredShop.kt @@ -33,6 +33,8 @@ data class StoredEntry( val hideWhenLocked: Boolean = false, val limitAmount: Int = 0, val limitPeriod: String = "NONE", + val sellLimitAmount: Int = 0, + val sellLimitPeriod: String = "NONE", val stockMax: Int = 0, val stockRestockSeconds: Long = 0L, val stockRemaining: Int = 0, diff --git a/src/main/resources/lang/en_US.yml b/src/main/resources/lang/en_US.yml index 1a6d53d..8352d82 100644 --- a/src/main/resources/lang/en_US.yml +++ b/src/main/resources/lang/en_US.yml @@ -448,7 +448,8 @@ gui: sell: "Sell: {price}" cost-item: "- {amount}x {item}" stock: "In stock: {amount}/{max}" - limit: "Left for you: {amount} ({period})" + limit: "You may still buy {amount} ({period})" + sell-limit: "You may still sell {amount} ({period})" click-buy: "Left-click to buy one" click-buy-max: "Shift-left-click to buy as many as you can" click-sell: "Right-click to sell one" @@ -551,7 +552,8 @@ gui: buy-items: "Price in items" sell-items: "Payout in items" item-count: "Items: {amount}" - limit: "Limit per player" + limit: "Buying limit per player" + sell-limit: "Selling limit per player" limit-value: "{amount} {period}" stock: "Stock" stock-unlimited: "Unlimited" @@ -572,6 +574,7 @@ gui: prompt-permission: "Type the permission node in chat." prompt-bundle: "Type how many items one purchase should give." prompt-limit: "Type how many items one player may buy per window. 0 removes the limit." + prompt-sell-limit: "Type how many items one player may sell per window. 0 removes the limit." prompt-stock: "Type the stock in items and the refill time in seconds, like 64 3600. 0 removes the stock." back: "Back" back-lore: "Return to the shop editor." @@ -631,6 +634,7 @@ shop: no-space: "You have no room for that." out-of-stock: "Only {amount} left in stock." limit-reached: "You may only buy {amount} more of this." + sell-limit-reached: "You may only sell {amount} more of this." economy-unavailable: "The economy is not available, so this cannot be paid for." payout-refused: "The payment could not be made, so nothing was sold." diff --git a/src/main/resources/lang/zh_CN.yml b/src/main/resources/lang/zh_CN.yml index ec6614b..4b0d506 100644 --- a/src/main/resources/lang/zh_CN.yml +++ b/src/main/resources/lang/zh_CN.yml @@ -449,7 +449,8 @@ gui: sell: "出售:{price}" cost-item: "- {amount}个 {item}" stock: "库存:{amount}/{max}" - limit: "你还能买:{amount} ({period})" + limit: "你还能买 {amount} 个 ({period})" + sell-limit: "你还能卖 {amount} 个 ({period})" click-buy: "左键 购买一份" click-buy-max: "Shift + 左键 买下能买的全部" click-sell: "右键 出售一份" @@ -553,6 +554,7 @@ gui: sell-items: "回收价(物品)" item-count: "物品种类:{amount}" limit: "每人限购" + sell-limit: "每人限售" limit-value: "{amount}({period})" stock: "库存" stock-unlimited: "无限" @@ -573,6 +575,7 @@ gui: prompt-permission: "请在聊天栏输入权限节点。" prompt-bundle: "请输入每次购买应获得的数量。" prompt-limit: "请输入每个玩家每个周期最多能买多少个物品。0 表示不限。" + prompt-sell-limit: "请输入每个玩家每个周期最多能卖多少个物品。0 表示不限。" prompt-stock: "请输入库存物品数和补货秒数,例如 64 3600。0 表示不限库存。" back: "返回" back-lore: "回到商店编辑器。" @@ -632,6 +635,7 @@ shop: no-space: "你的背包放不下。" out-of-stock: "库存只剩 {amount} 个。" limit-reached: "你最多还能买 {amount} 个。" + sell-limit-reached: "你最多还能卖 {amount} 个。" economy-unavailable: "经济系统不可用,无法付款。" payout-refused: "付款失败,交易未完成。" diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopMigrationsTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopMigrationsTest.kt index 3b5922b..fdfcf37 100644 --- a/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopMigrationsTest.kt +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopMigrationsTest.kt @@ -55,4 +55,12 @@ class ShopMigrationsTest { assertEquals(entry, upgraded(entry, from = ShopSchema.CURRENT)) } + + @Test + fun `the selling limit is left to its default on an upgraded file`() { + val entry = upgraded(StoredEntry(id = "bread", bundle = 16, limitAmount = 3)) + + assertEquals(0, entry.sellLimitAmount) + assertEquals("NONE", entry.sellLimitPeriod) + } } From 74e4aed383aeeafce942e828e29e8d3e3d03201e Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sun, 20 Sep 2026 15:51:40 +0800 Subject: [PATCH 03/10] Improvement: Say why a shift-click cannot buy anything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buying as many as you could always blamed the player's wallet when it came back with nothing, which is wrong whenever the stock or their own limit is what ran out — and now more often wrong, because a limit counted in items can leave a remainder too small for one more bundle. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 7 +++++ .../mc/plugins/tritown/guis/shop/ShopGUI.kt | 27 ++++++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d946c99..d2405d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ how much they buy. The two are set separately in the entry editor and counted separately, so an entry can be "buy 64 a day, sell 256 a day" without one side spending the other's allowance. +### Improvements + +#### Shops + ++ A shift-click that cannot buy anything now says why. It used to claim the player could not afford the entry even + when it was the stock or their own limit that had run out. + ### Fixes #### Shops diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt index d3f7b32..2cd5337 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt @@ -110,9 +110,7 @@ class ShopGUI : PagedPluginGUI( * confirmation menu takes over and this reports nothing. */ private fun tradeBuy(player: Player, shop: ShopDefinition, entry: ShopEntry, bundles: Int): ShopTrade.Result? { - if (bundles <= 0) { - return ShopTrade.Result.Failure("shop.error.cannot-afford", listOf("price" to unitPrice(player, entry))) - } + if (bundles <= 0) return nothingToBuy(player, shop, entry) val threshold = if (ShopSettings.isLoaded) ShopSettings.snapshot.confirmAbove else 0.0 val quote = ShopTrade.quoteBuy(player, entry, bundles) @@ -138,6 +136,29 @@ class ShopGUI : PagedPluginGUI( return ShopTrade.Result.Failure("shop.error.missing-goods") } + /** + * Why a shift-click could not buy anything at all. + * + * A limit and a stock are counted in items, so either can leave a remainder + * too small for one more bundle while still reading as more than nothing — + * telling that player they cannot afford it would simply be untrue. + */ + private fun nothingToBuy(player: Player, shop: ShopDefinition, entry: ShopEntry): ShopTrade.Result.Failure { + ShopLimits.remaining(player, shop, entry, TradeSide.BUY)?.let { left -> + if (left < entry.bundleSize) { + return ShopTrade.Result.Failure("shop.error.limit-reached", listOf("amount" to left)) + } + } + + entry.stock?.let { stock -> + if (stock.available(System.currentTimeMillis()) < entry.bundleSize) { + return ShopTrade.Result.Failure("shop.error.out-of-stock", listOf("amount" to stock.remaining)) + } + } + + return ShopTrade.Result.Failure("shop.error.cannot-afford", listOf("price" to unitPrice(player, entry))) + } + private fun announce(player: Player, entry: ShopEntry, result: ShopTrade.Result.Success) { val key = if (result.money > 0.0 || entry.buy?.hasMoney == true) "shop.traded" else "shop.traded-items" player.sendPrefixed( From f6efcdbadb284458a74df5c571c6321e0d159d2a Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sun, 20 Sep 2026 15:52:04 +0800 Subject: [PATCH 04/10] Improvement: Separate and colour a shop entry's description Prices, payouts, what is left and what a click does ran together in one list, so reading a price meant picking it out of a set of instructions. The lore is now built in blocks that ShopRender.sections spaces apart, dropping the ones an entry has nothing for. A price is red and a payout green wherever either appears, down to the items each side asks for, so the two directions are told apart before the number is read. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 + .../tritown/guis/shop/ShopConfirmGUI.kt | 2 +- .../mc/plugins/tritown/guis/shop/ShopGUI.kt | 92 ++++++++++++------- .../plugins/tritown/guis/shop/ShopRender.kt | 23 +++-- src/main/resources/lang/en_US.yml | 10 +- src/main/resources/lang/zh_CN.yml | 10 +- 6 files changed, 95 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2405d1..dfafce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ #### Shops ++ An entry's description now separates what it costs from what clicking does, with the prices, the payout and what is + left of the stock and your limits each in a block of their own. ++ A price is now red and a payout green wherever they appear, including the items either side asks for, so buying and + selling can be told apart at a glance. + A shift-click that cannot buy anything now says why. It used to claim the player could not afford the entry even when it was the stock or their own limit that had run out. diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopConfirmGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopConfirmGUI.kt index 7034722..684b8ca 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopConfirmGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopConfirmGUI.kt @@ -56,7 +56,7 @@ class ShopConfirmGUI : PluginGUI( if (quote != null && quote.hasMoney) { add(player.tr("gui.shop-confirm.price", "price" to ShopRender.money(quote.money))) } - entry.buy?.items?.forEach { add(ShopRender.itemLine(player, it, bundles)) } + entry.buy?.items?.forEach { add(ShopRender.itemLine(player, it, bundles, "gui.shop.buy-item")) } } inventory.setItem(SLOT_GOODS, ShopRender.withLore(entry.displayStack(), lines)) diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt index 2cd5337..1245517 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt @@ -211,36 +211,64 @@ class ShopGUI : PagedPluginGUI( return View(shop.id, entryIds, items) } + /** + * One entry as it sits on the shelf. + * + * The lore is built in blocks — what it costs, what it pays, how much of it + * is left, and what a click does — which [ShopRender.sections] spaces apart. + * Reading a price should not mean picking it out of a list of instructions. + */ private fun draw( viewer: Player, shop: ShopDefinition, entry: ShopEntry, standing: Set, ): ItemStack { - val lines = mutableListOf() val refusal = ShopAccess.refusalKey(viewer, entry.gate, standing) - if (entry.isBuyable) { - val quote = ShopTrade.quoteBuy(viewer, entry, 1, standing) - if (quote != null && quote.isDiscounted) { - lines += viewer.tr( - "gui.shop.buy-discounted", - "price" to ShopRender.money(quote.money), - "full" to ShopRender.money(quote.fullMoney), - ) - } else if (entry.buy?.hasMoney == true) { - lines += viewer.tr("gui.shop.buy", "price" to ShopRender.money(quote?.money ?: 0.0)) - } - entry.buy?.items?.forEach { lines += ShopRender.itemLine(viewer, it) } - } + val lore = ShopRender.sections( + listOf( + buyLines(viewer, entry, standing), + sellLines(viewer, entry), + availabilityLines(viewer, shop, entry), + if (refusal != null) listOf(viewer.tr(refusal)) else clickLines(viewer, entry), + ) + ) - if (entry.isSellable) { - entry.sell?.let { payout -> - if (payout.hasMoney) lines += viewer.tr("gui.shop.sell", "price" to ShopRender.money(payout.money)) - payout.items.forEach { lines += ShopRender.itemLine(viewer, it) } - } + return ShopRender.withLore(entry.displayStack(), lore) + } + + private fun buyLines(viewer: Player, entry: ShopEntry, standing: Set): List { + val cost = entry.buy ?: return emptyList() + val quote = ShopTrade.quoteBuy(viewer, entry, 1, standing) + val lines = mutableListOf() + + if (quote != null && quote.isDiscounted) { + lines += viewer.tr( + "gui.shop.buy-discounted", + "price" to ShopRender.money(quote.money), + "full" to ShopRender.money(quote.fullMoney), + ) + } else if (cost.hasMoney) { + lines += viewer.tr("gui.shop.buy", "price" to ShopRender.money(quote?.money ?: 0.0)) } + cost.items.forEach { lines += ShopRender.itemLine(viewer, it, key = "gui.shop.buy-item") } + return lines + } + + private fun sellLines(viewer: Player, entry: ShopEntry): List { + val payout = entry.sell ?: return emptyList() + val lines = mutableListOf() + + if (payout.hasMoney) lines += viewer.tr("gui.shop.sell", "price" to ShopRender.money(payout.money)) + payout.items.forEach { lines += ShopRender.itemLine(viewer, it, key = "gui.shop.sell-item") } + return lines + } + + private fun availabilityLines(viewer: Player, shop: ShopDefinition, entry: ShopEntry): List { + val lines = mutableListOf() + entry.stock?.let { stock -> lines += viewer.tr( "gui.shop.stock", @@ -265,20 +293,22 @@ class ShopGUI : PagedPluginGUI( ) } - if (refusal != null) { - lines += viewer.tr(refusal) - } else { - if (entry.isBuyable) { - lines += viewer.tr("gui.shop.click-buy") - lines += viewer.tr("gui.shop.click-buy-max") - } - if (entry.isSellable) { - lines += viewer.tr("gui.shop.click-sell") - lines += viewer.tr("gui.shop.click-sell-max") - } + return lines + } + + private fun clickLines(viewer: Player, entry: ShopEntry): List { + val lines = mutableListOf() + + if (entry.isBuyable) { + lines += viewer.tr("gui.shop.click-buy") + lines += viewer.tr("gui.shop.click-buy-max") + } + if (entry.isSellable) { + lines += viewer.tr("gui.shop.click-sell") + lines += viewer.tr("gui.shop.click-sell-max") } - return ShopRender.withLore(entry.displayStack(), lines) + return lines } private fun shopOf(player: Player): ShopDefinition? = views[player.uniqueId]?.let { ShopManager.get(it.shopId) } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt index 0dd35fb..925f6ba 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt @@ -51,12 +51,23 @@ object ShopRender { return custom?.let { ComponentUtil.escape(plain.serialize(it)) } ?: "" } - /** `3× Diamond`, for one line of a price or payout. */ - fun itemLine(player: Player, item: ItemStack, multiplier: Int = 1): String = player.tr( - "gui.shop.cost-item", - "amount" to item.amount * multiplier, - "item" to itemName(item), - ) + /** + * `3x Diamond`, for one line of a price or payout. + * + * [key] chooses the wording, so the same line can be coloured as something + * the player hands over or as something they are given. + */ + fun itemLine(player: Player, item: ItemStack, multiplier: Int = 1, key: String = "gui.shop.cost-item"): String = + player.tr(key, "amount" to item.amount * multiplier, "item" to itemName(item)) + + /** + * [blocks] run together into one lore, a blank line between each pair. + * + * Empty blocks are dropped rather than spaced, so an entry that is only for + * sale does not carry a gap where its payout would have been. + */ + fun sections(blocks: List>): List = + blocks.filter { it.isNotEmpty() }.reduceOrNull { left, right -> left + "" + right } ?: emptyList() /** Every line a [cost] needs, money first, or an empty list when it asks for nothing. */ fun costLines(player: Player, cost: ShopCost?, multiplier: Int = 1): List { diff --git a/src/main/resources/lang/en_US.yml b/src/main/resources/lang/en_US.yml index 8352d82..d32f4f4 100644 --- a/src/main/resources/lang/en_US.yml +++ b/src/main/resources/lang/en_US.yml @@ -443,13 +443,15 @@ gui: unknown: "Shop" empty: "Nothing for sale" empty-lore: "This shop has no entries yet." - buy: "Buy: {price}" - buy-discounted: "Buy: {price} {full}" + buy: "Buy: {price}" + buy-discounted: "Buy: {price} {full}" sell: "Sell: {price}" cost-item: "- {amount}x {item}" + buy-item: "- {amount}x {item}" + sell-item: "- {amount}x {item}" stock: "In stock: {amount}/{max}" - limit: "You may still buy {amount} ({period})" - sell-limit: "You may still sell {amount} ({period})" + limit: "You may still buy {amount} ({period})" + sell-limit: "You may still sell {amount} ({period})" click-buy: "Left-click to buy one" click-buy-max: "Shift-left-click to buy as many as you can" click-sell: "Right-click to sell one" diff --git a/src/main/resources/lang/zh_CN.yml b/src/main/resources/lang/zh_CN.yml index 4b0d506..470927d 100644 --- a/src/main/resources/lang/zh_CN.yml +++ b/src/main/resources/lang/zh_CN.yml @@ -444,13 +444,15 @@ gui: unknown: "商店" empty: "暂无商品" empty-lore: "这家商店还没有任何商品。" - buy: "购买:{price}" - buy-discounted: "购买:{price} {full}" + buy: "购买:{price}" + buy-discounted: "购买:{price} {full}" sell: "出售:{price}" cost-item: "- {amount}个 {item}" + buy-item: "- {amount}个 {item}" + sell-item: "- {amount}个 {item}" stock: "库存:{amount}/{max}" - limit: "你还能买 {amount} 个 ({period})" - sell-limit: "你还能卖 {amount} 个 ({period})" + limit: "你还能买 {amount} 个 ({period})" + sell-limit: "你还能卖 {amount} 个 ({period})" click-buy: "左键 购买一份" click-buy-max: "Shift + 左键 买下能买的全部" click-sell: "右键 出售一份" From a107b81fcfd2c490a66064affce2df1fdbef4d9d Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sun, 20 Sep 2026 16:15:13 +0800 Subject: [PATCH 05/10] Improvement: Add more amount options to stackable items in shop --- CHANGELOG.md | 12 +- README.md | 4 + docs/DEVELOPER_GUIDE.md | 24 +- .../tritown/guis/shop/ShopAmountGUI.kt | 211 ++++++++++++++++++ .../tritown/guis/shop/ShopConfirmGUI.kt | 62 ++--- .../mc/plugins/tritown/guis/shop/ShopGUI.kt | 123 +++------- .../plugins/tritown/guis/shop/ShopRender.kt | 93 ++++++++ .../mc/plugins/tritown/shops/ShopEntry.kt | 12 +- .../mc/plugins/tritown/shops/ShopQuote.kt | 7 +- .../mc/plugins/tritown/shops/ShopStats.kt | 18 +- .../mc/plugins/tritown/shops/ShopTrade.kt | 205 +++++++++-------- .../tritown/shops/storage/ShopMigrations.kt | 6 +- .../tritown/shops/storage/ShopSchema.kt | 4 +- src/main/resources/lang/en_US.yml | 12 +- src/main/resources/lang/zh_CN.yml | 12 +- .../shops/storage/ShopMigrationsTest.kt | 4 + 16 files changed, 559 insertions(+), 250 deletions(-) create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopAmountGUI.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index dfafce2..76e2bb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ + An entry can now limit how much each player **sells** to the shop per day, per week or ever, alongside the limit on how much they buy. The two are set separately in the entry editor and counted separately, so an entry can be "buy 64 a day, sell 256 a day" without one side spending the other's allowance. ++ Shift-left-clicking anything that stacks now opens a menu to pick how many to buy: 1, 8, 16, 32 or 64. They are + priced at the entry's own rate, so eight of something sold sixteen at a time costs half of what the shelf quotes, + and an amount you cannot take is greyed out with the reason rather than refusing once you have clicked it. This + replaces "buy as many as you can", which gave you a number you had not chosen and no way to ask for a smaller one. ### Improvements @@ -16,10 +20,10 @@ + An entry's description now separates what it costs from what clicking does, with the prices, the payout and what is left of the stock and your limits each in a block of their own. ++ A price that asks for items is refused for part of a purchase rather than quietly rounded, and says how many the + entry is traded at a time. + A price is now red and a payout green wherever they appear, including the items either side asks for, so buying and selling can be told apart at a glance. -+ A shift-click that cannot buy anything now says why. It used to claim the player could not afford the entry even - when it was the stock or their own limit that had run out. ### Fixes @@ -30,13 +34,15 @@ it moved one item or a hundred and twenty-eight. A limit of 64 is now sixty-four items, and stays sixty-four if you change the bundle afterwards. Existing shops are converted on first start, so every entry keeps trading exactly as it did; only the number you see in the editor changes unit. ++ A shop's sales figures count items too, so what one entry has traded can be compared with another whatever their + bundles are. Existing figures are converted with everything else. ### Technical Details #### Shops + An older shop file is now brought forward by `ShopMigrations` as it is read rather than being misread against the - current shape. Schema 2 is stock and limits in items, plus the selling limit. + current shape. Schema 2 is stock, limits and sales figures in items, plus the selling limit. ## Version 1.1.0 diff --git a/README.md b/README.md index 0ab5e2b..3fb0788 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,10 @@ A shop is created with `/tt shop create `, which opens its editor. Everythin - **Pricing it.** An entry has a buy side and a sell side, and each may be switched on or off on its own. Either side can ask for money, for items, or for both at once. Money is typed in chat when you click the price; items are added by clicking them in your inventory, and the stack size is the quantity. +- **Buying it.** A player left-clicks an entry to buy one purchase of it, and shift-left-clicks anything that stacks to + pick an amount instead — 1, 8, 16, 32 or 64, priced at the entry's own rate, so eight of something sold sixteen at a + time costs half. An amount they cannot take is greyed out with the reason rather than refusing after the click. Right + -click sells one purchase back, and shift-right-click sells everything they are carrying. - **Limiting it.** *Stock* is shared by everybody and refills to full on a timer. A *limit* is per player and resets daily, weekly, or never; buying and selling have one each, and they are counted separately. All of them are counted in items rather than in purchases — a limit of 64 on an entry that sells 16 at a time is four purchases — and all of diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index c98c9e5..87754d9 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -2566,8 +2566,13 @@ allows, which is what is both measured for room and handed over, while the first **Stock and limits are counted in items, not in purchases.** An administrator writing "64 a day" means sixty-four items however large the bundle is, which is the only reading that stays true when the bundle is edited afterwards. A -trade therefore spends `bundleSize * bundles` of each, and `maxBuyable` / `maxSellable` divide what is left by -`bundleSize` — a remainder too small for one more bundle is not offered, because half a bundle is not a purchase. +trade therefore spends exactly what it moves. `ShopTrade` takes an item count throughout: a left-click on the shelf +asks for `bundleSize`, and the amount menu asks for whatever the player picked. + +Money divides, so any amount can be priced — a bundle of sixteen at 10 puts one at 0.63, which is what keeping the +entry's own ratio means. A price or payout made of **items** cannot be divided that finely, so an amount that is not a +whole number of bundles has no quote at all: `quoteBuy` and `quoteSell` return `null` for it and the trade refuses with +`shop.error.part-bundle`. An entry carries a limit per side: `buyLimit` and `sellLimit`, reached through `limitOn(side)` / `setLimitOn(side, …)` with a `TradeSide`. They are independent, so neither spends the other's allowance. @@ -2611,7 +2616,8 @@ taken, and anything taken is remembered so it can be put back.** A buy, in order: -1. Re-check the gate, the buying limit and the stock, restocking lazily first. +1. Re-check everything that can refuse, through `buyRefusal` — the gate, the amount, the buying limit and the stock, + restocking lazily first. 2. Quote the price, applying the best discount the player's standing in Towny earns. 3. Check there is room for the goods. 4. Take the item side of the price, keeping what was removed. @@ -2680,6 +2686,7 @@ rather than in `getItems`. | Menu | What it does | |:-------------------|:------------------------------------------------------------------------| | `ShopGUI` | The player's view; buys and sells, and redraws only the entry traded | +| `ShopAmountGUI` | How many to buy: 1, 8, 16, 32 or 64, priced at the entry's own rate | | `ShopConfirmGUI` | A second look above `shops.confirm-above`; re-quotes on accept | | `ShopListGUI` | Every shop, for an administrator | | `ShopEditorGUI` | One shop's entries; adds one from the administrator's own inventory | @@ -2693,8 +2700,15 @@ Every one of them is framed: the paged menus through `PagedLayout.FRAMED`, and ` grid, through `GUIFrame` directly. The editor's actions — add, settings, figures, back — live in the navigation row via `navButtons`, so they do not shuffle along as entries are added. -`ShopRender` holds what they all draw with — item names, price lines, requirement names — and `ShopRender.navigate`, -which opens the next menu on the following tick. +`ShopRender` holds what they all draw with — item names, price lines, requirement names, and how a trade is announced +or refused — and `ShopRender.navigate`, which opens the next menu on the following tick. Every menu that can start a +purchase goes through `ShopConfirmGUI.askIfDear`, so `shops.confirm-above` guards all of them rather than whichever one +remembered to ask. + +**A menu never offers what a click would refuse.** `ShopAmountGUI` greys an amount out with the very +`ShopTrade.buyRefusal` the purchase itself runs, so what is drawn and what happens cannot drift apart. The one thing +that refusal leaves out is the money: a balance may be read to draw a menu, but only the withdrawal may decide a +charge, so the menu asks `EconomyUtil.has` itself and `ShopTrade.buy` still charges atomically. **Items are never taken to add them.** Clicking a stack in the administrator's own inventory copies it and cancels the event; a drag reads `event.oldCursor` and cancels too. A live slot would lose the item to a crash or a mistimed close, diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopAmountGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopAmountGUI.kt new file mode 100644 index 0000000..7ccaa9e --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopAmountGUI.kt @@ -0,0 +1,211 @@ +package net.trilleo.mc.plugins.tritown.guis.shop + +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.enums.TownyRequirement +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PluginGUI +import net.trilleo.mc.plugins.tritown.shops.* +import net.trilleo.mc.plugins.tritown.utils.* +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +/** + * How many of one entry to buy. + * + * The amounts on offer are the ones a player already thinks in — a single, and + * the stack fractions above it — rather than a number typed in chat, and they + * are priced at the entry's own rate, so eight of something sold sixteen at a + * time costs half of what the shelf quotes. + * + * An amount the player cannot take is drawn as grey glass saying why and does + * nothing when clicked: a menu that offers something and then refuses it is + * worse than one that says so up front. What greys an option out is the same + * [ShopTrade.buyRefusal] the purchase itself runs, so the two cannot drift + * apart. Only the money is asked about separately, because a balance may be + * read to draw a menu but never to decide a charge. + */ +class ShopAmountGUI : PluginGUI( + id = ID, + titleKey = "gui.shop-amount.title", + rows = 3, + fillMode = FillMode.DARK, +) { + + private data class Target(val shopId: String, val entryId: String) + + private val choosing = ConcurrentHashMap() + + /** Asks [player] how many of [entry] they want. */ + fun open(player: Player, shop: ShopDefinition, entry: ShopEntry) { + choosing[player.uniqueId] = Target(shop.id, entry.id) + GUIManager.open(player, ID) + } + + override fun setup(player: Player, inventory: Inventory) { + val (shop, entry) = resolve(player) ?: return + val standing = ShopAccess.standing(player) + + inventory.setItem(SLOT_GOODS, goods(player, entry, standing)) + AMOUNTS.forEachIndexed { index, amount -> + inventory.setItem(SLOT_FIRST + index, option(player, shop, entry, amount, standing)) + } + inventory.setItem( + SLOT_BACK, + button(player, Material.ARROW, "gui.shop-amount.back", "gui.shop-amount.back-lore"), + ) + } + + override fun onClick(event: InventoryClickEvent) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + val (shop, entry) = resolve(player) ?: return + + if (event.rawSlot == SLOT_BACK) { + ShopRender.navigate { ShopGUI.show(player, shop) } + return + } + + val index = event.rawSlot - SLOT_FIRST + val amount = if (index >= 0) AMOUNTS.getOrNull(index) else null + if (amount != null) buy(player, shop, entry, amount, event.inventory) + } + + override fun onClose(event: InventoryCloseEvent) { + choosing.remove((event.player as? Player)?.uniqueId ?: return) + } + + // ── Buying ────────────────────────────────────────────────────────── + + /** + * Buys [amount], then redraws rather than closing. + * + * A player who wanted sixteen often wants sixteen more, and what they can + * still take has changed under them — their balance, the stock and their + * own limit all moved. + */ + private fun buy(player: Player, shop: ShopDefinition, entry: ShopEntry, amount: Int, inventory: Inventory) { + if (refusal(player, shop, entry, amount, ShopAccess.standing(player)) != null) return + if (ShopConfirmGUI.askIfDear(player, shop, entry, amount)) return + + when (val result = ShopTrade.buy(player, shop, entry, amount)) { + is ShopTrade.Result.Success -> { + ShopRender.announce(player, entry, result) + setup(player, inventory) + } + + is ShopTrade.Result.Failure -> ShopRender.refuse(player, result) + } + } + + /** + * Why [amount] is not on offer, or `null` when it is. + * + * The trade's own refusal, plus the one thing it deliberately leaves out: + * whether the player has the money. A balance is read here only to draw the + * menu — the charge itself is still decided by the withdrawal. + */ + private fun refusal( + player: Player, + shop: ShopDefinition, + entry: ShopEntry, + amount: Int, + standing: Set, + ): ShopTrade.Result.Failure? { + ShopTrade.buyRefusal(player, shop, entry, amount, standing)?.let { return it } + + val quote = ShopTrade.quoteBuy(player, entry, amount, standing) ?: return null + if (quote.hasMoney && EconomyUtil.isAvailable && !EconomyUtil.has(player, quote.money)) { + return ShopTrade.Result.Failure( + "shop.error.cannot-afford", + listOf("price" to ShopRender.money(quote.money)), + ) + } + + return null + } + + // ── Drawing ───────────────────────────────────────────────────────── + + /** The goods themselves, priced as the shelf prices them, so the rate the amounts follow is on screen. */ + private fun goods(player: Player, entry: ShopEntry, standing: Set): ItemStack { + val bundle = entry.bundleSize + val perPurchase = + if (bundle > 1) listOf(player.tr("gui.shop-amount.bundle", "amount" to bundle)) else emptyList() + + return ShopRender.withLore( + entry.displayStack(), + ShopRender.sections(listOf(ShopRender.buyLines(player, entry, bundle, standing), perPurchase)), + ) + } + + private fun option( + player: Player, + shop: ShopDefinition, + entry: ShopEntry, + amount: Int, + standing: Set, + ): ItemStack { + val refusal = refusal(player, shop, entry, amount, standing) + val name = player.tr( + if (refusal == null) "gui.shop-amount.option" else "gui.shop-amount.option-locked", + "amount" to amount, + "item" to ShopRender.itemName(entry.item), + ) + + val closing = refusal + ?.let { listOf(player.tr(it.key, *it.args.toTypedArray())) } + ?: listOf(player.tr("gui.shop-amount.click-buy")) + + val lore = ShopRender.sections(listOf(ShopRender.buyLines(player, entry, amount, standing), closing)) + + if (refusal != null) { + return itemStack(Material.GRAY_STAINED_GLASS_PANE) { + name(name) + meta { lore(LoreUtil.wrapLore(lore.joinToString(""))) } + } + } + + // The goods themselves, stacked as far as a slot can show, so the amount is read before the label is. + val shown = entry.item.clone().apply { this.amount = amount.coerceIn(1, entry.item.maxStackSize) } + return ShopRender.named(shown, name, lore) + } + + private fun button(player: Player, material: Material, nameKey: String, loreKey: String): ItemStack = + itemStack(material) { + name(player.tr(nameKey)) + meta { lore(LoreUtil.wrapLore(player.tr(loreKey))) } + } + + private fun resolve(player: Player): Pair? { + val target = choosing[player.uniqueId] ?: return null + val shop = ShopManager.get(target.shopId) ?: return null + val entry = shop.entry(target.entryId) ?: return null + return shop to entry + } + + companion object { + const val ID = "shop-amount" + + /** What is on offer: a single, and the stack fractions above it. */ + val AMOUNTS = listOf(1, 8, 16, 32, 64) + + /** The middle row, centred: five options either side of slot 13 rather than packed against the left. */ + private const val SLOT_FIRST = 11 + private const val SLOT_GOODS = 4 + private const val SLOT_BACK = 22 + + /** Opens the amount menu for [entry] through the registered instance. */ + fun show(player: Player, shop: ShopDefinition, entry: ShopEntry): Boolean { + val gui = GUIManager.getGUI(ID) as? ShopAmountGUI ?: return false + gui.open(player, shop, entry) + return true + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopConfirmGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopConfirmGUI.kt index 684b8ca..1deefbf 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopConfirmGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopConfirmGUI.kt @@ -1,7 +1,6 @@ package net.trilleo.mc.plugins.tritown.guis.shop -import net.kyori.adventure.key.Key -import net.kyori.adventure.sound.Sound +import net.trilleo.mc.plugins.tritown.config.ShopSettings import net.trilleo.mc.plugins.tritown.enums.FillMode import net.trilleo.mc.plugins.tritown.registration.GUIManager import net.trilleo.mc.plugins.tritown.registration.PluginGUI @@ -11,7 +10,6 @@ import net.trilleo.mc.plugins.tritown.shops.ShopManager import net.trilleo.mc.plugins.tritown.shops.ShopTrade import net.trilleo.mc.plugins.tritown.utils.LoreUtil import net.trilleo.mc.plugins.tritown.utils.itemStack -import net.trilleo.mc.plugins.tritown.utils.sendPrefixed import net.trilleo.mc.plugins.tritown.utils.tr import org.bukkit.Material import org.bukkit.entity.Player @@ -37,26 +35,26 @@ class ShopConfirmGUI : PluginGUI( fillMode = FillMode.DARK, ) { - private data class Pending(val shopId: String, val entryId: String, val bundles: Int) + private data class Pending(val shopId: String, val entryId: String, val amount: Int) private val pending = ConcurrentHashMap() - /** Asks [player] to confirm buying [bundles] of [entry]. */ - fun open(player: Player, shop: ShopDefinition, entry: ShopEntry, bundles: Int) { - pending[player.uniqueId] = Pending(shop.id, entry.id, bundles) + /** Asks [player] to confirm buying [amount] of [entry]. */ + fun open(player: Player, shop: ShopDefinition, entry: ShopEntry, amount: Int) { + pending[player.uniqueId] = Pending(shop.id, entry.id, amount) GUIManager.open(player, ID) } override fun setup(player: Player, inventory: Inventory) { - val (shop, entry, bundles) = resolve(player) ?: return - val quote = ShopTrade.quoteBuy(player, entry, bundles) + val (_, entry, amount) = resolve(player) ?: return + val quote = ShopTrade.quoteBuy(player, entry, amount) val lines = buildList { - add(player.tr("gui.shop-confirm.amount", "amount" to entry.bundleSize * bundles)) + add(player.tr("gui.shop-confirm.amount", "amount" to amount)) if (quote != null && quote.hasMoney) { add(player.tr("gui.shop-confirm.price", "price" to ShopRender.money(quote.money))) } - entry.buy?.items?.forEach { add(ShopRender.itemLine(player, it, bundles, "gui.shop.buy-item")) } + quote?.items?.forEach { add(ShopRender.itemLine(player, it, key = "gui.shop.buy-item")) } } inventory.setItem(SLOT_GOODS, ShopRender.withLore(entry.displayStack(), lines)) @@ -86,23 +84,10 @@ class ShopConfirmGUI : PluginGUI( } private fun accept(player: Player) { - val (shop, entry, bundles) = resolve(player) ?: return - when (val result = ShopTrade.buy(player, shop, entry, bundles)) { - is ShopTrade.Result.Success -> { - player.sendPrefixed( - player.tr( - "shop.traded", - "amount" to entry.bundleSize * result.bundles, - "item" to ShopRender.itemName(entry.item), - "price" to ShopRender.money(result.money), - ) - ) - player.playSound(Sound.sound(Key.key("minecraft:entity.villager.yes"), Sound.Source.UI, 1f, 1f)) - } - - is ShopTrade.Result.Failure -> player.sendPrefixed( - player.tr("common.error", "message" to player.tr(result.key, *result.args.toTypedArray())) - ) + val (shop, entry, amount) = resolve(player) ?: return + when (val result = ShopTrade.buy(player, shop, entry, amount)) { + is ShopTrade.Result.Success -> ShopRender.announce(player, entry, result) + is ShopTrade.Result.Failure -> ShopRender.refuse(player, result) } back(player) @@ -122,7 +107,7 @@ class ShopConfirmGUI : PluginGUI( val held = pending[player.uniqueId] ?: return null val shop = ShopManager.get(held.shopId) ?: return null val entry = shop.entry(held.entryId) ?: return null - return Triple(shop, entry, held.bundles) + return Triple(shop, entry, held.amount) } private fun button(player: Player, material: Material, nameKey: String, loreKey: String): ItemStack = @@ -134,6 +119,25 @@ class ShopConfirmGUI : PluginGUI( companion object { const val ID = "shop-confirm" + /** + * Asks [player] to confirm buying [amount] when the bill is over + * `shops.confirm-above`, and reports whether it took the purchase over. + * + * Every menu that can start a purchase goes through this, so the + * threshold guards all of them rather than whichever remembered to ask. + */ + fun askIfDear(player: Player, shop: ShopDefinition, entry: ShopEntry, amount: Int): Boolean { + val threshold = if (ShopSettings.isLoaded) ShopSettings.snapshot.confirmAbove else 0.0 + if (threshold <= 0.0) return false + + val quote = ShopTrade.quoteBuy(player, entry, amount) ?: return false + if (quote.money <= threshold) return false + + val gui = GUIManager.getGUI(ID) as? ShopConfirmGUI ?: return false + ShopRender.navigate { gui.open(player, shop, entry, amount) } + return true + } + private const val SLOT_GOODS = 13 private const val SLOT_ACCEPT = 11 private const val SLOT_CANCEL = 15 diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt index 1245517..78aa6b6 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt @@ -1,9 +1,6 @@ package net.trilleo.mc.plugins.tritown.guis.shop -import net.kyori.adventure.key.Key -import net.kyori.adventure.sound.Sound import net.kyori.adventure.text.Component -import net.trilleo.mc.plugins.tritown.config.ShopSettings import net.trilleo.mc.plugins.tritown.enums.FillMode import net.trilleo.mc.plugins.tritown.enums.PagedLayout import net.trilleo.mc.plugins.tritown.enums.TownyRequirement @@ -77,24 +74,20 @@ class ShopGUI : PagedPluginGUI( val entry = view.entryIds.getOrNull(index)?.let(shop::entry) ?: return val result = when (event.click) { - ClickType.LEFT -> tradeBuy(player, shop, entry, 1) - ClickType.SHIFT_LEFT -> tradeBuy(player, shop, entry, ShopTrade.maxBuyable(player, shop, entry)) - ClickType.RIGHT -> ShopTrade.sell(player, shop, entry, 1) + ClickType.LEFT -> tradeBuy(player, shop, entry, entry.bundleSize) + ClickType.SHIFT_LEFT -> chooseAmount(player, shop, entry) + ClickType.RIGHT -> ShopTrade.sell(player, shop, entry, entry.bundleSize) ClickType.SHIFT_RIGHT -> sellMax(player, shop, entry) else -> return } ?: return when (result) { is ShopTrade.Result.Success -> { - announce(player, entry, result) + ShopRender.announce(player, entry, result) redraw(event, view, index, player, shop, entry) } - is ShopTrade.Result.Failure -> { - val reason = player.tr(result.key, *result.args.toTypedArray()) - player.sendPrefixed(player.tr("common.error", "message" to reason)) - player.playSound(Sound.sound(Key.key("minecraft:entity.villager.no"), Sound.Source.UI, 1f, 1f)) - } + is ShopTrade.Result.Failure -> ShopRender.refuse(player, result) } } @@ -109,24 +102,27 @@ class ShopGUI : PagedPluginGUI( * Buys, unless the bill is large enough to be worth a second look — then the * confirmation menu takes over and this reports nothing. */ - private fun tradeBuy(player: Player, shop: ShopDefinition, entry: ShopEntry, bundles: Int): ShopTrade.Result? { - if (bundles <= 0) return nothingToBuy(player, shop, entry) - - val threshold = if (ShopSettings.isLoaded) ShopSettings.snapshot.confirmAbove else 0.0 - val quote = ShopTrade.quoteBuy(player, entry, bundles) - val confirm = confirmGUI() - - if (threshold > 0.0 && confirm != null && quote != null && quote.money > threshold) { - ShopRender.navigate { confirm.open(player, shop, entry, bundles) } - return null - } + private fun tradeBuy(player: Player, shop: ShopDefinition, entry: ShopEntry, amount: Int): ShopTrade.Result? { + if (ShopConfirmGUI.askIfDear(player, shop, entry, amount)) return null + return ShopTrade.buy(player, shop, entry, amount) + } - return ShopTrade.buy(player, shop, entry, bundles) + /** + * Opens the amount menu, and reports nothing because the trade happens + * there. + * + * Only offered for goods that stack: an amount menu for a single item is + * five ways of saying one. + */ + private fun chooseAmount(player: Player, shop: ShopDefinition, entry: ShopEntry): ShopTrade.Result? { + if (!entry.isBuyable || !entry.isStackable) return null + ShopRender.navigate { ShopAmountGUI.show(player, shop, entry) } + return null } private fun sellMax(player: Player, shop: ShopDefinition, entry: ShopEntry): ShopTrade.Result { - val bundles = ShopTrade.maxSellable(player, shop, entry) - if (bundles > 0) return ShopTrade.sell(player, shop, entry, bundles) + val amount = ShopTrade.maxSellable(player, shop, entry) + if (amount > 0) return ShopTrade.sell(player, shop, entry, amount) ShopLimits.remaining(player, shop, entry, TradeSide.SELL)?.let { left -> if (left < entry.bundleSize) { @@ -136,42 +132,6 @@ class ShopGUI : PagedPluginGUI( return ShopTrade.Result.Failure("shop.error.missing-goods") } - /** - * Why a shift-click could not buy anything at all. - * - * A limit and a stock are counted in items, so either can leave a remainder - * too small for one more bundle while still reading as more than nothing — - * telling that player they cannot afford it would simply be untrue. - */ - private fun nothingToBuy(player: Player, shop: ShopDefinition, entry: ShopEntry): ShopTrade.Result.Failure { - ShopLimits.remaining(player, shop, entry, TradeSide.BUY)?.let { left -> - if (left < entry.bundleSize) { - return ShopTrade.Result.Failure("shop.error.limit-reached", listOf("amount" to left)) - } - } - - entry.stock?.let { stock -> - if (stock.available(System.currentTimeMillis()) < entry.bundleSize) { - return ShopTrade.Result.Failure("shop.error.out-of-stock", listOf("amount" to stock.remaining)) - } - } - - return ShopTrade.Result.Failure("shop.error.cannot-afford", listOf("price" to unitPrice(player, entry))) - } - - private fun announce(player: Player, entry: ShopEntry, result: ShopTrade.Result.Success) { - val key = if (result.money > 0.0 || entry.buy?.hasMoney == true) "shop.traded" else "shop.traded-items" - player.sendPrefixed( - player.tr( - key, - "amount" to entry.bundleSize * result.bundles, - "item" to ShopRender.itemName(entry.item), - "price" to ShopRender.money(result.money), - ) - ) - player.playSound(Sound.sound(Key.key("minecraft:entity.villager.yes"), Sound.Source.UI, 1f, 1f)) - } - private fun redraw( event: InventoryClickEvent, view: View, @@ -228,8 +188,8 @@ class ShopGUI : PagedPluginGUI( val lore = ShopRender.sections( listOf( - buyLines(viewer, entry, standing), - sellLines(viewer, entry), + ShopRender.buyLines(viewer, entry, entry.bundleSize, standing), + ShopRender.sellLines(viewer, entry, entry.bundleSize), availabilityLines(viewer, shop, entry), if (refusal != null) listOf(viewer.tr(refusal)) else clickLines(viewer, entry), ) @@ -238,34 +198,6 @@ class ShopGUI : PagedPluginGUI( return ShopRender.withLore(entry.displayStack(), lore) } - private fun buyLines(viewer: Player, entry: ShopEntry, standing: Set): List { - val cost = entry.buy ?: return emptyList() - val quote = ShopTrade.quoteBuy(viewer, entry, 1, standing) - val lines = mutableListOf() - - if (quote != null && quote.isDiscounted) { - lines += viewer.tr( - "gui.shop.buy-discounted", - "price" to ShopRender.money(quote.money), - "full" to ShopRender.money(quote.fullMoney), - ) - } else if (cost.hasMoney) { - lines += viewer.tr("gui.shop.buy", "price" to ShopRender.money(quote?.money ?: 0.0)) - } - - cost.items.forEach { lines += ShopRender.itemLine(viewer, it, key = "gui.shop.buy-item") } - return lines - } - - private fun sellLines(viewer: Player, entry: ShopEntry): List { - val payout = entry.sell ?: return emptyList() - val lines = mutableListOf() - - if (payout.hasMoney) lines += viewer.tr("gui.shop.sell", "price" to ShopRender.money(payout.money)) - payout.items.forEach { lines += ShopRender.itemLine(viewer, it, key = "gui.shop.sell-item") } - return lines - } - private fun availabilityLines(viewer: Player, shop: ShopDefinition, entry: ShopEntry): List { val lines = mutableListOf() @@ -301,7 +233,7 @@ class ShopGUI : PagedPluginGUI( if (entry.isBuyable) { lines += viewer.tr("gui.shop.click-buy") - lines += viewer.tr("gui.shop.click-buy-max") + if (entry.isStackable) lines += viewer.tr("gui.shop.click-amount") } if (entry.isSellable) { lines += viewer.tr("gui.shop.click-sell") @@ -313,11 +245,6 @@ class ShopGUI : PagedPluginGUI( private fun shopOf(player: Player): ShopDefinition? = views[player.uniqueId]?.let { ShopManager.get(it.shopId) } - private fun unitPrice(player: Player, entry: ShopEntry): String = - ShopRender.money(ShopTrade.quoteBuy(player, entry, 1)?.money ?: 0.0) - - private fun confirmGUI(): ShopConfirmGUI? = GUIManager.getGUI(ShopConfirmGUI.ID) as? ShopConfirmGUI - companion object { const val ID = "shop" diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt index 925f6ba..2f537f0 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt @@ -1,5 +1,7 @@ package net.trilleo.mc.plugins.tritown.guis.shop +import net.kyori.adventure.key.Key +import net.kyori.adventure.sound.Sound import net.kyori.adventure.text.Component import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer import net.trilleo.mc.plugins.tritown.Main @@ -7,9 +9,12 @@ import net.trilleo.mc.plugins.tritown.enums.LimitPeriod import net.trilleo.mc.plugins.tritown.enums.MatchMode import net.trilleo.mc.plugins.tritown.enums.TownyRequirement import net.trilleo.mc.plugins.tritown.shops.ShopCost +import net.trilleo.mc.plugins.tritown.shops.ShopEntry +import net.trilleo.mc.plugins.tritown.shops.ShopTrade import net.trilleo.mc.plugins.tritown.utils.ComponentUtil import net.trilleo.mc.plugins.tritown.utils.EconomyUtil import net.trilleo.mc.plugins.tritown.utils.LoreUtil +import net.trilleo.mc.plugins.tritown.utils.sendPrefixed import net.trilleo.mc.plugins.tritown.utils.tr import org.bukkit.Bukkit import org.bukkit.entity.Player @@ -60,6 +65,50 @@ object ShopRender { fun itemLine(player: Player, item: ItemStack, multiplier: Int = 1, key: String = "gui.shop.cost-item"): String = player.tr(key, "amount" to item.amount * multiplier, "item" to itemName(item)) + /** + * What [amount] of [entry] costs, money first, coloured as something the + * player hands over. + * + * Shared by the shelf and the amount menu so a price reads the same in + * both, and taken from a quote rather than multiplied here, because what a + * discount does to it is the quote's business. + */ + fun buyLines( + player: Player, + entry: ShopEntry, + amount: Int, + standing: Set, + ): List { + val cost = entry.buy ?: return emptyList() + val quote = ShopTrade.quoteBuy(player, entry, amount, standing) ?: return emptyList() + val lines = mutableListOf() + + if (quote.isDiscounted) { + lines += player.tr( + "gui.shop.buy-discounted", + "price" to money(quote.money), + "full" to money(quote.fullMoney), + ) + } else if (cost.hasMoney) { + lines += player.tr("gui.shop.buy", "price" to money(quote.money)) + } + + cost.items.forEach { lines += itemLine(player, it, amount / entry.bundleSize, "gui.shop.buy-item") } + return lines + } + + /** What [entry] pays out for [amount], coloured as something the player is given. */ + fun sellLines(player: Player, entry: ShopEntry, amount: Int): List { + val payout = entry.sell ?: return emptyList() + val quote = ShopTrade.quoteSell(entry, amount) ?: return emptyList() + val lines = mutableListOf() + + if (payout.hasMoney) lines += player.tr("gui.shop.sell", "price" to money(quote.money)) + + payout.items.forEach { lines += itemLine(player, it, amount / entry.bundleSize, "gui.shop.sell-item") } + return lines + } + /** * [blocks] run together into one lore, a blank line between each pair. * @@ -106,6 +155,34 @@ object ShopRender { MatchMode.MATERIAL -> player.tr("gui.shop.match-material") } + /** + * Tells [player] a trade went through, in the words the amount deserves. + * + * Every menu that can start a trade says the same thing afterwards, so this + * lives here rather than being written out again in each of them. + */ + fun announce(player: Player, entry: ShopEntry, result: ShopTrade.Result.Success) { + val key = if (result.money > 0.0 || entry.buy?.hasMoney == true) "shop.traded" else "shop.traded-items" + + player.sendPrefixed( + player.tr( + key, + "amount" to result.amount, + "item" to itemName(entry.item), + "price" to money(result.money), + ) + ) + player.playSound(Sound.sound(Key.key("minecraft:entity.villager.yes"), Sound.Source.UI, 1f, 1f)) + } + + /** Tells [player] why a trade was refused, colouring the reason as this plugin's errors are. */ + fun refuse(player: Player, failure: ShopTrade.Result.Failure) { + val reason = player.tr(failure.key, *failure.args.toTypedArray()) + + player.sendPrefixed(player.tr("common.error", "message" to reason)) + player.playSound(Sound.sound(Key.key("minecraft:entity.villager.no"), Sound.Source.UI, 1f, 1f)) + } + /** * A copy of [item] that glints, for the one thing a menu is asking about. * @@ -119,6 +196,22 @@ object ShopRender { itemMeta = meta } + /** + * A copy of [item] called [name] and described by [lines]. + * + * The name is reset and un-italicised the way the item DSL does it, because + * a display name written straight onto an item comes out in Minecraft's own + * italics and reads as a rename rather than a label. + */ + fun named(item: ItemStack, name: String, lines: List): ItemStack { + val copy = withLore(item, lines) + val meta = copy.itemMeta ?: return copy + + meta.displayName(ComponentUtil.parse("$name")) + copy.itemMeta = meta + return copy + } + /** * A copy of [item] with [lines] added under whatever lore it already has. * diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopEntry.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopEntry.kt index 0a10c7e..3bd0247 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopEntry.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopEntry.kt @@ -44,6 +44,9 @@ data class ShopEntry( /** Whether the shop buys this back. */ val isSellable: Boolean get() = sell != null + /** Whether an amount menu makes sense for these goods: a single item cannot be bought eight at a time. */ + val isStackable: Boolean get() = item.maxStackSize > 1 + /** The per-player cap that applies to [side], or `null` when that side is uncapped. */ fun limitOn(side: TradeSide): ShopLimit? = when (side) { TradeSide.BUY -> buyLimit @@ -67,20 +70,19 @@ data class ShopEntry( fun displayStack(): ItemStack = item.clone().apply { amount = bundleSize.coerceAtMost(item.maxStackSize) } /** - * [bundles] bundles of the goods, split into stacks the game allows. + * [amount] of the goods, split into stacks the game allows. * * Split here rather than left as one oversized stack, so that what is * checked for room is exactly what is handed over. */ - fun goodsStacks(bundles: Int = 1): List { - val total = bundleSize * bundles + fun goodsStacks(amount: Int = bundleSize): List { val perStack = item.maxStackSize.coerceAtLeast(1) return buildList { - var outstanding = total + var outstanding = amount while (outstanding > 0) { val size = minOf(outstanding, perStack) - add(item.clone().apply { amount = size }) + add(item.clone().apply { this.amount = size }) outstanding -= size } } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopQuote.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopQuote.kt index 87bff4d..7511443 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopQuote.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopQuote.kt @@ -3,7 +3,7 @@ package net.trilleo.mc.plugins.tritown.shops import org.bukkit.inventory.ItemStack /** - * What a given number of bundles would actually cost, or pay out, for one + * What a given number of items would actually cost, or pay out, for one * player at this moment. * * Prices are worked out once and then both shown and charged from the same @@ -12,10 +12,11 @@ import org.bukkit.inventory.ItemStack * * @param money the total actually charged or paid, after any discount * @param fullMoney the total before the discount, for showing what was saved - * @param items the stacks required or paid out in total, already multiplied by [bundles] + * @param amount how many items the quote is for + * @param items the stacks required or paid out in total, for all of [amount] */ data class ShopQuote( - val bundles: Int, + val amount: Int, val money: Double, val fullMoney: Double, val items: List, diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStats.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStats.kt index 7a297ff..9830443 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStats.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStats.kt @@ -3,9 +3,9 @@ package net.trilleo.mc.plugins.tritown.shops /** * What one entry has traded since the counters were last reset. * - * Counts bundles rather than items, matching what a player clicks, and keeps - * the two currency directions apart so an owner can see at a glance whether a - * shop is draining the economy or feeding it. + * Counts items, the same unit as a stock and a per-player limit, and keeps the + * two currency directions apart so an owner can see at a glance whether a shop + * is draining the economy or feeding it. */ data class ShopStats( var bought: Long = 0L, @@ -14,15 +14,15 @@ data class ShopStats( var moneyOut: Double = 0.0, ) { - /** Records a player buying [bundles] for [money]. */ - fun recordBuy(bundles: Int, money: Double) { - bought += bundles + /** Records a player buying [items] for [money]. */ + fun recordBuy(items: Int, money: Double) { + bought += items moneyIn += money } - /** Records a player selling [bundles] for [money]. */ - fun recordSell(bundles: Int, money: Double) { - sold += bundles + /** Records a player selling [items] for [money]. */ + fun recordSell(items: Int, money: Double) { + sold += items moneyOut += money } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopTrade.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopTrade.kt index 2dd21ed..7df23e9 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopTrade.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopTrade.kt @@ -10,7 +10,6 @@ import net.trilleo.mc.plugins.tritown.enums.TradeSide import net.trilleo.mc.plugins.tritown.utils.EconomyUtil import org.bukkit.entity.Player import org.bukkit.inventory.ItemStack -import kotlin.math.floor /** * Buying and selling, and the only place either happens. @@ -27,14 +26,14 @@ import kotlin.math.floor */ object ShopTrade { - /** The most bundles one click may move, so a shift-click cannot try to buy a chest-load at once. */ - const val MAX_BUNDLES = 64 + /** The most items one trade may move, so nothing can try to buy a warehouse in one click. */ + const val MAX_ITEMS = 4096 /** The outcome of a trade. */ sealed interface Result { /** The trade went through. */ - data class Success(val bundles: Int, val money: Double) : Result + data class Success(val amount: Int, val money: Double) : Result /** The trade was refused, with a `shop.error.*` key and any arguments the message needs. */ data class Failure(val key: String, val args: List> = emptyList()) : Result @@ -42,19 +41,29 @@ object ShopTrade { // ── Quoting ───────────────────────────────────────────────────────── - /** What [bundles] of [entry] would cost [player], with any discount applied. */ + /** + * What [amount] items of [entry] would cost [player], with any discount applied. + * + * Money is priced per item, so any amount can be quoted: a bundle of + * sixteen at 10 puts one at 0.63, which is what keeping the entry's own + * ratio means. A price that asks for items cannot be divided that finely — + * a quarter of an iron ingot is not a thing to hand over — so an amount + * that is not a whole number of bundles has no price at all, and is refused + * before it is ever asked for. + */ fun quoteBuy( player: Player, entry: ShopEntry, - bundles: Int, + amount: Int, standing: Set = ShopAccess.standing(player), ): ShopQuote? { val cost = entry.buy ?: return null + val bundles = wholeBundles(entry, cost, amount) ?: return null val discount = if (entry.discountable) discountFor(standing) else 0.0 - val full = ShopPricing.round(cost.money * bundles, scale()) + val full = ShopPricing.round(cost.money / entry.bundleSize * amount, scale()) return ShopQuote( - bundles = bundles, + amount = amount, money = ShopPricing.apply(full, discount, scale()), fullMoney = full, items = multiply(cost.items, bundles), @@ -62,108 +71,102 @@ object ShopTrade { ) } - /** What the shop would pay [player] for [bundles] of [entry]. Discounts never apply to a payout. */ - fun quoteSell(entry: ShopEntry, bundles: Int): ShopQuote? { + /** What the shop would pay [player] for [amount] items of [entry]. Discounts never apply to a payout. */ + fun quoteSell(entry: ShopEntry, amount: Int): ShopQuote? { val payout = entry.sell ?: return null - val total = ShopPricing.round(payout.money * bundles, scale()) - return ShopQuote(bundles, total, total, multiply(payout.items, bundles), 0.0) + val bundles = wholeBundles(entry, payout, amount) ?: return null + val total = ShopPricing.round(payout.money / entry.bundleSize * amount, scale()) + return ShopQuote(amount, total, total, multiply(payout.items, bundles), 0.0) } /** - * The most bundles of [entry] [player] could buy right now. - * - * Bounded by their money, the items the price asks for, the room they have, - * the supply left, their own limit and [MAX_BUNDLES] — whichever runs out - * first. Used by a shift-click, which buys as many as it can rather than - * refusing outright. + * How many items of [entry] [player] could sell right now, rounded down to + * whole purchases. * - * Stock and limits are counted in items, so a bundle that does not fit - * whole into what is left is not offered: half a bundle is not a purchase. - */ - fun maxBuyable( - player: Player, - shop: ShopDefinition, - entry: ShopEntry, - standing: Set = ShopAccess.standing(player), - ): Int { - val cost = entry.buy ?: return 0 - val bundle = entry.bundleSize - var max = MAX_BUNDLES - - entry.stock?.let { max = minOf(max, it.available(System.currentTimeMillis()) / bundle) } - ShopLimits.remaining(player, shop, entry, TradeSide.BUY)?.let { max = minOf(max, it / bundle) } - - for (item in cost.items) { - val held = ShopInventory.count(player, item, entry.matchMode) - max = minOf(max, held / item.amount.coerceAtLeast(1)) - } - - if (cost.money > 0.0) { - if (!EconomyUtil.isAvailable) return 0 - val discount = if (entry.discountable) discountFor(standing) else 0.0 - val unit = ShopPricing.apply(ShopPricing.round(cost.money, scale()), discount, scale()) - max = if (unit <= 0.0) max else minOf(max, floor(EconomyUtil.balance(player) / unit).toInt()) - } - - // Room is the slowest check, so it narrows an already-bounded number rather than searching from the top. - while (max > 0 && !ShopInventory.hasSpaceFor(player, entry.goodsStacks(max))) max-- - - return max.coerceAtLeast(0) - } - - /** - * How many bundles of [entry] [player] could sell right now. - * - * Bounded by what they are carrying, their own selling limit and - * [MAX_BUNDLES], for a shift-click that sells the lot. + * Bounded by what they are carrying and their own selling limit, for a + * shift-click that sells the lot. */ fun maxSellable(player: Player, shop: ShopDefinition, entry: ShopEntry): Int { if (!entry.isSellable) return 0 - val bundle = entry.bundleSize - val held = ShopInventory.count(player, entry.item, entry.matchMode) - var max = minOf(MAX_BUNDLES, held / bundle) + var max = minOf(MAX_ITEMS, ShopInventory.count(player, entry.item, entry.matchMode)) - ShopLimits.remaining(player, shop, entry, TradeSide.SELL)?.let { max = minOf(max, it / bundle) } + ShopLimits.remaining(player, shop, entry, TradeSide.SELL)?.let { max = minOf(max, it) } - return max.coerceAtLeast(0) + val bundle = entry.bundleSize + return (max / bundle * bundle).coerceAtLeast(0) } // ── Buying ────────────────────────────────────────────────────────── - /** Sells [bundles] of [entry] to [player]. */ - fun buy(player: Player, shop: ShopDefinition, entry: ShopEntry, bundles: Int): Result { + /** + * Why [player] could not buy [amount] items of [entry] right now, or `null` + * when they could. + * + * Everything a purchase can refuse over except the money itself, which only + * the withdrawal may decide — asking the balance and then charging it are + * two steps a trade must never take. The amount menu greys an option out + * with what this returns, so what it shows and what a click does cannot + * disagree. + */ + fun buyRefusal( + player: Player, + shop: ShopDefinition, + entry: ShopEntry, + amount: Int, + standing: Set = ShopAccess.standing(player), + ): Result.Failure? { if (!ShopManager.isReady) return Result.Failure("shop.error.unavailable") - if (bundles !in 1..MAX_BUNDLES) return Result.Failure("shop.error.bad-amount") + if (amount !in 1..MAX_ITEMS) return Result.Failure("shop.error.bad-amount") val cost = entry.buy ?: return Result.Failure("shop.error.not-for-sale") - val standing = ShopAccess.standing(player) ShopAccess.refusalKey(player, shop.gate, standing)?.let { return Result.Failure(it) } ShopAccess.refusalKey(player, entry.gate, standing)?.let { return Result.Failure(it) } - val items = entry.bundleSize * bundles + if (wholeBundles(entry, cost, amount) == null) { + return Result.Failure("shop.error.part-bundle", listOf("amount" to entry.bundleSize)) + } ShopLimits.remaining(player, shop, entry, TradeSide.BUY)?.let { left -> - if (left < items) return Result.Failure("shop.error.limit-reached", listOf("amount" to left)) + if (left < amount) return Result.Failure("shop.error.limit-reached", listOf("amount" to left)) } - val stock = entry.stock - val now = System.currentTimeMillis() - if (stock != null && stock.available(now) < items) { - return Result.Failure("shop.error.out-of-stock", listOf("amount" to stock.remaining)) + entry.stock?.let { stock -> + if (stock.available(System.currentTimeMillis()) < amount) { + return Result.Failure("shop.error.out-of-stock", listOf("amount" to stock.remaining)) + } } - val quote = quoteBuy(player, entry, bundles, standing) ?: return Result.Failure("shop.error.not-for-sale") - val goods = entry.goodsStacks(bundles) - if (!ShopInventory.hasSpaceFor(player, goods)) return Result.Failure("shop.error.no-space") - + val quote = quoteBuy(player, entry, amount, standing) ?: return Result.Failure("shop.error.not-for-sale") + if (!ShopInventory.hasSpaceFor(player, entry.goodsStacks(amount))) { + return Result.Failure("shop.error.no-space") + } if (quote.hasMoney && !EconomyUtil.isAvailable) return Result.Failure("shop.error.economy-unavailable") + for (item in quote.items) { + if (ShopInventory.count(player, item, entry.matchMode) < item.amount) { + return Result.Failure("shop.error.missing-items") + } + } + + return null + } + + /** Sells [amount] items of [entry] to [player]. */ + fun buy(player: Player, shop: ShopDefinition, entry: ShopEntry, amount: Int): Result { + val standing = ShopAccess.standing(player) + buyRefusal(player, shop, entry, amount, standing)?.let { return it } + + val quote = quoteBuy(player, entry, amount, standing) ?: return Result.Failure("shop.error.not-for-sale") + val goods = entry.goodsStacks(amount) + val stock = entry.stock + val now = System.currentTimeMillis() + // Taken before any money moves, and handed back below if the money then refuses. val taken = takeItems(player, quote.items, entry.matchMode) ?: return Result.Failure("shop.error.missing-items") - if (stock != null && !stock.take(items, now)) { + if (stock != null && !stock.take(amount, now)) { ShopInventory.give(player, taken) return Result.Failure("shop.error.out-of-stock", listOf("amount" to stock.remaining)) } @@ -171,44 +174,48 @@ object ShopTrade { if (quote.hasMoney) { val reason = TransactionReason.of(TransactionReason.SHOP_BUY, "shop" to shop.displayName) if (!EconomyUtil.withdraw(player, quote.money, EconomyContext.SOURCE_SHOP, reason)) { - stock?.restore(items) + stock?.restore(amount) ShopInventory.give(player, taken) return Result.Failure("shop.error.cannot-afford", listOf("price" to format(quote.money))) } } ShopInventory.give(player, goods) - ShopLimits.record(player, shop, entry, TradeSide.BUY, items, now) - entry.stats.recordBuy(bundles, quote.money) + ShopLimits.record(player, shop, entry, TradeSide.BUY, amount, now) + entry.stats.recordBuy(amount, quote.money) ShopManager.markDirty() - return Result.Success(bundles, quote.money) + return Result.Success(amount, quote.money) } // ── Selling ───────────────────────────────────────────────────────── - /** Buys [bundles] of [entry] back from [player]. */ - fun sell(player: Player, shop: ShopDefinition, entry: ShopEntry, bundles: Int): Result { + /** Buys [amount] items of [entry] back from [player]. */ + fun sell(player: Player, shop: ShopDefinition, entry: ShopEntry, amount: Int): Result { if (!ShopManager.isReady) return Result.Failure("shop.error.unavailable") - if (bundles !in 1..MAX_BUNDLES) return Result.Failure("shop.error.bad-amount") - if (!entry.isSellable) return Result.Failure("shop.error.not-bought") + if (amount !in 1..MAX_ITEMS) return Result.Failure("shop.error.bad-amount") + + val payout = entry.sell ?: return Result.Failure("shop.error.not-bought") val standing = ShopAccess.standing(player) ShopAccess.refusalKey(player, shop.gate, standing)?.let { return Result.Failure(it) } ShopAccess.refusalKey(player, entry.gate, standing)?.let { return Result.Failure(it) } - val items = entry.bundleSize * bundles + if (wholeBundles(entry, payout, amount) == null) { + return Result.Failure("shop.error.part-bundle", listOf("amount" to entry.bundleSize)) + } + val now = System.currentTimeMillis() ShopLimits.remaining(player, shop, entry, TradeSide.SELL, now)?.let { left -> - if (left < items) return Result.Failure("shop.error.sell-limit-reached", listOf("amount" to left)) + if (left < amount) return Result.Failure("shop.error.sell-limit-reached", listOf("amount" to left)) } - val quote = quoteSell(entry, bundles) ?: return Result.Failure("shop.error.not-bought") + val quote = quoteSell(entry, amount) ?: return Result.Failure("shop.error.not-bought") if (!ShopInventory.hasSpaceFor(player, quote.items)) return Result.Failure("shop.error.no-space") if (quote.hasMoney && !EconomyUtil.isAvailable) return Result.Failure("shop.error.economy-unavailable") - val handedOver = ShopInventory.remove(player, entry.item, entry.matchMode, items) + val handedOver = ShopInventory.remove(player, entry.item, entry.matchMode, amount) ?: return Result.Failure("shop.error.missing-goods") if (quote.hasMoney) { @@ -220,15 +227,29 @@ object ShopTrade { } ShopInventory.give(player, quote.items) - ShopLimits.record(player, shop, entry, TradeSide.SELL, items, now) - entry.stats.recordSell(bundles, quote.money) + ShopLimits.record(player, shop, entry, TradeSide.SELL, amount, now) + entry.stats.recordSell(amount, quote.money) ShopManager.markDirty() - return Result.Success(bundles, quote.money) + return Result.Success(amount, quote.money) } // ── Helpers ───────────────────────────────────────────────────────── + /** + * How many bundles [amount] is, or `null` when [cost] cannot be divided + * that finely. + * + * A price or payout made only of money scales to any amount, so the bundle + * count is only ever needed — and only ever has to be whole — when items + * are part of it. + */ + private fun wholeBundles(entry: ShopEntry, cost: ShopCost, amount: Int): Int? { + val bundle = entry.bundleSize + if (cost.items.isNotEmpty() && amount % bundle != 0) return null + return amount / bundle + } + /** * Takes every stack in [required], putting back anything already taken if * one of them turns out to be short. diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopMigrations.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopMigrations.kt index 06c3f42..ac6eabb 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopMigrations.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopMigrations.kt @@ -25,8 +25,8 @@ object ShopMigrations { } /** - * Schema 1 counted stock and limits in purchases; schema 2 counts them in - * items. + * Schema 1 counted stock, limits and sales figures in purchases; schema 2 + * counts all three in items. * * Multiplying by the bundle keeps every shop trading exactly as it did — an * entry selling sixteen at a time with a stock of four still offers @@ -41,6 +41,8 @@ object ShopMigrations { limitAmount = entry.limitAmount * bundle, stockMax = entry.stockMax * bundle, stockRemaining = entry.stockRemaining * bundle, + bought = entry.bought * bundle, + sold = entry.sold * bundle, ) } ) diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopSchema.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopSchema.kt index 01a6d20..d683408 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopSchema.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopSchema.kt @@ -13,8 +13,8 @@ package net.trilleo.mc.plugins.tritown.shops.storage * * 1. The first shape shops were written in. Stock and per-player limits counted * purchases. - * 2. Stock and per-player limits count items, and an entry carries a separate - * limit for selling. + * 2. Stock, per-player limits and the sales figures count items, and an entry + * carries a separate limit for selling. */ object ShopSchema { diff --git a/src/main/resources/lang/en_US.yml b/src/main/resources/lang/en_US.yml index d32f4f4..645836d 100644 --- a/src/main/resources/lang/en_US.yml +++ b/src/main/resources/lang/en_US.yml @@ -453,7 +453,7 @@ gui: limit: "You may still buy {amount} ({period})" sell-limit: "You may still sell {amount} ({period})" click-buy: "Left-click to buy one" - click-buy-max: "Shift-left-click to buy as many as you can" + click-amount: "Shift-left-click to choose an amount" click-sell: "Right-click to sell one" click-sell-max: "Shift-right-click to sell all you carry" locked-permission: "You cannot buy this." @@ -474,6 +474,15 @@ gui: match-exact: "Exactly this item" match-material: "Any item of this kind" + shop-amount: + title: "How many?" + bundle: "Sold {amount} at a time" + option: "{amount}x {item}" + option-locked: "{amount}x {item}" + click-buy: "Click to buy this many" + back: "Back" + back-lore: "Return to the shop, nothing bought." + shop-confirm: title: "Confirm purchase" amount: "You will receive {amount}" @@ -628,6 +637,7 @@ shop: unavailable: "Shops are not available right now." locked: "This shop is not open to you." bad-amount: "That is not an amount this shop can trade." + part-bundle: "This is traded {amount} at a time." not-for-sale: "That is not for sale." not-bought: "This shop does not buy that." cannot-afford: "You cannot afford that. It costs {price}." diff --git a/src/main/resources/lang/zh_CN.yml b/src/main/resources/lang/zh_CN.yml index 470927d..e8d7d57 100644 --- a/src/main/resources/lang/zh_CN.yml +++ b/src/main/resources/lang/zh_CN.yml @@ -454,7 +454,7 @@ gui: limit: "你还能买 {amount} 个 ({period})" sell-limit: "你还能卖 {amount} 个 ({period})" click-buy: "左键 购买一份" - click-buy-max: "Shift + 左键 买下能买的全部" + click-amount: "Shift + 左键 选择数量" click-sell: "右键 出售一份" click-sell-max: "Shift + 右键 卖出身上全部" locked-permission: "你无法购买此商品。" @@ -475,6 +475,15 @@ gui: match-exact: "完全相同的物品" match-material: "同类的任意物品" + shop-amount: + title: "买多少?" + bundle: "每次出售 {amount} 个" + option: "{amount}个 {item}" + option-locked: "{amount}个 {item}" + click-buy: "单击 买下这些" + back: "返回" + back-lore: "返回商店,不购买任何东西。" + shop-confirm: title: "确认购买" amount: "你将获得 {amount}" @@ -629,6 +638,7 @@ shop: unavailable: "商店暂时不可用。" locked: "你无法进入这家商店。" bad-amount: "这家商店无法交易这个数量。" + part-bundle: "此物品每次以 {amount} 个为单位交易。" not-for-sale: "这件商品不出售。" not-bought: "这家商店不回收此物。" cannot-afford: "你的钱不够,需要 {price}。" diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopMigrationsTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopMigrationsTest.kt index fdfcf37..f9d9cbd 100644 --- a/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopMigrationsTest.kt +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopMigrationsTest.kt @@ -24,12 +24,16 @@ class ShopMigrationsTest { limitAmount = 3, stockMax = 8, stockRemaining = 5, + bought = 4L, + sold = 2L, ) ) assertEquals(48, entry.limitAmount) assertEquals(128, entry.stockMax) assertEquals(80, entry.stockRemaining) + assertEquals(64L, entry.bought) + assertEquals(32L, entry.sold) } @Test From 2c035d01959a9e03c197546016fbdff20f3787b9 Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sun, 20 Sep 2026 16:26:00 +0800 Subject: [PATCH 06/10] Feature: Add global shop with /trades command --- CHANGELOG.md | 5 ++ README.md | 5 ++ docs/DEVELOPER_GUIDE.md | 18 +++++++ .../net/trilleo/mc/plugins/tritown/Main.kt | 4 ++ .../tritown/commands/shop/ShopCommand.kt | 5 ++ .../tritown/commands/shop/TradesCommand.kt | 52 +++++++++++++++++++ .../mc/plugins/tritown/config/ShopSettings.kt | 19 +++++++ .../plugins/tritown/guis/shop/ShopListGUI.kt | 6 ++- .../mc/plugins/tritown/shops/ShopManager.kt | 32 +++++++++++- src/main/resources/config.yml | 5 ++ src/main/resources/lang/en_US.yml | 8 +++ src/main/resources/lang/zh_CN.yml | 8 +++ 12 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/shop/TradesCommand.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 76e2bb6..7188641 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ + An entry can now limit how much each player **sells** to the shop per day, per week or ever, alongside the limit on how much they buy. The two are set separately in the entry editor and counted separately, so an entry can be "buy 64 a day, sell 256 a day" without one side spending the other's allowance. ++ Added a global shop, opened from anywhere with `/trades` and needing no NPC — for the goods the server always + trades. It is created empty on first start, appears in `/tritown shop list` and the editor like any other shop, and + is set up the same way; `shops.global-id` chooses which shop it is. It cannot be deleted while it is the one + `/trades` opens, and anyone may run the command, so what each player sees inside it is still the shop's own + permission and Towny requirements. + Shift-left-clicking anything that stacks now opens a menu to pick how many to buy: 1, 8, 16, 32 or 64. They are priced at the entry's own rate, so eight of something sold sixteen at a time costs half of what the shelf quotes, and an amount you cannot take is greyed out with the reason rather than refusing once you have clicked it. This diff --git a/README.md b/README.md index 3fb0788..1c2a216 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ Prebuilt jars are attached to every [GitHub release](https://github.com/Trilleo/ | `/baltop [page]` | List the richest accounts | | `/eco …` | Administer balances (OP only) | | `/tt scoreboard` | Show or hide the sidebar | +| `/trades` | Open the server's global shop | | `/tt shop …` | Set up the server's shops (OP only) | | `/tt admin [section]` | Open the admin panel (OP only) | @@ -147,6 +148,7 @@ version stays available as `/tritown:balance` and so on. | `economy.stats.retention-days` | `30` | How far back those figures reach; `0` keeps them forever | | `shops.enabled` | `true` | Turn shops off entirely | | `shops.save-interval` | `60` | Seconds between writing stock and sales figures; edits are saved immediately | +| `shops.global-id` | `trades` | The shop `/trades` opens; created empty if missing, and not deletable | | `shops.confirm-above` | `1000.0` | Purchase total that asks for confirmation first; `0` never asks | | `shops.sell-rate` | `0.5` | What the editor suggests as a payout, as a fraction of the buy price | | `shops.discounts.` | `0.0` | Money off for `has-town`, `has-nation`, `is-mayor` or `is-king` | @@ -182,6 +184,9 @@ A shop is created with `/tt shop create `, which opens its editor. Everythin - **Pricing it.** An entry has a buy side and a sell side, and each may be switched on or off on its own. Either side can ask for money, for items, or for both at once. Money is typed in chat when you click the price; items are added by clicking them in your inventory, and the stack size is the quantity. +- **Reaching it.** A shop normally stands behind an NPC. One does not: the shop named by `shops.global-id` + (`trades` by default) opens from anywhere with `/trades`, for the goods the server always trades. It is created + empty on first start, is edited like any other shop, and cannot be deleted while it is the one `/trades` opens. - **Buying it.** A player left-clicks an entry to buy one purchase of it, and shift-left-clicks anything that stacks to pick an amount instead — 1, 8, 16, 32 or 64, priced at the entry's own rate, so eight of something sold sixteen at a time costs half. An amount they cannot take is greyed out with the reason rather than refusing after the click. Right diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 87754d9..aed01e6 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -2585,6 +2585,24 @@ exactly as it went in, and Paper upgrades the embedded game version when Minecra `ItemCodec.decode` returns `null` rather than throwing. One unreadable entry must not take a whole shop with it. +### The global shop + +Every shop stands behind an NPC somebody has to walk to, which is the point of a shop — except one. `ShopManager.global()` +returns the shop named by `shops.global-id` (default `trades`), **creating it empty when it is not there**, and +`/trades` opens it from anywhere. It is an ordinary `ShopDefinition` otherwise: listed, edited, gated, sorted and +bound to NPCs like the rest, which is why nothing in the editor needed a special case for it. + +Two things follow from its being created rather than configured: + +- **It cannot be deleted while it is named.** `ShopManager.delete` refuses it and `ShopCommand` says why. Deleting it + would only lose its entries and then bring the shop back empty at the next start, so emptying it in the editor is + the honest way to do that. +- **`global()` runs at enable and again on reload**, so it is in the editor's list before anybody asks for it, and a + changed `shops.global-id` takes effect without a restart. + +`TradesCommand` declares no permission, the way `ScoreboardCommand` does — the shop's own `ShopGate` is what decides +who may see and buy what inside it. + ### Storage `plugins/TriTown/shops/shops.json`, written by `JsonShopStorage` through a temporary file with the previous copy kept diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt index 6e11c10..c6d15b2 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt @@ -99,6 +99,8 @@ class Main : JavaPlugin() { ShopSettings.load(pluginConfig) if (ShopSettings.snapshot.enabled) { ShopManager.start(JsonShopStorage(dataFolder, logger), logger) + // Made now rather than on the first /trades, so it is in the editor's list from the start. + ShopManager.global() } ItemRegistrar.registerAll(this) @@ -139,6 +141,8 @@ class Main : JavaPlugin() { // Only the settings: re-reading the shop file would throw away an edit // that has not been flushed, and nothing in that file comes from config.yml. ShopSettings.load(pluginConfig) + // shops.global-id may have moved, and the shop it now names may not exist yet. + ShopManager.global() } override fun onDisable() { diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/shop/ShopCommand.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/shop/ShopCommand.kt index fdd71e8..ac69685 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/shop/ShopCommand.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/shop/ShopCommand.kt @@ -126,6 +126,11 @@ class ShopCommand : PluginCommand( return } + if (ShopManager.isGlobal(shop.id)) { + sender.sendPrefixed(sender.tr("command.shop.delete-global", "id" to shop.id)) + return + } + Bukkit.getOnlinePlayers().forEach { ShopLimits.forget(it, shop) } ShopManager.delete(shop.id) sender.sendPrefixed(sender.tr("command.shop.deleted", "id" to shop.id)) diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/shop/TradesCommand.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/shop/TradesCommand.kt new file mode 100644 index 0000000..b9f73f2 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/shop/TradesCommand.kt @@ -0,0 +1,52 @@ +package net.trilleo.mc.plugins.tritown.commands.shop + +import net.trilleo.mc.plugins.tritown.config.ShopSettings +import net.trilleo.mc.plugins.tritown.guis.shop.ShopGUI +import net.trilleo.mc.plugins.tritown.registration.PluginCommand +import net.trilleo.mc.plugins.tritown.shops.ShopManager +import net.trilleo.mc.plugins.tritown.utils.sendPrefixed +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.command.CommandSender +import org.bukkit.entity.Player + +/** + * The server's own shop, open from anywhere. + * + * Every other shop stands behind an NPC somebody has to walk to, which is the + * point of them — a shop is a place. This one is the exception: the goods the + * server always trades, reachable wherever a player is standing. + * + * It declares no permission on purpose, the way `/tritown scoreboard` does. + * What a player may see and buy in it is the shop's own gate's business, which + * an administrator sets in the shop's settings like any other. + */ +class TradesCommand : PluginCommand( + name = "trades", + description = "Open the server's global shop", + usage = "/trades", + isMainCommand = true, +) { + + override fun execute(sender: CommandSender, args: Array): Boolean { + val player = sender as? Player ?: run { + sender.sendPrefixed(sender.tr("command.trades.players-only")) + return true + } + + if (!ShopSettings.isLoaded || !ShopSettings.snapshot.enabled) { + player.sendPrefixed(player.tr("command.trades.disabled")) + return true + } + + val shop = ShopManager.global() + if (shop == null) { + player.sendPrefixed(player.tr("command.trades.unavailable")) + return true + } + + ShopGUI.show(player, shop) + return true + } + + override fun tabComplete(sender: CommandSender, args: Array): List = emptyList() +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/ShopSettings.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/ShopSettings.kt index d0c56f3..c94cc4c 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/ShopSettings.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/ShopSettings.kt @@ -1,6 +1,7 @@ package net.trilleo.mc.plugins.tritown.config import net.trilleo.mc.plugins.tritown.enums.TownyRequirement +import net.trilleo.mc.plugins.tritown.shops.ShopDefinition /** * An immutable snapshot of the `shops` block of `config.yml`. @@ -11,6 +12,7 @@ import net.trilleo.mc.plugins.tritown.enums.TownyRequirement * @param saveIntervalSeconds how often stock and statistics are written out; definitions save immediately * @param confirmAbove the currency total that makes a purchase ask for confirmation first; 0 never asks * @param sellRate what an entry pays back, as a fraction of its buy price, when no sell price was set + * @param globalId the id of the shop `/trades` opens, which is created empty when it does not exist * @param discounts how much each standing in Towny takes off a price, as a fraction between 0 and 1 */ data class ShopSettings( @@ -18,11 +20,15 @@ data class ShopSettings( val saveIntervalSeconds: Long, val confirmAbove: Double, val sellRate: Double, + val globalId: String, val discounts: Map, ) { companion object { + /** What the global shop is called when `config.yml` does not say. */ + const val DEFAULT_GLOBAL_ID = "trades" + /** The standings a discount can be attached to, by their key in `config.yml`. */ private val DISCOUNT_KEYS = mapOf( "has-town" to TownyRequirement.HAS_TOWN, @@ -45,6 +51,18 @@ data class ShopSettings( /** Reads the `shops` block from [config] and makes it the current snapshot. */ fun load(config: PluginConfig): ShopSettings = read(config).also { current = it } + /** + * The id `/trades` opens. + * + * An id the shop registry would refuse is no use — the shop behind it + * could never be created — so a malformed one falls back to the default + * rather than leaving the command pointing at nothing. + */ + private fun globalId(config: PluginConfig): String { + val id = config.getString("shops.global-id", DEFAULT_GLOBAL_ID).lowercase() + return if (ShopDefinition.isValidId(id)) id else DEFAULT_GLOBAL_ID + } + private fun read(config: PluginConfig): ShopSettings { val discounts = DISCOUNT_KEYS.mapNotNull { (key, requirement) -> val rate = config.getDouble("shops.discounts.$key", 0.0).coerceIn(0.0, 1.0) @@ -56,6 +74,7 @@ data class ShopSettings( saveIntervalSeconds = config.getLong("shops.save-interval", 60L).coerceIn(5L, 3600L), confirmAbove = config.getDouble("shops.confirm-above", 0.0).coerceAtLeast(0.0), sellRate = config.getDouble("shops.sell-rate", 0.5).coerceIn(0.0, 1.0), + globalId = globalId(config), discounts = discounts, ) } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopListGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopListGUI.kt index 0df430b..7397550 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopListGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopListGUI.kt @@ -56,15 +56,19 @@ class ShopListGUI : PagedPluginGUI( } private fun icon(player: Player, shop: ShopDefinition): ItemStack { + val isGlobal = ShopManager.isGlobal(shop.id) + val lore = buildList { add(player.tr("gui.shop-list.id", "id" to shop.id)) add(player.tr("gui.shop-list.entries", "amount" to shop.entries.size)) add(player.tr("gui.shop-list.npcs", "amount" to shop.npcIds.size)) + if (isGlobal) add(player.tr("gui.shop-list.global")) add(player.tr("gui.shop-list.click-edit")) add(player.tr("gui.shop-list.click-preview")) } - return itemStack(Material.CHEST) { + // The one shop players reach without walking to it, so it is worth telling apart at a glance. + return itemStack(if (isGlobal) Material.ENDER_CHEST else Material.CHEST) { name(shop.displayName) meta { lore(LoreUtil.wrapLore(lore.joinToString(""))) } } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopManager.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopManager.kt index ccc89bf..e03f890 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopManager.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopManager.kt @@ -1,5 +1,6 @@ package net.trilleo.mc.plugins.tritown.shops +import net.trilleo.mc.plugins.tritown.config.ShopSettings import net.trilleo.mc.plugins.tritown.enums.LimitPeriod import net.trilleo.mc.plugins.tritown.enums.MatchMode import net.trilleo.mc.plugins.tritown.enums.TownyRequirement @@ -76,6 +77,26 @@ object ShopManager { /** The shop with [id], or `null` when there is none. */ fun get(id: String): ShopDefinition? = shops[id.lowercase()] + /** + * The shop `/trades` opens, creating it empty when it is not there. + * + * Created rather than reported missing, so a fresh server has somewhere to + * put its first entry and `/trades` never answers with an apology. It is an + * ordinary shop in every other way: it is listed, edited, gated and sorted + * like the rest, and an owner who wants a different one only has to point + * `shops.global-id` somewhere else. + */ + fun global(): ShopDefinition? { + if (!isReady || !ShopSettings.isLoaded) return null + + val id = ShopSettings.snapshot.globalId + return get(id) ?: create(id, id) + } + + /** Whether [id] is the shop `/trades` opens, which is the one shop that may not be deleted. */ + fun isGlobal(id: String): Boolean = + ShopSettings.isLoaded && id.equals(ShopSettings.snapshot.globalId, ignoreCase = true) + /** Every shop, ordered by id so a listing does not shuffle between restarts. */ fun all(): List = shops.values.sortedBy { it.id } @@ -102,8 +123,17 @@ object ShopManager { return shop } - /** Removes the shop with [id]. Returns `false` when there was none. */ + /** + * Removes the shop with [id]. Returns `false` when there was none. + * + * The global shop is not one of them: it would be recreated empty on the + * next start anyway, so deleting it only ever means losing its entries + * without losing the shop. Emptying it in the editor is the honest way to + * do that. + */ fun delete(id: String): Boolean { + if (isGlobal(id)) return false + val removed = shops.remove(id.lowercase()) != null if (removed) save() return removed diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 4f54eba..72a60e9 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -129,6 +129,11 @@ shops: # price, when sell-back is switched on for it. Only a starting point — the # price can be changed to anything afterwards. sell-rate: 0.5 + # The shop /trades opens. It is created empty if it does not exist, is edited + # like any other shop, and cannot be deleted while it is named here. Point + # this at a different id to make that shop the one players reach from + # anywhere. Lowercase letters, digits, dashes and underscores only. + global-id: trades # Money taken off a purchase for players who have earned it, as a fraction: # 0.1 is ten percent off. Discounts do not stack, so a player who matches more diff --git a/src/main/resources/lang/en_US.yml b/src/main/resources/lang/en_US.yml index 645836d..1c510b8 100644 --- a/src/main/resources/lang/en_US.yml +++ b/src/main/resources/lang/en_US.yml @@ -111,6 +111,12 @@ command: unpinned: "Sidebar back to matching where you stand." unknown-board: "No board named {board}." + trades: + description: "Open the server's global shop" + players-only: "A shop is a menu, so only players can open it." + disabled: "Shops are switched off on this server." + unavailable: "The global shop is not available right now." + shop: description: "Administer the server's shops" disabled: "Shops are switched off on this server." @@ -121,6 +127,7 @@ command: created: "Created the shop {id}." create-failed: "{id} is taken or is not a usable id. Use lowercase letters, digits, dashes and underscores." delete-confirm: "Deleting a shop cannot be undone. Run /tritown shop delete {id} confirm to go ahead." + delete-global: "{id} is the shop /trades opens and cannot be deleted. Empty it in the editor instead, or point shops.global-id elsewhere." deleted: "Deleted the shop {id}." opened: "Opened {id} for {player}." bound: "{npc} now opens the shop {id}." @@ -499,6 +506,7 @@ gui: id: "Id: {id}" entries: "Entries: {amount}" npcs: "NPCs: {amount}" + global: "Opened with /trades" click-edit: "Click to edit" click-preview: "Shift-click to see it as a player does" diff --git a/src/main/resources/lang/zh_CN.yml b/src/main/resources/lang/zh_CN.yml index e8d7d57..2578415 100644 --- a/src/main/resources/lang/zh_CN.yml +++ b/src/main/resources/lang/zh_CN.yml @@ -111,6 +111,12 @@ command: unpinned: "侧边栏已恢复为根据所在位置自动切换。" unknown-board: "不存在名为 {board} 的板面。" + trades: + description: "打开服务器的全局商店" + players-only: "商店是菜单,只有玩家能打开。" + disabled: "本服务器已关闭商店功能。" + unavailable: "全局商店现在不可用。" + shop: description: "管理服务器商店" disabled: "本服务器已关闭商店功能。" @@ -121,6 +127,7 @@ command: created: "已创建商店 {id}。" create-failed: "{id} 已被占用或不是有效的 id。请使用小写字母、数字、短横线和下划线。" delete-confirm: "删除商店无法撤销。请执行 /tritown shop delete {id} confirm 继续。" + delete-global: "{id} 是 /trades 打开的商店,无法删除。请在编辑器中清空它,或将 shops.global-id 指向其他商店。" deleted: "已删除商店 {id}。" opened: "已为 {player} 打开商店 {id}。" bound: "{npc} 现在会打开商店 {id}。" @@ -500,6 +507,7 @@ gui: id: "Id:{id}" entries: "商品数:{amount}" npcs: "NPC 数:{amount}" + global: "使用 /trades 打开" click-edit: "单击 编辑" click-preview: "Shift + 单击 以玩家视角查看" From 330c6d627c7a78e203ab508a8b8485f57d8aee50 Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sun, 20 Sep 2026 16:42:47 +0800 Subject: [PATCH 07/10] Internal: Move the inventory and lore helpers into utils Giving a player items, asking whether they would fit, and adding lines to an item's lore are not shop-specific. They move to InventoryUtil and LoreUtil.withLore so anything else that hands over items or draws a menu decides both the same way shops already do. ShopInventory keeps what is genuinely its own: finding and taking goods by MatchMode. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 6 ++ docs/UTILITY_GUIDE.md | 62 +++++++++++++++ .../tritown/guis/admin/AdminShopsGUI.kt | 3 +- .../tritown/guis/shop/ShopAmountGUI.kt | 2 +- .../tritown/guis/shop/ShopConfirmGUI.kt | 2 +- .../plugins/tritown/guis/shop/ShopCostGUI.kt | 2 +- .../tritown/guis/shop/ShopEditorGUI.kt | 4 +- .../plugins/tritown/guis/shop/ShopEntryGUI.kt | 2 +- .../mc/plugins/tritown/guis/shop/ShopGUI.kt | 2 +- .../plugins/tritown/guis/shop/ShopRender.kt | 21 +---- .../plugins/tritown/guis/shop/ShopStatsGUI.kt | 2 +- .../mc/plugins/tritown/shops/ShopInventory.kt | 63 ++------------- .../mc/plugins/tritown/shops/ShopTrade.kt | 17 ++-- .../mc/plugins/tritown/utils/InventoryUtil.kt | 78 +++++++++++++++++++ .../mc/plugins/tritown/utils/LoreUtil.kt | 24 ++++++ 15 files changed, 195 insertions(+), 95 deletions(-) create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/InventoryUtil.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 7188641..15ed4b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,12 @@ + An older shop file is now brought forward by `ShopMigrations` as it is read rather than being misread against the current shape. Schema 2 is stock, limits and sales figures in items, plus the selling limit. +#### Misc + ++ Handing a player items, asking whether they would fit, and adding lines to an item's lore are now shared utilities + — `InventoryUtil` and `LoreUtil.withLore` — rather than living inside the shop package, so anything else that moves + items or draws a menu decides both the same way shops do. + ## Version 1.1.0 ### New Features diff --git a/docs/UTILITY_GUIDE.md b/docs/UTILITY_GUIDE.md index bca139b..a82c647 100644 --- a/docs/UTILITY_GUIDE.md +++ b/docs/UTILITY_GUIDE.md @@ -16,6 +16,7 @@ reduce boilerplate and provide commonly needed functionality out of the box. | `PDCUtil` | Persistent data container helpers for Entity, Chunk, and ItemStack | | `GameRuleUtil` | Convenient get, set, and toggle helpers for Minecraft game rules | | `LoreUtil` | Word-aware text wrapping for item lore with style carry-over | +| `InventoryUtil` | Gives items to a player, and asks first whether they would fit | | `TownyUtil` | Reads and formats Towny data: names, balances, upkeep, the new day | | `ComponentUtil` | Parses a MiniMessage string into a Component, and escapes input | @@ -844,6 +845,21 @@ val item = itemStack(Material.DIAMOND_SWORD) { } ``` +### Adding Lore to an Existing Item + +`withLore` returns a **copy** of an item with the given lines wrapped and appended under whatever lore it already +carries, separated by a blank line. The original item is never modified, so an item held in a definition can be drawn +in a menu without the menu's labels leaking back into it. + +```kotlin +import net.trilleo.mc.plugins.tritown.utils.LoreUtil + +val icon = LoreUtil.withLore( + entry.displayStack(), + listOf(player.tr("gui.shop.buy", "price" to price), player.tr("gui.shop.click-buy")), +) +``` + ### Parameters | Parameter | Type | Default | Description | @@ -851,6 +867,13 @@ val item = itemStack(Material.DIAMOND_SWORD) { | `text` | `String` | — | MiniMessage-formatted input string | | `maxWidth` | `Int` | `40` | Maximum visible characters per line | +### Methods + +| Method | Return | Description | +|:-------------------------|:------------------|:---------------------------------------------------------------------| +| `wrapLore(text, width)` | `List` | Wraps one MiniMessage string into lore-ready lines | +| `withLore(item, lines)` | `ItemStack` | A copy of the item with the wrapped lines added under its own lore | + ### Behavior Details - **Word-aware**: Lines break at word boundaries (spaces). A word that exceeds `maxWidth` alone is force-broken @@ -863,6 +886,45 @@ val item = itemStack(Material.DIAMOND_SWORD) { --- +## InventoryUtil + +`InventoryUtil` puts stacks into a player's own inventory and answers, first, whether they would fit. Anything that +hands a player items — a shop purchase, a trade, a reward — goes through it so that "will this fit?" is decided the +same way everywhere, by the code that will do the real insertion. + +Every method runs on the server thread, because an inventory may not be touched from anywhere else. Only the 36 +storage slots are considered; armour and the off-hand are never written to. + +### Usage + +```kotlin +import net.trilleo.mc.plugins.tritown.utils.InventoryUtil + +if (!InventoryUtil.hasSpaceFor(player, goods)) { + player.sendPrefixed(player.tr("common.error", "message" to player.tr("shop.error.no-space"))) + return +} + +InventoryUtil.give(player, goods) +``` + +### Methods + +| Method | Return | Description | +|:---------------------------|:------------------|:-------------------------------------------------------------------------------| +| `hasSpaceFor(player, items)` | `Boolean` | Whether every stack would fit, tested against a copy of the player's storage | +| `give(player, items)` | `Unit` | Adds the stacks, dropping at the player's feet whatever will not fit | +| `split(items)` | `List` | Breaks oversized stacks down into ones the game allows | + +### Behavior Details + +- **Space is tested, not counted**: `hasSpaceFor` copies the player's storage into a scratch inventory and tries the + real insertion, so partial stacks, per-item stack limits and items that do not stack are all accounted for. +- **Oversized stacks**: a quantity larger than one stack holds is accepted in memory but cannot be stored, so both + `hasSpaceFor` and `give` run their input through `split` first. +- **Nothing is ever lost**: `give` drops what will not fit rather than discarding it. Check `hasSpaceFor` beforehand + and a drop only happens when something else filled the inventory in between. + --- ## TownyUtil diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/AdminShopsGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/AdminShopsGUI.kt index ce7d7f6..242263d 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/AdminShopsGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/AdminShopsGUI.kt @@ -11,6 +11,7 @@ import net.trilleo.mc.plugins.tritown.registration.PagedPluginGUI import net.trilleo.mc.plugins.tritown.shops.ShopDefinition import net.trilleo.mc.plugins.tritown.shops.ShopManager import net.trilleo.mc.plugins.tritown.utils.ComponentUtil +import net.trilleo.mc.plugins.tritown.utils.LoreUtil import net.trilleo.mc.plugins.tritown.utils.tr import org.bukkit.Material import org.bukkit.entity.Player @@ -141,7 +142,7 @@ class AdminShopsGUI : PagedPluginGUI( meta.lore(null) } - return ShopRender.withLore(icon, lines) + return LoreUtil.withLore(icon, lines) } companion object { diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopAmountGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopAmountGUI.kt index 7ccaa9e..220889c 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopAmountGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopAmountGUI.kt @@ -139,7 +139,7 @@ class ShopAmountGUI : PluginGUI( val perPurchase = if (bundle > 1) listOf(player.tr("gui.shop-amount.bundle", "amount" to bundle)) else emptyList() - return ShopRender.withLore( + return LoreUtil.withLore( entry.displayStack(), ShopRender.sections(listOf(ShopRender.buyLines(player, entry, bundle, standing), perPurchase)), ) diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopConfirmGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopConfirmGUI.kt index 1deefbf..aa05ca9 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopConfirmGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopConfirmGUI.kt @@ -57,7 +57,7 @@ class ShopConfirmGUI : PluginGUI( quote?.items?.forEach { add(ShopRender.itemLine(player, it, key = "gui.shop.buy-item")) } } - inventory.setItem(SLOT_GOODS, ShopRender.withLore(entry.displayStack(), lines)) + inventory.setItem(SLOT_GOODS, LoreUtil.withLore(entry.displayStack(), lines)) inventory.setItem( SLOT_ACCEPT, button(player, Material.LIME_CONCRETE, "gui.shop-confirm.accept", "gui.shop-confirm.accept-lore"), diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopCostGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopCostGUI.kt index 78562e5..3f0d8c5 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopCostGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopCostGUI.kt @@ -130,7 +130,7 @@ class ShopCostGUI : PluginGUI( (if (buying) entry.buy else entry.sell) ?: ShopCost.FREE private fun describe(player: Player, item: ItemStack): ItemStack = - ShopRender.withLore(item, listOf(player.tr("gui.shop-cost.click-remove"))) + LoreUtil.withLore(item, listOf(player.tr("gui.shop-cost.click-remove"))) private fun button(player: Player, material: Material, nameKey: String, loreKey: String): ItemStack = itemStack(material) { diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEditorGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEditorGUI.kt index 2c5c999..4741ba2 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEditorGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEditorGUI.kt @@ -281,7 +281,7 @@ class ShopEditorGUI : PagedPluginGUI( } } - val icon = ShopRender.withLore(entry.displayStack(), lore) + val icon = LoreUtil.withLore(entry.displayStack(), lore) return if (heldId == entry.id) ShopRender.glowing(icon) else icon } @@ -293,7 +293,7 @@ class ShopEditorGUI : PagedPluginGUI( /** The entry being moved, in the navigation row so it is on screen whatever page is open. */ private fun heldButton(player: Player, entry: ShopEntry): ItemStack = ShopRender.glowing( - ShopRender.withLore( + LoreUtil.withLore( entry.displayStack(), listOf(player.tr("gui.shop-editor.holding"), player.tr("gui.shop-editor.click-put-down")), ) diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEntryGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEntryGUI.kt index 67d9d1a..7e6a572 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEntryGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEntryGUI.kt @@ -271,7 +271,7 @@ class ShopEntryGUI : PluginGUI( // ── Drawing ───────────────────────────────────────────────────────── - private fun goods(player: Player, entry: ShopEntry): ItemStack = ShopRender.withLore( + private fun goods(player: Player, entry: ShopEntry): ItemStack = LoreUtil.withLore( entry.displayStack(), listOf(player.tr("gui.shop-entry.bundle", "amount" to entry.bundleSize)), ) diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt index 78aa6b6..f1c9c35 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt @@ -195,7 +195,7 @@ class ShopGUI : PagedPluginGUI( ) ) - return ShopRender.withLore(entry.displayStack(), lore) + return LoreUtil.withLore(entry.displayStack(), lore) } private fun availabilityLines(viewer: Player, shop: ShopDefinition, entry: ShopEntry): List { diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt index 2f537f0..a382b7c 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt @@ -204,30 +204,11 @@ object ShopRender { * italics and reads as a rename rather than a label. */ fun named(item: ItemStack, name: String, lines: List): ItemStack { - val copy = withLore(item, lines) + val copy = LoreUtil.withLore(item, lines) val meta = copy.itemMeta ?: return copy meta.displayName(ComponentUtil.parse("$name")) copy.itemMeta = meta return copy } - - /** - * A copy of [item] with [lines] added under whatever lore it already has. - * - * The goods keep their own description, because an item that says what it - * does should still say it on the shelf. - */ - fun withLore(item: ItemStack, lines: List): ItemStack { - if (lines.isEmpty()) return item.clone() - - val copy = item.clone() - val meta = copy.itemMeta ?: return copy - val existing = meta.lore().orEmpty() - val added = LoreUtil.wrapLore(lines.joinToString("")) - - meta.lore(if (existing.isEmpty()) added else existing + Component.empty() + added) - copy.itemMeta = meta - return copy - } } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopStatsGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopStatsGUI.kt index 016b38e..ffb70cd 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopStatsGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopStatsGUI.kt @@ -97,7 +97,7 @@ class ShopStatsGUI : PagedPluginGUI( player.tr("gui.shop-stats.money-out", "amount" to ShopRender.money(entry.stats.moneyOut)), ) - return ShopRender.withLore(entry.displayStack(), lore) + return LoreUtil.withLore(entry.displayStack(), lore) } private fun shopOf(player: Player): ShopDefinition? = viewing[player.uniqueId]?.let(ShopManager::get) diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopInventory.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopInventory.kt index cf7722a..127148a 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopInventory.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopInventory.kt @@ -1,44 +1,29 @@ package net.trilleo.mc.plugins.tritown.shops import net.trilleo.mc.plugins.tritown.enums.MatchMode -import org.bukkit.Bukkit import org.bukkit.entity.Player import org.bukkit.inventory.ItemStack /** - * The inventory side of a trade. + * Finding and taking the goods a shop trade asks for. * * Every method here runs on the server thread, because inventories may not be * touched from anywhere else. Removals hand back exactly what they took so a * trade that fails later can put it right back — a purchase must never be able * to leave a player short of both the goods and the price. + * + * Only the [MatchMode] side of an inventory lives here; giving items back and + * asking whether they would fit is the same for everything that moves items, so + * that lives in [net.trilleo.mc.plugins.tritown.utils.InventoryUtil]. */ object ShopInventory { - /** A player's storage, without armour or the off-hand, which a trade never touches. */ - private const val STORAGE_SLOTS = 36 - /** How many of [template] the player holds, counting by [mode]. */ fun count(player: Player, template: ItemStack, mode: MatchMode): Int = player.inventory.storageContents.sumOf { stack -> if (stack != null && matches(template, stack, mode)) stack.amount else 0 } - /** - * Whether [items] would all fit in the player's storage. - * - * Tested against a copy rather than by counting empty slots, so partial - * stacks, stack limits and items that do not stack are all accounted for by - * the same code that will do the real insertion. - */ - fun hasSpaceFor(player: Player, items: List): Boolean { - if (items.isEmpty()) return true - - val scratch = Bukkit.createInventory(null, STORAGE_SLOTS) - scratch.storageContents = player.inventory.storageContents.map { it?.clone() }.toTypedArray() - return scratch.addItem(*split(items).toTypedArray()).isEmpty() - } - /** * Takes [amount] items matching [template] out of the player's storage. * @@ -73,44 +58,6 @@ object ShopInventory { return removed } - /** - * Puts [items] into the player's storage, dropping at their feet whatever - * will not fit. - * - * Space is checked before a trade starts, so a drop only happens when - * something else filled the inventory in between. Dropping is still the - * right answer there: the player has already paid. - */ - fun give(player: Player, items: List) { - if (items.isEmpty()) return - - val leftover = player.inventory.addItem(*split(items).toTypedArray()) - for (stack in leftover.values) { - player.world.dropItemNaturally(player.location, stack) - } - } - - /** - * [items] broken down into stacks the game allows. - * - * A price multiplied by a shift-click can ask for more than one stack holds, - * and an oversized stack is accepted in memory but cannot be stored, so it - * is split before anything is measured against an inventory. - */ - fun split(items: List): List = items.flatMap { item -> - val perStack = item.maxStackSize.coerceAtLeast(1) - if (item.amount <= perStack) return@flatMap listOf(item.clone()) - - buildList { - var outstanding = item.amount - while (outstanding > 0) { - val size = minOf(outstanding, perStack) - add(item.clone().apply { amount = size }) - outstanding -= size - } - } - } - private fun matches(template: ItemStack, stack: ItemStack, mode: MatchMode): Boolean = when (mode) { MatchMode.EXACT -> template.isSimilar(stack) MatchMode.MATERIAL -> template.type == stack.type diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopTrade.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopTrade.kt index 7df23e9..fff6064 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopTrade.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopTrade.kt @@ -8,6 +8,7 @@ import net.trilleo.mc.plugins.tritown.enums.MatchMode import net.trilleo.mc.plugins.tritown.enums.TownyRequirement import net.trilleo.mc.plugins.tritown.enums.TradeSide import net.trilleo.mc.plugins.tritown.utils.EconomyUtil +import net.trilleo.mc.plugins.tritown.utils.InventoryUtil import org.bukkit.entity.Player import org.bukkit.inventory.ItemStack @@ -138,7 +139,7 @@ object ShopTrade { } val quote = quoteBuy(player, entry, amount, standing) ?: return Result.Failure("shop.error.not-for-sale") - if (!ShopInventory.hasSpaceFor(player, entry.goodsStacks(amount))) { + if (!InventoryUtil.hasSpaceFor(player, entry.goodsStacks(amount))) { return Result.Failure("shop.error.no-space") } if (quote.hasMoney && !EconomyUtil.isAvailable) return Result.Failure("shop.error.economy-unavailable") @@ -167,7 +168,7 @@ object ShopTrade { ?: return Result.Failure("shop.error.missing-items") if (stock != null && !stock.take(amount, now)) { - ShopInventory.give(player, taken) + InventoryUtil.give(player, taken) return Result.Failure("shop.error.out-of-stock", listOf("amount" to stock.remaining)) } @@ -175,12 +176,12 @@ object ShopTrade { val reason = TransactionReason.of(TransactionReason.SHOP_BUY, "shop" to shop.displayName) if (!EconomyUtil.withdraw(player, quote.money, EconomyContext.SOURCE_SHOP, reason)) { stock?.restore(amount) - ShopInventory.give(player, taken) + InventoryUtil.give(player, taken) return Result.Failure("shop.error.cannot-afford", listOf("price" to format(quote.money))) } } - ShopInventory.give(player, goods) + InventoryUtil.give(player, goods) ShopLimits.record(player, shop, entry, TradeSide.BUY, amount, now) entry.stats.recordBuy(amount, quote.money) ShopManager.markDirty() @@ -212,7 +213,7 @@ object ShopTrade { } val quote = quoteSell(entry, amount) ?: return Result.Failure("shop.error.not-bought") - if (!ShopInventory.hasSpaceFor(player, quote.items)) return Result.Failure("shop.error.no-space") + if (!InventoryUtil.hasSpaceFor(player, quote.items)) return Result.Failure("shop.error.no-space") if (quote.hasMoney && !EconomyUtil.isAvailable) return Result.Failure("shop.error.economy-unavailable") val handedOver = ShopInventory.remove(player, entry.item, entry.matchMode, amount) @@ -221,12 +222,12 @@ object ShopTrade { if (quote.hasMoney) { val reason = TransactionReason.of(TransactionReason.SHOP_SELL, "shop" to shop.displayName) if (!EconomyUtil.deposit(player, quote.money, EconomyContext.SOURCE_SHOP, reason)) { - ShopInventory.give(player, handedOver) + InventoryUtil.give(player, handedOver) return Result.Failure("shop.error.payout-refused") } } - ShopInventory.give(player, quote.items) + InventoryUtil.give(player, quote.items) ShopLimits.record(player, shop, entry, TradeSide.SELL, amount, now) entry.stats.recordSell(amount, quote.money) ShopManager.markDirty() @@ -259,7 +260,7 @@ object ShopTrade { for (item in required) { val removed = ShopInventory.remove(player, item, mode, item.amount) if (removed == null) { - ShopInventory.give(player, taken) + InventoryUtil.give(player, taken) return null } taken += removed diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/InventoryUtil.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/InventoryUtil.kt new file mode 100644 index 0000000..6162f20 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/InventoryUtil.kt @@ -0,0 +1,78 @@ +package net.trilleo.mc.plugins.tritown.utils + +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import org.bukkit.inventory.ItemStack + +/** + * Putting stacks into a player's own inventory, and asking first whether they + * will fit. + * + * Everything here runs on the server thread, because an inventory may not be + * touched from anywhere else. + * + * ### Usage + * + * ```kotlin + * if (!InventoryUtil.hasSpaceFor(player, goods)) return + * InventoryUtil.give(player, goods) + * ``` + */ +object InventoryUtil { + + /** A player's storage, without armour or the off-hand, which none of this touches. */ + private const val STORAGE_SLOTS = 36 + + /** + * Whether [items] would all fit in the player's storage. + * + * Tested against a copy rather than by counting empty slots, so partial + * stacks, stack limits and items that do not stack are all accounted for by + * the same code that will do the real insertion. + */ + fun hasSpaceFor(player: Player, items: List): Boolean { + if (items.isEmpty()) return true + + val scratch = Bukkit.createInventory(null, STORAGE_SLOTS) + scratch.storageContents = player.inventory.storageContents.map { it?.clone() }.toTypedArray() + return scratch.addItem(*split(items).toTypedArray()).isEmpty() + } + + /** + * Puts [items] into the player's storage, dropping at their feet whatever + * will not fit. + * + * Space is checked before anything is handed over, so a drop only happens + * when something else filled the inventory in between. Dropping is still + * the right answer there: the player has already paid. + */ + fun give(player: Player, items: List) { + if (items.isEmpty()) return + + val leftover = player.inventory.addItem(*split(items).toTypedArray()) + for (stack in leftover.values) { + player.world.dropItemNaturally(player.location, stack) + } + } + + /** + * [items] broken down into stacks the game allows. + * + * A price multiplied by a shift-click can ask for more than one stack holds, + * and an oversized stack is accepted in memory but cannot be stored, so it + * is split before anything is measured against an inventory. + */ + fun split(items: List): List = items.flatMap { item -> + val perStack = item.maxStackSize.coerceAtLeast(1) + if (item.amount <= perStack) return@flatMap listOf(item.clone()) + + buildList { + var outstanding = item.amount + while (outstanding > 0) { + val size = minOf(outstanding, perStack) + add(item.clone().apply { amount = size }) + outstanding -= size + } + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/LoreUtil.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/LoreUtil.kt index 55fa988..caaee62 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/LoreUtil.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/LoreUtil.kt @@ -5,6 +5,7 @@ import net.kyori.adventure.text.TextComponent import net.kyori.adventure.text.format.Style import net.kyori.adventure.text.format.TextDecoration import net.kyori.adventure.text.minimessage.MiniMessage +import org.bukkit.inventory.ItemStack /** * Utility for wrapping MiniMessage-formatted text into multiple lore-ready @@ -79,6 +80,29 @@ object LoreUtil { return result } + /** + * A copy of [item] with [lines] wrapped and added under whatever lore it + * already has. + * + * The item keeps its own description, because something that says what it + * does should still say it wherever a menu shows it. + * + * @param item the item to copy; it is never modified + * @param lines MiniMessage lines to add, each wrapped like [wrapLore] does + */ + fun withLore(item: ItemStack, lines: List): ItemStack { + if (lines.isEmpty()) return item.clone() + + val copy = item.clone() + val meta = copy.itemMeta ?: return copy + val existing = meta.lore().orEmpty() + val added = wrapLore(lines.joinToString("")) + + meta.lore(if (existing.isEmpty()) added else existing + Component.empty() + added) + copy.itemMeta = meta + return copy + } + /** * Represents a single visible character paired with its resolved style. */ From a61acb1cf97968c9b646c1f12737a9891a46612c Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sun, 20 Sep 2026 17:01:41 +0800 Subject: [PATCH 08/10] Feature: Add player-to-player trading Shift-right-clicking another player, or /trade , asks them to trade; once they accept both get the same table, drawn for its own viewer so your half is always on the left. What a player puts up leaves their inventory and is held by the trade, so the other side can trust what it is looking at. Money is named rather than held, because a balance quietly missing from everything that reads it would be the worse lie, so confirming re-checks it and the swap can still refuse over it. Every change to either offer drops both confirmations and locks them for a moment, so nothing can be swapped out after the other player has agreed to it. TradeManager.cancel is the only way a trade ends without the swap and is idempotent, so escrow is handed back exactly once however many things end the trade at the same instant. The quit path returns items inside PlayerQuitEvent, which still runs before the server writes the player's inventory. Money settles as a single net payment, attributed as a player trade so it lands in the ledger and the panel as a payment rather than an anonymous Vault call. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 20 ++ CLAUDE.md | 28 +- README.md | 31 ++ docs/DEVELOPER_GUIDE.md | 120 ++++++- .../net/trilleo/mc/plugins/tritown/Main.kt | 11 + .../tritown/commands/trade/TradeCommand.kt | 175 +++++++++ .../plugins/tritown/config/TradeSettings.kt | 48 +++ .../plugins/tritown/economy/EconomyContext.kt | 1 + .../tritown/economy/TransactionReason.kt | 1 + .../mc/plugins/tritown/enums/FlowCategory.kt | 4 +- .../mc/plugins/tritown/guis/trade/TradeGUI.kt | 335 ++++++++++++++++++ .../plugins/tritown/guis/trade/TradeRender.kt | 180 ++++++++++ .../tritown/listeners/trade/TradeListener.kt | 70 ++++ .../tritown/registration/GUIManager.kt | 23 ++ .../tritown/tasks/trade/TradeWatchTask.kt | 103 ++++++ .../plugins/tritown/trades/TradeExchange.kt | 96 +++++ .../mc/plugins/tritown/trades/TradeManager.kt | 264 ++++++++++++++ .../mc/plugins/tritown/trades/TradeOffer.kt | 103 ++++++ .../mc/plugins/tritown/trades/TradeSession.kt | 136 +++++++ src/main/resources/config.yml | 20 ++ src/main/resources/lang/en_US.yml | 79 +++++ src/main/resources/lang/zh_CN.yml | 78 ++++ 22 files changed, 1919 insertions(+), 7 deletions(-) create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/trade/TradeCommand.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/config/TradeSettings.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/trade/TradeGUI.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/trade/TradeRender.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/trade/TradeListener.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/trade/TradeWatchTask.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeExchange.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeManager.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeOffer.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeSession.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 15ed4b4..3492c94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ ### New Features +#### Trading + ++ Added player-to-player trading. Shift-right-click another player, or run `/trade `, and once they accept you + both get the same table: up to sixteen stacks and any amount of money a side, yours on the left and theirs on the + right, each of you reading it in your own language. + + Click an item in your inventory to put it up and click it again in the menu to take it back; right-click puts up + a single one. The gold ingot is your money — left-click adds, right-click takes off, shift does ten times as + much, and **Q** types an exact amount in chat. You can never put up more than you actually have. + + What you put up leaves your inventory and is held by the trade, so what the other side is looking at cannot be + spent, dropped or deposited behind their back. It all comes straight back the moment the trade ends any way + other than going through. + + Anything either of you changes clears both confirmations and greys the buttons for a moment, so nothing can be + swapped out after the other person has agreed to it. When you have both confirmed the items change hands and any + difference in money is paid across in one payment, recorded in the transaction log and counted in the admin + panel like any other payment between players. + + Closing the menu, walking too far apart, disconnecting or the server stopping all call the trade off and hand + everything back. `player-trades.distance` sets how close you have to be, and `player-trades.request-expiry` how + long an unanswered request stands; `player-trades.enabled` turns the whole thing off. + + NPCs wearing a player's shape are left alone, so shift-right-clicking a shop keeper still opens its shop. + #### Shops + An entry can now limit how much each player **sells** to the shop per day, per week or ever, alongside the limit on diff --git a/CLAUDE.md b/CLAUDE.md index f0a2d2d..8354135 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,8 +82,9 @@ src/main/kotlin/net/trilleo/mc/plugins/tritown/ ├── registration/ # Auto-registration engine (do not modify lightly) ├── shops/ # Admin shops: model, trading, storage, FancyNpcs bridge (not scanned) ├── tasks/ # Scheduled tasks (auto-registered, extend PluginTask) -└── utils/ # Lang, EconomyUtil, itemStack DSL, MessageUtil, LoreUtil, ChatPrompt, CountdownUtil, - # TeamUtil, TagUtil, PDCUtil, GameRuleUtil +├── trades/ # Player trades: sessions, escrow, the swap (not scanned) +└── utils/ # Lang, EconomyUtil, InventoryUtil, itemStack DSL, MessageUtil, LoreUtil, + # ChatPrompt, CountdownUtil, TeamUtil, TagUtil, PDCUtil, GameRuleUtil src/main/resources/ ├── config.yml plugin.yml └── lang/ # en_US.yml, zh_CN.yml — every player-facing string @@ -93,8 +94,8 @@ src/main/resources/ The plugin uses `PackageScanner` to discover components at startup — you **never** edit `plugin.yml` or wire things manually. Just extend the right base class and place the file in the correct package. Packages outside the table below -are never scanned, which is why the economy core lives in `economy/` and the shop core in `shops/`: both have to be -alive before the registrars build the commands and menus that read them. +are never scanned, which is why the economy core lives in `economy/`, the shop core in `shops/` and the trade core +in `trades/`: each has to be alive before the registrars build the commands and menus that read it. | Component | Base Class | Package | |:------------|:-------------------------------|:------------------------| @@ -247,6 +248,25 @@ The panel in `guis/admin` is where an owner reads the server; `/tritown admin` o anything player-written before embedding it; a shop's own name is deliberately not escaped, because an administrator wrote it. +## Working with Player Trades + +**A trade holds items that belong to a player.** See [Player Trades](docs/DEVELOPER_GUIDE.md#player-trades). + +- **Items are escrowed, money is not.** An item leaves the player's inventory the moment it is put up, so the other + side can trust what it sees. A balance is read by everything else on the server, so money is only named on the table + and moves at settlement — which is why confirming re-checks it and the swap can still refuse over it. +- **Escrow goes back exactly once.** `TradeManager.cancel` is the only way a trade ends without the swap, and + `TradeSession.end` makes it idempotent. A new way for a trade to end is a new caller of `cancel`, never a new place + that drains an offer. +- **Hand items back inside `PlayerQuitEvent`.** It still runs before the server writes the player's inventory, which + is the whole reason a disconnect costs them nothing. Pass the leaving `Player` in rather than looking it up. +- **Every change to an offer goes through `TradeSession.touch`**, which drops both confirmations and starts the lock. + A confirmation must only ever describe the table as it was at the moment it was given. +- **The swap lives in `TradeExchange` and nowhere else** — everything that can refuse is asked before anything is + handed over, and money settles as a single net payment so a trade can never be half paid for. +- **Both windows are drawn for their viewer**, never for a side, and anything that changes the table calls + `TradeGUI.redraw` so the two can never disagree. + ## Versioning & Releases - `plugin_version` in [gradle.properties](gradle.properties) is the single source of truth for the plugin version. It diff --git a/README.md b/README.md index 1c2a216..fb02fe4 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,13 @@ town or nation members a discount while you are at it. Players reach a shop by c [FancyNpcs](https://modrinth.com/plugin/fancynpcs) NPC, and every sale is recorded in the transaction log and totalled in a sales view. +**Trading, player to player.** Shift-right-click another player, or run `/trade `, and once they agree you +both get the same table: sixteen stacks and any amount of money a side, yours on the left and theirs on the right. +Items leave your inventory the moment you put them up and are held by the trade, so what the other side is looking at +cannot be spent behind their back, and anything changing on the table clears both confirmations — nothing can be +swapped out after somebody has agreed to it. Everything comes straight back if either of you closes the menu, walks +away or disconnects. + **An admin panel.** `/tt admin` opens a menu that reads the server back to you. The economy section shows how much currency exists and who holds it, what created it and what removed it — new players, shops, Towny, administrators or another plugin — with the net drift per day, how unevenly wealth is spread, how fast money circulates, and a chart of @@ -92,6 +99,7 @@ Prebuilt jars are attached to every [GitHub release](https://github.com/Trilleo/ | `/eco …` | Administer balances (OP only) | | `/tt scoreboard` | Show or hide the sidebar | | `/trades` | Open the server's global shop | +| `/trade ` | Ask another player to trade | | `/tt shop …` | Set up the server's shops (OP only) | | `/tt admin [section]` | Open the admin panel (OP only) | @@ -105,6 +113,10 @@ viewing someone else's history additionally needs `tritown.economy.admin.history `unbind ` to put an NPC behind the counter, and `stats ` for what it has traded. Each action has its own permission, `tritown.shop.admin.`. Players have no shop command of their own — they click an NPC. +`/trade` also takes `accept [player]` and `deny [player]`, which the request message offers as buttons. Both players +have to be within `player-trades.distance` blocks of each other, and have to stay that close for as long as the menu +is open. There is no permission node: whether players may trade at all is `player-trades.enabled`. + `/tt admin` opens the panel itself, and `economy` or `shops` opens that section directly. Opening the panel needs `tritown.admin`; the sections need `tritown.admin.economy` and `tritown.admin.shops` on top of it. @@ -152,6 +164,9 @@ version stays available as `/tritown:balance` and so on. | `shops.confirm-above` | `1000.0` | Purchase total that asks for confirmation first; `0` never asks | | `shops.sell-rate` | `0.5` | What the editor suggests as a payout, as a fraction of the buy price | | `shops.discounts.` | `0.0` | Money off for `has-town`, `has-nation`, `is-mayor` or `is-king` | +| `player-trades.enabled` | `true` | Turn player-to-player trading off entirely | +| `player-trades.distance` | `10.0` | How close two players must be to trade, and stay while the menu is open | +| `player-trades.request-expiry` | `60` | Seconds an unanswered trade request stands | | `scoreboard.enabled` | `true` | Turn the sidebar off entirely | | `scoreboard.refresh-interval` | `2` | Seconds between redraws of a sidebar nothing has changed on | | `scoreboard.default-on` | `true` | Whether a player who has never used `/tt scoreboard` sees one | @@ -215,6 +230,22 @@ Shops live in `plugins/TriTown/shops/shops.json`, written atomically with a `.ba written straight away; stock levels and sales figures are written every `shops.save-interval` seconds, so a crash costs at most that long of counters and never a shop. +### Player trades + +Shift-right-click the other player, or run `/trade `. They get a request with **Accept** and **Deny** buttons, +and nothing opens until they take it. Both of you have to be standing close by, and have to stay there. + +In the menu, click an item in your inventory to put it up — right-click puts up a single one — and click it again in +the menu to take it back. The gold ingot is your money: left-click adds, right-click takes off, hold shift for ten +times as much, and press **Q** to type an exact amount in chat. You can never put up more than you actually have. + +Anything either of you changes clears both confirmations and greys the button for a moment, so nothing can be swapped +out after the other person has agreed to it. When you have both confirmed, the items change hands and any difference +in money is paid across in one payment, recorded in the transaction log like any other. + +Closing the menu calls the trade off and everything goes straight back. So does walking too far apart, disconnecting, +or the server stopping. + ### The sidebar Each board under `scoreboard.boards` has a `priority`, a `condition`, and a list of `lines`. A player sees the diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index aed01e6..405e5b0 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -2,7 +2,7 @@ This guide explains how to create **commands**, **listeners**, **GUIs**, **tasks**, **custom items**, **recipes**, work with **translations** and the **configuration** system using TriTown's registration system, and how to build on -**Towny**, the **Vault economy**, the **admin panel** and the **sidebar**. Commands, listeners, GUIs, tasks, custom items, and recipes all +**Towny**, the **Vault economy**, the **admin panel**, **shops**, **player trades** and the **sidebar**. Commands, listeners, GUIs, tasks, custom items, and recipes all follow the same pattern: extend a base class (or implement an interface), place the file in the correct package, and the plugin handles the rest automatically at startup. The configuration system provides typed access to `config.yml` values. @@ -368,6 +368,20 @@ GUIManager.open(player, "settings") GUIManager.openLater(player, "settings") ``` +To redraw a menu somebody is already looking at, without opening a new one: + +```kotlin +// Which of this plugin's GUIs the player has open, or null +val open = GUIManager.openGUI(player) + +// Re-runs setup() on the same inventory: no flash, and the menu stays open +if (open?.id == "settings") GUIManager.refresh(player) +``` + +`refresh` writes into the inventory the player is already looking at rather than opening a replacement, which is what +lets a menu change under somebody — the other side of a trade adding an item, a figure that has moved on. It works on +any player, not only the one whose click you are handling, so a menu two people share can keep both windows the same. + ### Example ```kotlin @@ -2750,6 +2764,110 @@ Anything free-form — a price, a permission node, a shop's name — is asked fo a chest menu has nowhere to type and a price of 12500 is not somewhere to click. +--- + +## Player Trades + +Two players trading face to face: items and money across one table, both sides confirming before anything moves. Asked +for by shift-right-clicking the other player or with `/trade `, and always agreed to before a menu opens. + +The core lives under `trades/`, which is **not a scanned package** — the same reason `economy/` and `shops/` are not. +`TradeManager` has to be alive before the registrars build the command, the menu and the listener that read it. The +menu is `guis/trade/TradeGUI`, the command `commands/trade/TradeCommand`, the click and disconnect handling +`listeners/trade/TradeListener`, and the watchdog `tasks/trade/TradeWatchTask`. + +### The model + +| Type | What it is | +|:----------------|:------------------------------------------------------------------------------------| +| `TradeOffer` | One side's table: up to 16 stacks held by the trade, plus an amount of money named | +| `TradeParty` | One player in a trade: their offer, their confirmation, and whether they are in chat | +| `TradeSession` | The trade itself: both parties, the confirmation lock, and whether it has ended | +| `TradeManager` | Requests, live sessions, and the one way a trade ends | +| `TradeExchange` | The swap, and the only place a trade's items or money move | + +### Items are escrowed, money is not + +An item leaves the player's inventory the moment they put it up and is held by the `TradeOffer` until the trade ends. +That is what lets the other side trust what it is looking at: an offer cannot be dropped, deposited or handed to +somebody else behind their back while it is on the table. + +Money is deliberately *not* held aside. A balance is read by everything from the sidebar to another plugin, and a +figure quietly missing from all of them for the length of a trade would be a worse lie than the one escrow prevents. +Money is named on the table and only moves when both sides confirm, which is why the confirmation re-checks the +balance and the settlement can still refuse over it. + +### Escrow is handed back exactly once + +Everything that ends a trade without the swap happening goes through `TradeManager.cancel`, and `TradeSession.end` +makes that idempotent — a player closing the menu at the very moment the other one disconnects would otherwise hand +the same stacks back twice. The ways a trade ends: + +| What happened | Where it is noticed | +|:----------------------------------|:-------------------------------------------| +| Either player closed the menu | `TradeGUI.onClose` | +| Either player disconnected | `TradeListener.onQuit` | +| They walked too far apart | `TradeWatchTask` | +| A menu was replaced by another | `TradeWatchTask` | +| A chat question went unanswered | `TradeWatchTask` | +| The plugin stopped | `TradeManager.shutdown`, from `onDisable` | + +**The quit path is load-bearing.** A `PlayerQuitEvent` handler still runs before the server writes the player's +inventory to disk, so handing escrow back there is what keeps a disconnect from costing them anything. It is also why +`cancel` takes the leaving `Player` rather than looking them up: by the time a lookup would run they may be gone. +`TradeManager.shutdown` runs early in `onDisable`, while both players of every trade are still online. + +A hard crash is the one case escrow cannot survive, and nothing pretends otherwise: an item that cannot be handed back +is logged as a warning naming the player and the number of stacks. + +### Confirming + +`TradeSession.touch` is called after **every** change to either offer. It drops both confirmations and starts a short +lock, so a confirmation only ever describes the table as it was at the moment it was given, and a change cannot be +beaten by a click already on its way to the server. Both sides confirming settles the trade immediately. + +A refused settlement does **not** end the trade. It drops both confirmations and says why, because what went wrong — +no room, or money that is no longer there — is usually something the players can put right without starting over. + +### The swap + +`TradeExchange.execute` follows the same discipline shop trades do: everything that can refuse is asked before +anything is handed over. Both players online, both still in range, both with room for what they are about to receive; +then the money, which is the last step that can fail; then the items, which cannot. + +Money settles as a **single net payment**. If one side puts up 100 and the other 40, sixty moves once. Two payments +could leave a player unable to make the second with money the first had already taken, and there is no state in which +a trade should be half paid for. It is attributed with `EconomyContext.SOURCE_TRADE` and `TransactionReason.TRADE`, +which `FlowCategory.of` maps to `PAYMENT` — a trade moves money between two players rather than creating or +destroying any, so it is neither a faucet nor a sink. + +### The menu + +Both players look at the same trade through a window of their own, so every item is drawn for its **viewer** rather +than for a side: the left four columns are always what you have put up and the right four always the other player's, +whichever end of the trade you are at. Anything either of them changes calls `TradeGUI.redraw`, which refreshes both +windows through `GUIManager.refresh` so the two can never show different tables. + +Every click is cancelled and then carried out by hand. The offer and the player's own inventory only add up if one +piece of code moves both, so the client is never allowed to move anything itself. + +Money is put up with click steps on the gold-ingot slot, and an exact amount is asked for in chat through +`ChatPrompt`, because a chest menu has nowhere to type. That closes the menu, which is otherwise how a trade is called +off — `TradeParty.promptingSince` is what tells the two apart, and it is what the other side is shown in place of a +confirmation while it is set. `TradeWatchTask` brings a player back to the menu if they back out of the question, and +calls the trade off if they leave it hanging. + +### Settings + +| Key | What it does | +|:-------------------------------|:-----------------------------------------------------------------| +| `player-trades.enabled` | Turns trading off entirely | +| `player-trades.distance` | How close the two players must be, when asking and throughout | +| `player-trades.request-expiry` | Seconds an unanswered request stands | + +`TradeSettings` is a snapshot swapped in whole on a reload, the way `ShopSettings` is. + + ## Sidebar The sidebar lives in `net.trilleo.mc.plugins.tritown.scoreboard`, which — like `economy` — is **not** one of the diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt index c6d15b2..37e8e04 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt @@ -6,6 +6,7 @@ import net.trilleo.mc.plugins.tritown.config.EconomySettings import net.trilleo.mc.plugins.tritown.config.PluginConfig import net.trilleo.mc.plugins.tritown.config.ScoreboardSettings import net.trilleo.mc.plugins.tritown.config.ShopSettings +import net.trilleo.mc.plugins.tritown.config.TradeSettings import net.trilleo.mc.plugins.tritown.data.PlayerDataManager import net.trilleo.mc.plugins.tritown.data.ServerDataManager import net.trilleo.mc.plugins.tritown.economy.* @@ -18,6 +19,7 @@ import net.trilleo.mc.plugins.tritown.registration.* import net.trilleo.mc.plugins.tritown.scoreboard.ScoreboardService import net.trilleo.mc.plugins.tritown.shops.ShopManager import net.trilleo.mc.plugins.tritown.shops.storage.JsonShopStorage +import net.trilleo.mc.plugins.tritown.trades.TradeManager import net.trilleo.mc.plugins.tritown.utils.EconomyUtil import net.trilleo.mc.plugins.tritown.utils.Lang import net.trilleo.mc.plugins.tritown.utils.MessageUtil @@ -103,6 +105,8 @@ class Main : JavaPlugin() { ShopManager.global() } + TradeSettings.load(pluginConfig) + ItemRegistrar.registerAll(this) RecipeRegistrar.registerAll(this) @@ -143,6 +147,8 @@ class Main : JavaPlugin() { ShopSettings.load(pluginConfig) // shops.global-id may have moved, and the shop it now names may not exist yet. ShopManager.global() + + TradeSettings.load(pluginConfig) } override fun onDisable() { @@ -154,6 +160,11 @@ class Main : JavaPlugin() { TaskRegistrar.unregisterAll() RecipeRegistrar.unregisterAll() + // Before anything else is torn down, and while both players of a trade + // are still online: every escrowed item has to be back in an inventory + // the server is about to save. + TradeManager.shutdown() + ShopManager.shutdown() PlayerDataManager.saveAll() diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/trade/TradeCommand.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/trade/TradeCommand.kt new file mode 100644 index 0000000..7762546 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/trade/TradeCommand.kt @@ -0,0 +1,175 @@ +package net.trilleo.mc.plugins.tritown.commands.trade + +import net.trilleo.mc.plugins.tritown.guis.trade.TradeGUI +import net.trilleo.mc.plugins.tritown.registration.PluginCommand +import net.trilleo.mc.plugins.tritown.trades.TradeManager +import net.trilleo.mc.plugins.tritown.utils.ComponentUtil +import net.trilleo.mc.plugins.tritown.utils.sendPrefixed +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Bukkit +import org.bukkit.command.CommandSender +import org.bukkit.entity.Player + +/** + * Asking another player to trade, and answering someone who has asked. + * + * The same thing shift-right-clicking them does, for a player who would rather + * type it — so the rules are the same too, down to having to be standing next + * to each other. Nothing opens until the other player agrees. + * + * It declares no permission on purpose, the way `/trades` does: trading is + * something every player does, and whether it happens at all is + * `player-trades.enabled` in the configuration rather than a node. + */ +class TradeCommand : PluginCommand( + name = "trade", + description = "Ask another player to trade", + usage = "/trade ", + isMainCommand = true, +) { + + override fun execute(sender: CommandSender, args: Array): Boolean { + val player = sender as? Player ?: run { + sender.sendPrefixed(sender.tr("command.trade.players-only")) + return true + } + + if (!TradeManager.isEnabled) { + player.sendPrefixed(error(player, "command.trade.disabled")) + return true + } + + val first = args.firstOrNull() ?: run { + player.sendPrefixed(player.tr("command.trade.usage")) + return true + } + + when (first.lowercase()) { + ACCEPT -> answer(player, args.getOrNull(1), accept = true) + DENY -> answer(player, args.getOrNull(1), accept = false) + else -> ask(player, first) + } + return true + } + + override fun tabComplete(sender: CommandSender, args: Array): List { + val player = sender as? Player ?: return emptyList() + + return when (args.size) { + 1 -> (listOf(ACCEPT, DENY) + nearby(player)).filter { it.startsWith(args[0], ignoreCase = true) } + 2 -> if (args[0].lowercase() in setOf(ACCEPT, DENY)) { + requesters(player).filter { it.startsWith(args[1], ignoreCase = true) } + } else { + emptyList() + } + + else -> emptyList() + } + } + + // ── Asking ────────────────────────────────────────────────────────── + + private fun ask(player: Player, name: String) { + val target = Bukkit.getPlayerExact(name)?.takeIf { player.canSee(it) } ?: run { + player.sendPrefixed(error(player, "command.trade.not-found", name)) + return + } + + // Answering someone who has already asked, rather than asking them back + // and leaving the two requests waiting for each other. + if (TradeManager.hasRequest(player.uniqueId, target.uniqueId)) { + open(player, target) + return + } + + TradeManager.refusal(player, target)?.let { key -> + player.sendPrefixed(error(player, key, target.name)) + return + } + + TradeManager.request(player, target) + } + + // ── Answering ─────────────────────────────────────────────────────── + + /** + * Answers [name]'s request, or the only outstanding one when no name was + * given. + */ + private fun answer(player: Player, name: String?, accept: Boolean) { + val waiting = requesters(player) + val from = when { + name != null -> name + waiting.size == 1 -> waiting.first() + waiting.isEmpty() -> { + player.sendPrefixed(error(player, "command.trade.no-requests")) + return + } + + else -> { + player.sendPrefixed( + player.tr( + "command.trade.which-request", + "names" to waiting.joinToString(", ") { ComponentUtil.escape(it) }, + ) + ) + return + } + } + + val requester = Bukkit.getPlayerExact(from) ?: run { + player.sendPrefixed(error(player, "command.trade.not-found", from)) + return + } + + if (!TradeManager.hasRequest(player.uniqueId, requester.uniqueId)) { + player.sendPrefixed(error(player, "command.trade.no-request", requester.name)) + return + } + + if (!accept) { + TradeManager.dropRequest(player.uniqueId, requester.uniqueId) + player.sendPrefixed(player.tr("command.trade.denied", "name" to TradeManager.name(requester))) + requester.sendPrefixed( + requester.tr("command.trade.denied-by", "name" to TradeManager.name(player)) + ) + return + } + + open(player, requester) + } + + /** Opens the trade, checking one last time that both players are still able to. */ + private fun open(player: Player, other: Player) { + TradeManager.dropRequest(player.uniqueId, other.uniqueId) + + TradeManager.refusal(player, other)?.let { key -> + player.sendPrefixed(error(player, key, other.name)) + return + } + + TradeManager.begin(other, player) + TradeGUI.show(other) + TradeGUI.show(player) + } + + // ── Helpers ───────────────────────────────────────────────────────── + + /** Everyone whose request [player] has not answered yet and who is still online. */ + private fun requesters(player: Player): List = + TradeManager.requestersFor(player.uniqueId).mapNotNull { Bukkit.getPlayer(it)?.name } + + /** Everyone [player] could ask right now, which is everyone close enough to trade. */ + private fun nearby(player: Player): List = Bukkit.getOnlinePlayers() + .filter { it.uniqueId != player.uniqueId && player.canSee(it) && TradeManager.inRange(player, it) } + .map { it.name } + + /** A refusal in the plugin's error colours, with [name] escaped because a player typed it. */ + private fun error(player: Player, key: String, name: String = ""): String = + player.tr("common.error", "message" to player.tr(key, "name" to ComponentUtil.escape(name))) + + private companion object { + const val ACCEPT = "accept" + const val DENY = "deny" + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/TradeSettings.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/TradeSettings.kt new file mode 100644 index 0000000..ee5a313 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/TradeSettings.kt @@ -0,0 +1,48 @@ +package net.trilleo.mc.plugins.tritown.config + +/** + * An immutable snapshot of the `player-trades` block of `config.yml`. + * + * Built whole and swapped in on a reload, the same way [ShopSettings] is, so a + * trade being carried out while the configuration is re-read cannot see half of + * it. + * + * @param enabled whether players may trade with one another at all + * @param distance how many blocks apart the two players may be, when asking and for as long as the menu is open + * @param requestExpirySeconds how long an unanswered request stands before it lapses + */ +data class TradeSettings( + val enabled: Boolean, + val distance: Double, + val requestExpirySeconds: Long, +) { + + /** Compared against `Location.distanceSquared`, so a square root is never taken to check a range. */ + val distanceSquared: Double = distance * distance + + /** [requestExpirySeconds] in the milliseconds every timestamp in the trade code is kept in. */ + val requestExpiryMillis: Long = requestExpirySeconds * 1_000L + + companion object { + + @Volatile + private var current: TradeSettings? = null + + /** The settings in force. */ + val snapshot: TradeSettings + get() = current ?: error("Trade settings have not been loaded yet") + + /** Whether [load] has run. */ + val isLoaded: Boolean + get() = current != null + + /** Reads the `player-trades` block from [config] and makes it the current snapshot. */ + fun load(config: PluginConfig): TradeSettings = read(config).also { current = it } + + private fun read(config: PluginConfig): TradeSettings = TradeSettings( + enabled = config.getBoolean("player-trades.enabled", true), + distance = config.getDouble("player-trades.distance", 10.0).coerceIn(1.0, 128.0), + requestExpirySeconds = config.getLong("player-trades.request-expiry", 60L).coerceIn(5L, 600L), + ) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyContext.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyContext.kt index 945deef..6a46868 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyContext.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyContext.kt @@ -43,4 +43,5 @@ object EconomyContext { const val SOURCE_COMMAND = "command" const val SOURCE_TOWNY = "towny" const val SOURCE_SHOP = "shop" + const val SOURCE_TRADE = "trade" } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/TransactionReason.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/TransactionReason.kt index 5a9fdbc..fc46c57 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/TransactionReason.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/TransactionReason.kt @@ -27,6 +27,7 @@ object TransactionReason { const val ADMIN_RESET = "money.reason.admin-reset" const val SHOP_BUY = "money.reason.shop-buy" const val SHOP_SELL = "money.reason.shop-sell" + const val TRADE = "money.reason.trade" /** Encodes [key] and its [args] into the single string that is stored with the transaction. */ fun of(key: String, vararg args: Pair): String = diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/FlowCategory.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/FlowCategory.kt index 88c4bdc..df2e903 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/FlowCategory.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/FlowCategory.kt @@ -28,7 +28,7 @@ enum class FlowCategory { /** An administrator giving, taking, setting or resetting a balance. */ ADMIN, - /** A payment from one account to another. */ + /** A payment from one account to another, whether sent with `/pay` or settled by a trade. */ PAYMENT, /** Another plugin, through Vault, with nothing to identify it further. */ @@ -67,7 +67,7 @@ enum class FlowCategory { key == TransactionReason.SHOP_BUY || key == TransactionReason.SHOP_SELL -> SHOP key == TransactionReason.TOWNY || key == TransactionReason.TOWN_DELETED -> TOWNY key == TransactionReason.ADMIN_SET || key == TransactionReason.ADMIN_RESET -> ADMIN - key == TransactionReason.PAYMENT -> PAYMENT + key == TransactionReason.PAYMENT || key == TransactionReason.TRADE -> PAYMENT source == EconomyContext.SOURCE_SHOP -> SHOP source == EconomyContext.SOURCE_TOWNY -> TOWNY key == TransactionReason.EXTERNAL -> EXTERNAL diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/trade/TradeGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/trade/TradeGUI.kt new file mode 100644 index 0000000..a10f28a --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/trade/TradeGUI.kt @@ -0,0 +1,335 @@ +package net.trilleo.mc.plugins.tritown.guis.trade + +import net.kyori.adventure.text.Component +import net.trilleo.mc.plugins.tritown.economy.Money +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.registration.GUIFrame +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PluginGUI +import net.trilleo.mc.plugins.tritown.trades.TradeExchange +import net.trilleo.mc.plugins.tritown.trades.TradeManager +import net.trilleo.mc.plugins.tritown.trades.TradeOffer +import net.trilleo.mc.plugins.tritown.trades.TradeParty +import net.trilleo.mc.plugins.tritown.trades.TradeSession +import net.trilleo.mc.plugins.tritown.utils.ChatPrompt +import net.trilleo.mc.plugins.tritown.utils.ComponentUtil +import net.trilleo.mc.plugins.tritown.utils.InventoryUtil +import net.trilleo.mc.plugins.tritown.utils.LoreUtil +import net.trilleo.mc.plugins.tritown.utils.sendPrefixed +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.entity.Player +import org.bukkit.event.inventory.ClickType +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.inventory.Inventory + +/** + * The table two players trade across. + * + * Both of them are looking at the same trade through a window of their own, so + * every side of the menu is drawn for its viewer: the left half is always the + * items you put up and the right half always the other player's, whichever end + * of the trade you are at. Anything either of them changes redraws both + * windows, which is what keeps the two from ever showing different tables. + * + * Items are held by the trade rather than by the player from the moment they + * are put up, so what the other side is looking at cannot be spent or dropped + * behind their back. Closing the menu calls the trade off and hands everything + * back; the one exception is stepping out to chat to name an exact amount, + * which the other side is shown instead of a confirmation. + */ +class TradeGUI : PluginGUI( + id = ID, + titleKey = "gui.trade.title", + rows = ROWS, + fillMode = FillMode.NONE, +) { + + override fun title(player: Player): Component { + val session = TradeManager.sessionOf(player.uniqueId) + val you = session?.partyOf(player.uniqueId) + val name = if (session != null && you != null) TradeManager.name(session.other(you)) else "" + return ComponentUtil.parse(player.tr("gui.trade.title", "name" to name)) + } + + override fun setup(player: Player, inventory: Inventory) { + val session = TradeManager.sessionOf(player.uniqueId) ?: return + val you = session.partyOf(player.uniqueId) ?: return + val them = session.other(you) + + inventory.clear() + GUIFrame.draw(inventory, ITEM_SLOTS) + + val takeBack = listOf(player.tr("gui.trade.take-back")) + you.offer.items.forEachIndexed { index, item -> + YOURS.getOrNull(index)?.let { inventory.setItem(it, LoreUtil.withLore(item, takeBack)) } + } + + val offered = listOf(player.tr("gui.trade.their-item", "name" to TradeManager.name(them))) + them.offer.items.forEachIndexed { index, item -> + THEIRS.getOrNull(index)?.let { inventory.setItem(it, LoreUtil.withLore(item, offered)) } + } + + inventory.setItem(SLOT_PARTNER, TradeRender.partner(player, them)) + inventory.setItem(SLOT_MONEY_YOURS, TradeRender.yourMoney(player, you)) + inventory.setItem(SLOT_MONEY_THEIRS, TradeRender.theirMoney(player, them)) + inventory.setItem(SLOT_CONFIRM_YOURS, TradeRender.yourConfirm(player, you)) + inventory.setItem(SLOT_CONFIRM_THEIRS, TradeRender.theirConfirm(player, them)) + inventory.setItem(SLOT_STATUS, TradeRender.status(player, session, System.currentTimeMillis())) + } + + /** + * Nothing is ever moved by the client here. + * + * Every click is cancelled and then carried out by hand, because the only + * way the offer and the player's own inventory can be kept adding up is for + * one piece of code to move both. + */ + override fun onClick(event: InventoryClickEvent) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + val session = TradeManager.sessionOf(player.uniqueId) ?: return + val you = session.partyOf(player.uniqueId) ?: return + + if (event.clickedInventory === player.inventory) { + offer(player, session, you, event) + return + } + if (event.clickedInventory !== event.view.topInventory) return + + when (event.rawSlot) { + SLOT_MONEY_YOURS -> money(player, session, you, event.click) + SLOT_CONFIRM_YOURS -> confirm(player, session, you) + else -> YOURS.indexOf(event.rawSlot).takeIf { it >= 0 }?.let { takeBack(player, session, you, it) } + } + } + + override fun onClose(event: InventoryCloseEvent) { + val player = event.player as? Player ?: return + val session = TradeManager.sessionOf(player.uniqueId) ?: return + val you = session.partyOf(player.uniqueId) ?: return + + // Stepping out to chat to name an amount closes the menu too, and that + // is the one close that does not mean the trade is off. + if (you.isPrompting) return + + TradeManager.cancel(session, "trade.cancelled.closed") + } + + // ── Items ─────────────────────────────────────────────────────────── + + /** Moves what was clicked in the player's own inventory onto the table. */ + private fun offer(player: Player, session: TradeSession, you: TradeParty, event: InventoryClickEvent) { + val clicked = event.currentItem ?: return + if (clicked.type.isAir) return + + val wanted = if (event.click == ClickType.RIGHT) 1 else clicked.amount + val accepted = you.offer.add(clicked.clone().apply { amount = wanted }) + if (accepted <= 0) { + TradeRender.refuse(player, "trade.error.offer-full", "amount" to TradeOffer.MAX_STACKS) + return + } + + val left = clicked.amount - accepted + event.clickedInventory?.setItem(event.slot, if (left > 0) clicked.clone().apply { amount = left } else null) + + TradeRender.click(player) + changed(session) + } + + /** Takes the stack at [index] of the player's own half back off the table. */ + private fun takeBack(player: Player, session: TradeSession, you: TradeParty, index: Int) { + val stack = you.offer.itemAt(index) ?: return + if (!InventoryUtil.hasSpaceFor(player, listOf(stack))) { + TradeRender.refuse(player, "trade.error.no-room") + return + } + + you.offer.takeAt(index)?.let { InventoryUtil.give(player, listOf(it)) } + TradeRender.click(player) + changed(session) + } + + // ── Money ─────────────────────────────────────────────────────────── + + /** Puts money on or takes it off, or steps out to chat for an exact amount. */ + private fun money(player: Player, session: TradeSession, you: TradeParty, click: ClickType) { + if (!TradeRender.isMoneyAvailable) { + TradeRender.refuse(player, "trade.error.economy-unavailable") + return + } + + if (click == ClickType.DROP || click == ClickType.CONTROL_DROP) { + promptMoney(player, session, you) + return + } + + val bulk = click.isShiftClick + val step = TradeRender.step(bulk) + val wanted = when (click) { + ClickType.LEFT, ClickType.SHIFT_LEFT -> you.offer.money.plusExact(step) + ClickType.RIGHT, ClickType.SHIFT_RIGHT -> you.offer.money.minusExact(step) + else -> return + } + + val capped = cap(player, wanted) + if (capped == you.offer.money) { + // Asking for more than there is has to be said; asking for less than + // nothing is just the button running out of room. + if (wanted > capped) TradeRender.refuse(player, "trade.error.cannot-afford") + return + } + + you.offer.setMoney(capped) + TradeRender.click(player) + changed(session) + } + + /** Sends the player to chat for an exact amount, leaving the trade standing while they type. */ + private fun promptMoney(player: Player, session: TradeSession, you: TradeParty) { + you.beginPrompt(System.currentTimeMillis()) + session.touch(System.currentTimeMillis()) + redraw(session) + + player.closeInventory() + ChatPrompt.ask( + player, + player.tr("trade.money-prompt", "amount" to TradeRender.money(TradeRender.balanceOf(player))), + ) { input -> answerMoney(player, input) } + } + + /** Reads what the player typed and puts them back at the menu, whatever it was. */ + private fun answerMoney(player: Player, input: String) { + val session = TradeManager.sessionOf(player.uniqueId) ?: return + val you = session.partyOf(player.uniqueId) ?: return + you.endPrompt() + + val typed = input.toDoubleOrNull() + val wanted = typed?.takeIf { it.isFinite() && it >= 0.0 }?.let { runCatching { TradeRender.of(it) }.getOrNull() } + + if (wanted == null) { + player.sendPrefixed(player.tr("common.invalid-amount")) + } else { + val capped = cap(player, wanted) + you.offer.setMoney(capped) + if (capped < wanted) { + player.sendPrefixed(player.tr("trade.money-capped", "amount" to TradeRender.money(capped))) + } + } + + session.touch(System.currentTimeMillis()) + show(player) + redraw(session) + } + + /** [wanted] brought inside what the player actually has, and never below nothing. */ + private fun cap(player: Player, wanted: Money): Money = when { + wanted.isNegative -> Money.ZERO + else -> minOf(wanted, TradeRender.balanceOf(player)) + } + + // ── Confirming ────────────────────────────────────────────────────── + + /** Agrees to the trade as it stands, or takes that agreement back. */ + private fun confirm(player: Player, session: TradeSession, you: TradeParty) { + if (you.confirmed) { + you.confirmed = false + TradeRender.click(player) + redraw(session) + return + } + + val now = System.currentTimeMillis() + if (session.isLocked(now)) { + TradeRender.refuse(player, "trade.error.locked") + return + } + if (!you.offer.money.isZero && TradeRender.balanceOf(player) < you.offer.money) { + TradeRender.refuse(player, "trade.error.cannot-afford") + return + } + + you.confirmed = true + TradeRender.click(player) + redraw(session) + + if (session.bothConfirmed) settle(session) + } + + /** + * Carries the trade out, now that both sides have agreed to it. + * + * A refusal drops both confirmations rather than ending the trade: what + * went wrong is usually something the players can put right — making room, + * or asking for less — without starting over. + */ + private fun settle(session: TradeSession) { + when (val result = TradeExchange.execute(session)) { + is TradeExchange.Result.Success -> TradeManager.finish(session) + + is TradeExchange.Result.Failure -> { + session.touch(System.currentTimeMillis()) + session.parties.forEach { party -> + party.player?.let { TradeRender.refuse(it, result.key, "name" to result.name) } + } + redraw(session) + } + } + } + + // ── Keeping both windows the same ─────────────────────────────────── + + /** Records a change to the table and redraws both windows. */ + private fun changed(session: TradeSession) { + session.touch(System.currentTimeMillis()) + redraw(session) + } + + companion object { + + const val ID = "trade" + + private const val ROWS = 6 + + /** The viewer's own half: the left four columns of the top four rows. */ + private val YOURS = (0..3).flatMap { row -> (0..3).map { row * 9 + it } } + + /** The other player's half, mirrored across the divider. */ + private val THEIRS = (0..3).flatMap { row -> (5..8).map { row * 9 + it } } + + private val ITEM_SLOTS = YOURS + THEIRS + + private const val SLOT_MONEY_YOURS = 38 + private const val SLOT_PARTNER = 40 + private const val SLOT_MONEY_THEIRS = 42 + private const val SLOT_CONFIRM_YOURS = 48 + private const val SLOT_STATUS = 49 + private const val SLOT_CONFIRM_THEIRS = 50 + + init { + // Each half of the menu is exactly as big as an offer, so nothing a + // player puts up can be held by the trade without being drawn. + require(YOURS.size == TradeOffer.MAX_STACKS) { "A trade's half of the menu must fit a whole offer" } + } + + /** Opens the trade [player] is in. */ + fun show(player: Player) { + GUIManager.open(player, ID) + } + + /** + * Redraws whichever of [session]'s two windows is actually open. + * + * Both players are looking at the same trade, so anything that changes + * it — a click in either menu, or the watchdog bringing someone back + * from chat — redraws both rather than only the window it happened in. + */ + fun redraw(session: TradeSession) { + for (party in session.parties) { + val player = party.player ?: continue + if (GUIManager.openGUI(player)?.id == ID) GUIManager.refresh(player) + } + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/trade/TradeRender.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/trade/TradeRender.kt new file mode 100644 index 0000000..679cb9c --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/trade/TradeRender.kt @@ -0,0 +1,180 @@ +package net.trilleo.mc.plugins.tritown.guis.trade + +import net.kyori.adventure.key.Key +import net.kyori.adventure.sound.Sound +import net.trilleo.mc.plugins.tritown.economy.CurrencyRegistry +import net.trilleo.mc.plugins.tritown.economy.Money +import net.trilleo.mc.plugins.tritown.trades.TradeManager +import net.trilleo.mc.plugins.tritown.trades.TradeParty +import net.trilleo.mc.plugins.tritown.trades.TradeSession +import net.trilleo.mc.plugins.tritown.utils.ComponentUtil +import net.trilleo.mc.plugins.tritown.utils.EconomyUtil +import net.trilleo.mc.plugins.tritown.utils.LoreUtil +import net.trilleo.mc.plugins.tritown.utils.itemStack +import net.trilleo.mc.plugins.tritown.utils.sendPrefixed +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.inventory.ItemStack +import org.bukkit.inventory.meta.SkullMeta + +/** + * The pieces the trade menu is drawn with, and the money arithmetic behind its + * buttons. + * + * Both players are looking at the same trade from opposite sides, so every item + * here is built for a viewer rather than for a side: what is "yours" in one + * window is "theirs" in the other, and only the viewer decides which wording + * and which language it is written in. + */ +object TradeRender { + + /** What one click puts on or takes off the table, in whole units of the currency. */ + private const val STEP = 100.0 + + /** How much a shift-click multiplies [STEP] by. */ + private const val BULK = 10 + + /** Whether money can be part of a trade at all right now. */ + val isMoneyAvailable: Boolean + get() = EconomyUtil.isAvailable + + /** What one click adds or removes. */ + fun step(bulk: Boolean): Money = of(if (bulk) STEP * BULK else STEP) + + /** [amount] in the currency's minor units, at the scale the economy is keeping balances in. */ + fun of(amount: Double): Money = + if (CurrencyRegistry.isLoaded) CurrencyRegistry.primary.of(amount) else Money.ofDouble(amount, 2) + + /** [money] back as the `Double` the Vault economy speaks in. */ + fun toDouble(money: Money): Double = + if (CurrencyRegistry.isLoaded) CurrencyRegistry.primary.toDouble(money) else money.toDouble(2) + + /** What [player] can actually put up, which is every button's ceiling. */ + fun balanceOf(player: Player): Money = + if (isMoneyAvailable) of(EconomyUtil.balance(player)) else Money.ZERO + + /** [money] written out with the server's currency, escaped for embedding in MiniMessage. */ + fun money(money: Money): String = + if (isMoneyAvailable) ComponentUtil.escape(EconomyUtil.format(toDouble(money))) else toDouble(money).toString() + + // ── The menu's own items ──────────────────────────────────────────── + + /** The other player, so it is never in doubt whose window this is. */ + fun partner(viewer: Player, them: TradeParty): ItemStack { + val name = TradeManager.name(them) + return itemStack(Material.PLAYER_HEAD) { + name(viewer.tr("gui.trade.partner", "name" to name)) + meta { + lore(LoreUtil.wrapLore(viewer.tr("gui.trade.partner-lore"))) + (this as? SkullMeta)?.owningPlayer = them.player + } + } + } + + /** What the viewer has put up, and how to change it. */ + fun yourMoney(viewer: Player, you: TradeParty): ItemStack { + if (!isMoneyAvailable) return noMoney(viewer) + + val lines = listOf( + viewer.tr("gui.trade.money-amount", "amount" to money(you.offer.money)), + viewer.tr("gui.trade.money-balance", "amount" to money(balanceOf(viewer))), + "", + viewer.tr("gui.trade.money-add", "amount" to money(step(bulk = false))), + viewer.tr("gui.trade.money-remove", "amount" to money(step(bulk = false))), + viewer.tr("gui.trade.money-bulk", "amount" to money(step(bulk = true))), + viewer.tr("gui.trade.money-exact"), + ) + + return itemStack(Material.GOLD_INGOT) { + name(viewer.tr("gui.trade.money-yours")) + meta { lore(LoreUtil.wrapLore(lines.joinToString(""))) } + } + } + + /** What the other player has put up. */ + fun theirMoney(viewer: Player, them: TradeParty): ItemStack { + if (!isMoneyAvailable) return noMoney(viewer) + + return itemStack(Material.GOLD_NUGGET) { + name(viewer.tr("gui.trade.money-theirs", "name" to TradeManager.name(them))) + meta { + lore( + LoreUtil.wrapLore( + viewer.tr("gui.trade.money-amount", "amount" to money(them.offer.money)) + ) + ) + } + } + } + + /** The viewer's own confirmation, which their click turns on and off. */ + fun yourConfirm(viewer: Player, you: TradeParty): ItemStack { + val material = if (you.confirmed) Material.LIME_CONCRETE else Material.GRAY_CONCRETE + val nameKey = if (you.confirmed) "gui.trade.confirmed" else "gui.trade.confirm" + val loreKey = if (you.confirmed) "gui.trade.confirmed-lore" else "gui.trade.confirm-lore" + + return itemStack(material) { + name(viewer.tr(nameKey)) + meta { lore(LoreUtil.wrapLore(viewer.tr(loreKey))) } + } + } + + /** Where the other player has got to, including having stepped out to chat to name an amount. */ + fun theirConfirm(viewer: Player, them: TradeParty): ItemStack { + val name = TradeManager.name(them) + val material = when { + them.isPrompting -> Material.ORANGE_CONCRETE + them.confirmed -> Material.LIME_CONCRETE + else -> Material.GRAY_CONCRETE + } + val key = when { + them.isPrompting -> "gui.trade.their-away" + them.confirmed -> "gui.trade.their-confirmed" + else -> "gui.trade.their-confirm" + } + + return itemStack(material) { + name(viewer.tr(key, "name" to name)) + } + } + + /** What the menu expects of the viewer, and what has just changed under them. */ + fun status(viewer: Player, session: TradeSession, now: Long): ItemStack { + val lines = buildList { + add(viewer.tr("gui.trade.status-add")) + add(viewer.tr("gui.trade.status-take")) + add(viewer.tr("gui.trade.status-both")) + add("") + add(viewer.tr("gui.trade.status-close")) + if (session.isLocked(now)) { + add("") + add(viewer.tr("gui.trade.status-locked")) + } + } + + return itemStack(Material.PAPER) { + name(viewer.tr("gui.trade.status")) + meta { lore(LoreUtil.wrapLore(lines.joinToString(""))) } + } + } + + // ── Feedback ──────────────────────────────────────────────────────── + + /** Tells [player] why a click did nothing, in the plugin's own error colours. */ + fun refuse(player: Player, key: String, vararg args: Pair) { + player.sendPrefixed(player.tr("common.error", "message" to player.tr(key, *args))) + player.playSound(sound("minecraft:entity.villager.no")) + } + + /** The click that means something changed on the table. */ + fun click(player: Player) { + player.playSound(sound("minecraft:ui.button.click")) + } + + private fun noMoney(viewer: Player): ItemStack = itemStack(Material.BARRIER) { + name(viewer.tr("gui.trade.money-none")) + } + + private fun sound(key: String): Sound = Sound.sound(Key.key(key), Sound.Source.UI, 1f, 1f) +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/trade/TradeListener.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/trade/TradeListener.kt new file mode 100644 index 0000000..b0ef6bb --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/trade/TradeListener.kt @@ -0,0 +1,70 @@ +package net.trilleo.mc.plugins.tritown.listeners.trade + +import net.trilleo.mc.plugins.tritown.trades.TradeManager +import net.trilleo.mc.plugins.tritown.utils.sendPrefixed +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerInteractAtEntityEvent +import org.bukkit.event.player.PlayerInteractEntityEvent +import org.bukkit.event.player.PlayerQuitEvent +import org.bukkit.inventory.EquipmentSlot + +/** + * Asking to trade by shift-right-clicking the other player, and cleaning up + * after someone who disconnects mid-trade. + * + * The quit half matters more than it looks: a trade is holding items that + * belong to the player leaving, and a `PlayerQuitEvent` handler still runs + * before the server writes their inventory to disk, so handing the escrow back + * here is what keeps a disconnect from costing them anything. + */ +class TradeListener : Listener { + + /** + * Opens a request when one player shift-right-clicks another. + * + * Right-clicking an entity sends two packets, and the second of them is + * delivered as [PlayerInteractAtEntityEvent] — which is a + * [PlayerInteractEntityEvent] as well, so without dropping it here one + * click would ask twice. The off hand is dropped for the same reason. + */ + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + fun onInteract(event: PlayerInteractEntityEvent) { + if (event is PlayerInteractAtEntityEvent) return + if (event.hand != EquipmentSlot.HAND) return + if (!event.player.isSneaking) return + + val target = event.rightClicked as? Player ?: return + if (isNpc(target)) return + if (!TradeManager.isEnabled) return + + val player = event.player + TradeManager.refusal(player, target)?.let { key -> + player.sendPrefixed(player.tr("common.error", "message" to player.tr(key, "name" to TradeManager.name(target)))) + return + } + + TradeManager.request(player, target) + } + + @EventHandler + fun onQuit(event: PlayerQuitEvent) { + TradeManager.handleQuit(event.player) + } + + /** + * Whether [target] is an NPC wearing a player's shape rather than somebody + * who could trade. + * + * A shop keeper is a player entity as far as this event is concerned, so + * the usual `NPC` marker is checked and, for the plugins that do not set + * one, whether the server's own player list actually has them: a fake + * player is never in it. + */ + private fun isNpc(target: Player): Boolean = + target.hasMetadata("NPC") || Bukkit.getPlayer(target.uniqueId) !== target +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIManager.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIManager.kt index 2e6c735..50be7fc 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIManager.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIManager.kt @@ -99,6 +99,29 @@ object GUIManager : Listener { */ fun getGUI(id: String): PluginGUI? = guis[id] + /** + * The GUI [player] currently has open, or `null` when they have none of + * this plugin's menus open. + */ + fun openGUI(player: Player): PluginGUI? = openGUIs[player]?.first + + /** + * Redraws the GUI [player] already has open. + * + * The same inventory is written into rather than a new one being opened, so + * the screen does not flash and a menu that changes while it is being + * looked at — a trade the other player just added to — updates in place. + * + * @return `false` when the player has no plugin GUI open + */ + fun refresh(player: Player): Boolean { + val (gui, inventory) = openGUIs[player] ?: return false + fillInventory(gui, inventory) + gui.setup(player, inventory) + player.updateInventory() + return true + } + /** * Returns an unmodifiable view of all registered GUI ids. */ diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/trade/TradeWatchTask.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/trade/TradeWatchTask.kt new file mode 100644 index 0000000..a9a3492 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/trade/TradeWatchTask.kt @@ -0,0 +1,103 @@ +package net.trilleo.mc.plugins.tritown.tasks.trade + +import net.trilleo.mc.plugins.tritown.guis.trade.TradeGUI +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PluginTask +import net.trilleo.mc.plugins.tritown.trades.TradeManager +import net.trilleo.mc.plugins.tritown.trades.TradeParty +import net.trilleo.mc.plugins.tritown.trades.TradeSession +import net.trilleo.mc.plugins.tritown.utils.ChatPrompt +import org.bukkit.entity.Player + +/** + * Watches every live trade once a second: lapses requests nobody answered, and + * calls off a trade whose two players are no longer in a position to finish it. + * + * The menu itself only hears about what is clicked in it, so everything that + * happens *around* a trade — one player walking away, the other one being + * shown something else, someone leaving a question hanging in chat — is noticed + * here. Every one of those ends with the escrow going back where it came from. + */ +class TradeWatchTask : PluginTask(delay = INTERVAL_TICKS, period = INTERVAL_TICKS) { + + override fun run() { + if (!TradeManager.isEnabled) return + + val now = System.currentTimeMillis() + TradeManager.expireRequests(now) + TradeManager.sessions().forEach { watch(it, now) } + } + + private fun watch(session: TradeSession, now: Long) { + val first = session.initiator.player + val second = session.target.player + if (first == null || second == null) { + TradeManager.cancel(session, "trade.cancelled.gone") + return + } + + if (!TradeManager.inRange(first, second)) { + TradeManager.cancel(session, "trade.cancelled.too-far") + return + } + + // A trade that has only just opened may not have drawn both menus yet. + if (now - session.startedAt < OPENING_GRACE_MILLIS) return + + for (party in session.parties) { + val player = party.player ?: continue + + if (party.isPrompting) { + if (!resume(session, party, player, now)) return + continue + } + + if (GUIManager.openGUI(player)?.id != TradeGUI.ID) { + TradeManager.cancel(session, "trade.cancelled.closed") + return + } + } + } + + /** + * Deals with a player who is off in chat naming an amount. + * + * Backing out of the question and answering it look identical for the one + * tick between the answer being taken and the callback running, so a player + * is only brought back to the menu once they have looked idle twice. + * + * @return `false` when the trade was called off and the rest of it should be left alone + */ + private fun resume(session: TradeSession, party: TradeParty, player: Player, now: Long): Boolean { + if (ChatPrompt.isWaiting(player)) { + party.promptIdleSince = 0L + if (now - party.promptingSince > PROMPT_TIMEOUT_MILLIS) { + TradeManager.cancel(session, "trade.cancelled.away") + return false + } + return true + } + + if (party.promptIdleSince == 0L) { + party.promptIdleSince = now + return true + } + + party.endPrompt() + TradeGUI.show(player) + TradeGUI.redraw(session) + return true + } + + companion object { + + /** Once a second: often enough that walking away ends a trade promptly, cheap enough to ignore. */ + private const val INTERVAL_TICKS = 20L + + /** How long after a trade opens before its menus are expected to be on screen. */ + private const val OPENING_GRACE_MILLIS = 2_000L + + /** How long a player may leave a question hanging in chat before the trade is called off. */ + private const val PROMPT_TIMEOUT_MILLIS = 60_000L + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeExchange.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeExchange.kt new file mode 100644 index 0000000..c469163 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeExchange.kt @@ -0,0 +1,96 @@ +package net.trilleo.mc.plugins.tritown.trades + +import net.trilleo.mc.plugins.tritown.economy.CurrencyRegistry +import net.trilleo.mc.plugins.tritown.economy.EconomyContext +import net.trilleo.mc.plugins.tritown.economy.Money +import net.trilleo.mc.plugins.tritown.economy.TransactionReason +import net.trilleo.mc.plugins.tritown.utils.EconomyUtil +import net.trilleo.mc.plugins.tritown.utils.InventoryUtil +import org.bukkit.entity.Player + +/** + * The swap itself, and the only place a trade's items or money move. + * + * The ordering is what makes it safe, and it is the same discipline shop trades + * follow: everything that can refuse is asked before anything is handed over, + * and the one step that can still fail after that — the money — is taken before + * either side's goods leave escrow. A refused trade leaves both players exactly + * as they were, still at the menu, free to change what is on the table and try + * again. + * + * Money is settled as a single net payment rather than as two. If one side puts + * up 100 and the other 40, sixty moves once: two payments could leave a player + * unable to make the second one with money the first had already taken, and + * there is no state in which a trade should be half paid for. + */ +object TradeExchange { + + /** The outcome of a settlement. */ + sealed interface Result { + + /** The swap went through. */ + data object Success : Result + + /** The swap was refused, with a `trade.error.*` key and the player it is about. */ + data class Failure(val key: String, val name: String = "") : Result + } + + /** + * Carries out [session], which both players have confirmed. + * + * The session is left alone on failure: the caller drops both + * confirmations and shows the reason, because what went wrong is usually + * something the players can put right without starting over. + */ + fun execute(session: TradeSession): Result { + val initiator = session.initiator + val target = session.target + + val first = initiator.player ?: return Result.Failure("trade.error.gone", TradeManager.name(initiator)) + val second = target.player ?: return Result.Failure("trade.error.gone", TradeManager.name(target)) + if (!TradeManager.inRange(first, second)) return Result.Failure("trade.error.too-far") + + val toFirst = target.offer.items + val toSecond = initiator.offer.items + if (!InventoryUtil.hasSpaceFor(first, toFirst)) { + return Result.Failure("trade.error.no-space", TradeManager.name(first)) + } + if (!InventoryUtil.hasSpaceFor(second, toSecond)) { + return Result.Failure("trade.error.no-space", TradeManager.name(second)) + } + + settleMoney(first, initiator.offer.money, second, target.offer.money)?.let { return it } + + InventoryUtil.give(first, target.offer.drain()) + InventoryUtil.give(second, initiator.offer.drain()) + return Result.Success + } + + /** + * Moves what the two sides owe each other, or returns why it could not. + * + * Attributed rather than moved bare, so a trade shows up in the ledger and + * the panel's figures as a payment between two players instead of as an + * anonymous Vault call. + */ + private fun settleMoney(first: Player, fromFirst: Money, second: Player, fromSecond: Money): Result.Failure? { + val net = fromFirst.minusExact(fromSecond) + if (net.isZero) return null + + if (!EconomyUtil.isAvailable) return Result.Failure("trade.error.economy-unavailable") + + val payer = if (net.isPositive) first else second + val payee = if (net.isPositive) second else first + val amount = toDouble(net.abs()) + + val paid = EconomyContext.with(EconomyContext.SOURCE_TRADE, TransactionReason.TRADE) { + EconomyUtil.transfer(payer, payee, amount) + } + + return if (paid) null else Result.Failure("trade.error.money", TradeManager.name(payer)) + } + + /** [money] at the primary currency's scale, for the one call that crosses into Vault. */ + private fun toDouble(money: Money): Double = + if (CurrencyRegistry.isLoaded) CurrencyRegistry.primary.toDouble(money) else money.toDouble(2) +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeManager.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeManager.kt new file mode 100644 index 0000000..765a473 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeManager.kt @@ -0,0 +1,264 @@ +package net.trilleo.mc.plugins.tritown.trades + +import net.kyori.adventure.key.Key +import net.kyori.adventure.sound.Sound +import net.trilleo.mc.plugins.tritown.Main +import net.trilleo.mc.plugins.tritown.config.TradeSettings +import net.trilleo.mc.plugins.tritown.utils.ComponentUtil +import net.trilleo.mc.plugins.tritown.utils.InventoryUtil +import net.trilleo.mc.plugins.tritown.utils.sendPrefixed +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * Who has asked whom to trade, who is trading, and the one way a trade ends. + * + * A trade holds items that belong to a player, so the single rule everything + * here is built around is that escrow is handed back exactly once: [cancel] is + * the only way a trade ends without the swap happening, and [TradeSession.end] + * makes it idempotent, because a player closing the menu at the moment the + * other one disconnects would otherwise hand the same stacks back twice. + * + * Everything runs on the server thread — trades touch inventories — so the maps + * are concurrent only to keep a stray read from another thread from tearing, + * never to make two trades safe to settle at once. + */ +object TradeManager { + + /** + * An outstanding request, and what the player who sent it called the player + * they sent it to — kept so that a request lapsing while the other player + * is offline can still name them. + */ + private data class Pending(val sentAt: Long, val targetName: String) + + /** Who has asked whom, as target → requester → the request. */ + private val requests = ConcurrentHashMap>() + + /** Both players of a trade point at the same session. */ + private val sessions = ConcurrentHashMap() + + /** How long the same player's repeated click on another player is read as the same request. */ + private const val REPEAT_MILLIS = 3_000L + + /** Whether players may trade with one another at all. */ + val isEnabled: Boolean + get() = TradeSettings.isLoaded && TradeSettings.snapshot.enabled + + /** The trade [uuid] is in, or `null` when they are not trading. */ + fun sessionOf(uuid: UUID): TradeSession? = sessions[uuid] + + /** Whether [uuid] is already in a trade. */ + fun isTrading(uuid: UUID): Boolean = sessions.containsKey(uuid) + + /** Every live trade, each one exactly once. */ + fun sessions(): List = sessions.values.distinct() + + /** Whether [first] and [second] are close enough to trade. */ + fun inRange(first: Player, second: Player): Boolean { + if (first.world != second.world) return false + val limit = if (TradeSettings.isLoaded) TradeSettings.snapshot.distanceSquared else 100.0 + return first.location.distanceSquared(second.location) <= limit + } + + // ── Requests ──────────────────────────────────────────────────────── + + /** + * Why [from] cannot ask [to] to trade right now, as a `command.trade.*` + * key, or `null` when they can. + * + * The same answer whether the asking was a command or a click, so the two + * ways in cannot drift apart. + */ + fun refusal(from: Player, to: Player): String? = when { + !isEnabled -> "command.trade.disabled" + from.uniqueId == to.uniqueId -> "command.trade.self" + !from.canSee(to) -> "command.trade.not-found" + isTrading(from.uniqueId) -> "command.trade.you-busy" + isTrading(to.uniqueId) -> "command.trade.busy" + !inRange(from, to) -> "command.trade.too-far" + else -> null + } + + /** + * Asks [to] to trade with [from], telling both players where things stand. + * + * A request repeated within a few seconds is ignored rather than announced + * again: the client sends two packets for one right-click, and a player + * clicking twice did not mean to send two. + */ + fun request(from: Player, to: Player) { + val now = System.currentTimeMillis() + val pending = requests.computeIfAbsent(to.uniqueId) { ConcurrentHashMap() } + val previous = pending[from.uniqueId] + if (previous != null && now - previous.sentAt < REPEAT_MILLIS) return + + pending[from.uniqueId] = Pending(now, to.name) + + val seconds = if (TradeSettings.isLoaded) TradeSettings.snapshot.requestExpirySeconds else 60L + from.sendPrefixed(from.tr("command.trade.sent", "name" to name(to), "seconds" to seconds)) + from.playSound(sound("minecraft:ui.button.click")) + + to.sendPrefixed(to.tr("command.trade.received", "name" to name(from))) + to.playSound(sound("minecraft:entity.experience_orb.pickup")) + } + + /** Whether [requester] has an outstanding request to [target]. */ + fun hasRequest(target: UUID, requester: UUID): Boolean = requests[target]?.containsKey(requester) == true + + /** Everyone with an outstanding request to [target]. */ + fun requestersFor(target: UUID): List = requests[target]?.keys?.toList().orEmpty() + + /** Drops [requester]'s request to [target], and reports whether there was one. */ + fun dropRequest(target: UUID, requester: UUID): Boolean { + val pending = requests[target] ?: return false + val removed = pending.remove(requester) != null + if (pending.isEmpty()) requests.remove(target) + return removed + } + + /** Drops every request [uuid] has sent or been sent. */ + fun dropRequests(uuid: UUID) { + requests.remove(uuid) + requests.values.forEach { it.remove(uuid) } + requests.entries.removeIf { it.value.isEmpty() } + } + + /** Lapses every request older than `player-trades.request-expiry`, telling the player who sent it. */ + fun expireRequests(now: Long) { + val limit = if (TradeSettings.isLoaded) TradeSettings.snapshot.requestExpiryMillis else 60_000L + + for ((targetId, pending) in requests) { + for ((requesterId, request) in pending) { + if (now - request.sentAt < limit) continue + pending.remove(requesterId) + + val requester = Bukkit.getPlayer(requesterId) ?: continue + requester.sendPrefixed( + requester.tr("command.trade.expired", "name" to ComponentUtil.escape(request.targetName)) + ) + } + if (pending.isEmpty()) requests.remove(targetId) + } + } + + // ── The trade itself ──────────────────────────────────────────────── + + /** + * Opens a trade between [first] and [second]. + * + * Both are registered before either menu is drawn, so the first thing + * either of them can click already sees a trade they are both in. + */ + fun begin(first: Player, second: Player): TradeSession { + dropRequests(first.uniqueId) + dropRequests(second.uniqueId) + + val session = TradeSession( + initiator = TradeParty(first.uniqueId, first.name), + target = TradeParty(second.uniqueId, second.name), + startedAt = System.currentTimeMillis(), + ) + + sessions[first.uniqueId] = session + sessions[second.uniqueId] = session + return session + } + + /** + * Calls [session] off, hands every escrowed item back and tells both + * players why, with [reasonKey]. + * + * [leaving] is the player object of someone who is disconnecting. Their + * inventory is written before the server saves it, which is why they are + * passed in rather than looked up: by the time the lookup would run they + * may already be gone. + */ + fun cancel(session: TradeSession, reasonKey: String, leaving: Player? = null) { + if (!session.end()) return + + for (party in session.parties) { + sessions.remove(party.uuid, session) + + val items = party.offer.drain() + val player = leaving?.takeIf { it.uniqueId == party.uuid } ?: party.player + + if (player == null) { + if (items.isNotEmpty()) { + Main.instance.logger.warning( + "Could not return ${items.size} escrowed stack(s) to ${party.name}: they are offline" + ) + } + continue + } + + InventoryUtil.give(player, items) + // Not wrapped in the error colours: a trade called off because a + // player closed the menu is not a mistake, it is the menu working. + player.sendPrefixed(player.tr(reasonKey)) + player.playSound(sound("minecraft:entity.villager.no")) + close(player) + } + } + + /** + * Ends [session] after the swap has already happened. + * + * Nothing is handed back here: [TradeExchange] emptied both offers into the + * other player as it went, so there is no escrow left to return. + */ + fun finish(session: TradeSession) { + if (!session.end()) return + + for (party in session.parties) { + sessions.remove(party.uuid, session) + + val player = party.player ?: continue + player.sendPrefixed(player.tr("trade.done", "name" to name(session.other(party)))) + player.playSound(sound("minecraft:entity.player.levelup")) + close(player) + } + } + + /** Ends whatever [player] was part of as they disconnect. */ + fun handleQuit(player: Player) { + dropRequests(player.uniqueId) + sessions[player.uniqueId]?.let { cancel(it, "trade.cancelled.quit", leaving = player) } + } + + /** Calls every live trade off, so no escrow is left holding items when the plugin stops. */ + fun shutdown() { + sessions().forEach { cancel(it, "trade.cancelled.shutdown") } + sessions.clear() + requests.clear() + } + + /** A player's name, escaped, because it is about to be embedded in MiniMessage. */ + fun name(player: Player): String = ComponentUtil.escape(player.name) + + /** A party's name, escaped, for a message about someone who may have left. */ + fun name(party: TradeParty): String = ComponentUtil.escape(party.player?.name ?: party.name) + + /** + * Closes [player]'s menu on the following tick. + * + * A trade is usually called off from inside a click or a close of the very + * menu being shut, and closing an inventory while one of those is still + * being delivered leaves the server and the client disagreeing about what + * is on screen. + */ + private fun close(player: Player) { + // The scheduler refuses work from a plugin that is already stopping, and + // a shutdown is not inside a click anyway, so it closes straight away. + if (!Main.instance.isEnabled) { + player.closeInventory() + return + } + Bukkit.getScheduler().runTask(Main.instance, Runnable { player.closeInventory() }) + } + + private fun sound(key: String): Sound = Sound.sound(Key.key(key), Sound.Source.UI, 1f, 1f) +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeOffer.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeOffer.kt new file mode 100644 index 0000000..2829ed1 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeOffer.kt @@ -0,0 +1,103 @@ +package net.trilleo.mc.plugins.tritown.trades + +import net.trilleo.mc.plugins.tritown.economy.Money +import org.bukkit.inventory.ItemStack + +/** + * What one player has put into a trade. + * + * The items here are held by the trade, not by the player: they left the + * player's inventory the moment they were offered, and they go back there the + * moment the trade ends any way other than going through. That is what lets the + * other side trust what it is looking at — an offer cannot be dropped, stored + * or handed to somebody else while it is on the table. + * + * Money is the exception, and deliberately so: it is named here but only moves + * when both sides have confirmed, because a balance is not something that can + * be held aside without it being missing from everything else that reads it. + */ +class TradeOffer { + + private val stacks = mutableListOf() + + /** The money this side has named, always zero or more. */ + var money: Money = Money.ZERO + private set + + /** Copies of the stacks on the table, in the order they were put down. */ + val items: List + get() = stacks.map { it.clone() } + + /** How many stacks are on the table. */ + val size: Int + get() = stacks.size + + /** Whether this side is offering nothing at all. */ + val isEmpty: Boolean + get() = stacks.isEmpty() && money.isZero + + /** A copy of the stack at [index], or `null` when there is nothing there. */ + fun itemAt(index: Int): ItemStack? = stacks.getOrNull(index)?.clone() + + /** + * Puts as much of [stack] on the table as will fit, and reports how many + * items that was. + * + * Stacks that can merge are merged first, so offering a sword and then + * thirty-two arrows twice leaves three entries rather than three-quarters + * of the table full. What does not fit stays with the caller, which is what + * keeps the player's own inventory and the offer adding up. + */ + fun add(stack: ItemStack): Int { + if (stack.type.isAir || stack.amount <= 0) return 0 + + var outstanding = stack.amount + + for (existing in stacks) { + if (outstanding == 0) break + if (!existing.isSimilar(stack)) continue + + val room = existing.maxStackSize - existing.amount + if (room <= 0) continue + + val moved = minOf(room, outstanding) + existing.amount += moved + outstanding -= moved + } + + while (outstanding > 0 && stacks.size < MAX_STACKS) { + val size = minOf(outstanding, stack.maxStackSize.coerceAtLeast(1)) + stacks += stack.clone().apply { amount = size } + outstanding -= size + } + + return stack.amount - outstanding + } + + /** Takes the stack at [index] off the table and hands it back, or `null` when there is nothing there. */ + fun takeAt(index: Int): ItemStack? = if (index in stacks.indices) stacks.removeAt(index) else null + + /** Names [amount] as this side's money. A negative amount is read as nothing. */ + fun setMoney(amount: Money) { + money = if (amount.isNegative) Money.ZERO else amount + } + + /** + * Empties the offer and hands back everything that was on it. + * + * The one way items leave, whether the trade went through or was called + * off, so nothing can be handed over twice. + */ + fun drain(): List { + val taken = stacks.toList() + stacks.clear() + money = Money.ZERO + return taken + } + + companion object { + + /** How many stacks one side may put on the table, which is the size of its half of the menu. */ + const val MAX_STACKS = 16 + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeSession.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeSession.kt new file mode 100644 index 0000000..e8caa56 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeSession.kt @@ -0,0 +1,136 @@ +package net.trilleo.mc.plugins.tritown.trades + +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import java.util.UUID + +/** One player's side of a live trade: what they have put up, and whether they have agreed to it. */ +class TradeParty(val uuid: UUID, val name: String) { + + /** What this player has put on the table. */ + val offer = TradeOffer() + + /** Whether this player has agreed to the trade as it currently stands. */ + var confirmed = false + + /** + * When this player was sent to chat to type an amount, or `0` when they are + * at the menu. + * + * A chest menu has nowhere to type, so naming an exact amount means closing + * it — and a closed trade menu is otherwise a cancelled trade. This is what + * tells the two apart, and what the other side is shown instead of a + * confirmation while it is set. + */ + var promptingSince: Long = 0L + + /** + * When this player stopped having a question waiting without an answer + * reaching the menu, or `0`. + * + * Backing out of the question and answering it look the same for the one + * tick between the answer being taken and the callback running, so a player + * is only treated as having walked away once they have looked idle twice. + */ + var promptIdleSince: Long = 0L + + /** Whether this player is away in chat naming an amount. */ + val isPrompting: Boolean + get() = promptingSince != 0L + + /** Sends this player off to chat at [now]. */ + fun beginPrompt(now: Long) { + promptingSince = now + promptIdleSince = 0L + } + + /** Brings this player back to the menu. */ + fun endPrompt() { + promptingSince = 0L + promptIdleSince = 0L + } + + /** This player, or `null` when they are no longer online. */ + val player: Player? + get() = Bukkit.getPlayer(uuid) +} + +/** + * A trade between two players, from the moment both agreed to open it until it + * goes through or is called off. + * + * Nothing here moves items or money; it is the state both menus are drawn from + * and the rules about when a confirmation counts. [TradeManager] owns the + * lifecycle and [TradeExchange] does the swap. + * + * Every change to either offer goes through [touch], which is what makes the + * confirmations mean something: they only ever describe the table as it was at + * the moment they were given. + */ +class TradeSession(val initiator: TradeParty, val target: TradeParty, val startedAt: Long) { + + private var over = false + + /** Until when a confirmation is refused, because the table just changed. */ + var lockedUntil: Long = 0L + private set + + /** Both sides, initiator first. */ + val parties: List + get() = listOf(initiator, target) + + /** Whether both players have agreed to the trade as it stands. */ + val bothConfirmed: Boolean + get() = initiator.confirmed && target.confirmed + + /** The side [uuid] is on, or `null` when they are not in this trade. */ + fun partyOf(uuid: UUID): TradeParty? = when (uuid) { + initiator.uuid -> initiator + target.uuid -> target + else -> null + } + + /** The side opposite [party]. */ + fun other(party: TradeParty): TradeParty = if (party === initiator) target else initiator + + /** + * Whether a confirmation would be refused at [now]. + * + * A click takes a moment to arrive, so without this a player could change + * what is on the table in the instant between the other side deciding and + * the server hearing about it. + */ + fun isLocked(now: Long): Boolean = now < lockedUntil + + /** + * Records that something on the table changed at [now]. + * + * Both confirmations are dropped, because neither of them describes this + * table any more, and the lock is started so that the change cannot be + * beaten by a click already on its way. + */ + fun touch(now: Long) { + initiator.confirmed = false + target.confirmed = false + lockedUntil = now + LOCK_MILLIS + } + + /** + * Marks the trade finished. + * + * @return `false` when it already was, so that a trade cancelled from two + * places at once — a player closing the menu as the other one quits — + * only hands its escrow back once + */ + fun end(): Boolean { + if (over) return false + over = true + return true + } + + companion object { + + /** How long a confirmation is refused after the table changes. */ + const val LOCK_MILLIS = 1_000L + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 72a60e9..04c1e75 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -144,6 +144,26 @@ shops: is-mayor: 0.0 is-king: 0.0 +# ── Player Trades ─────────────────────────────────────────────────────────── +# Trading between two players, face to face. Shift-right-click the other player +# or run /trade ; they have to agree before the menu opens, and both of +# you have to confirm before anything changes hands. +# +# Items leave your inventory the moment you put them up and are held by the +# trade until it goes through, so what the other side is looking at cannot be +# spent behind their back. Anything a trade is holding goes straight back if it +# is called off, if either of you closes the menu, or if one of you disconnects. +player-trades: + # Turn player trading off entirely. /trade then says so, and shift-right-clicking + # another player does nothing. + enabled: true + # How close the two of you have to be, in blocks. You also have to stay that + # close: walking away — or leaving the world they are in — calls the trade off + # and hands everything back. + distance: 10.0 + # Seconds an unanswered request stands before it lapses. + request-expiry: 60 + # ── Sidebar ───────────────────────────────────────────────────────────────── # The scoreboard shown at the right of the screen. Each board names a condition # and a priority, and a player sees the highest-priority board whose condition diff --git a/src/main/resources/lang/en_US.yml b/src/main/resources/lang/en_US.yml index 1c510b8..c137716 100644 --- a/src/main/resources/lang/en_US.yml +++ b/src/main/resources/lang/en_US.yml @@ -17,6 +17,7 @@ command: info: "Info" moderation: "Moderation" shop: "Shops" + trade: "Trading" admin: "Admin" help: @@ -117,6 +118,25 @@ command: disabled: "Shops are switched off on this server." unavailable: "The global shop is not available right now." + trade: + description: "Ask another player to trade" + players-only: "Trading happens in a menu, so only players can do it." + disabled: "Player trading is switched off on this server." + usage: "Usage: /trade " + self: "You cannot trade with yourself." + not-found: "{name} is not online." + busy: "{name} is already trading with somebody." + you-busy: "You are already trading." + too-far: "You have to be standing next to each other to trade." + sent: "Asked {name} to trade. They have {seconds}s to answer." + received: "{name} wants to trade with you. [Accept] [Deny]" + expired: "Your trade request to {name} lapsed." + no-requests: "Nobody has asked you to trade." + no-request: "{name} has not asked you to trade." + which-request: "More than one player has asked. Say which: {names}" + denied: "Turned down the trade request from {name}." + denied-by: "{name} turned down your trade request." + shop: description: "Administer the server's shops" disabled: "Shops are switched off on this server." @@ -192,6 +212,7 @@ money: admin-reset: "Reset by {admin}" shop-buy: "Bought at {shop}" shop-sell: "Sold at {shop}" + trade: "Player trade" # Where money comes from and where it goes, as the admin panel groups it. flow: @@ -635,6 +656,38 @@ gui: net: "Net: {amount}" reset: "Shift-click to set every figure back to zero" + + # The table two players trade across. The left half of the menu is always what + # the player looking at it has put up, the right half the other player's. + trade: + title: "Trading with {name}" + take-back: "Click to take this back" + their-item: "Put up by {name}" + partner: "{name}" + partner-lore: "The player you are trading with." + money-yours: "Your money" + money-theirs: "{name}'s money" + money-amount: "On the table: {amount}" + money-balance: "You have: {amount}" + money-add: "Click to put up {amount} more" + money-remove: "Right-click to take off {amount}" + money-bulk: "Hold shift for {amount} at a time" + money-exact: "Press Q to type an exact amount" + money-none: "No economy is available" + confirm: "Confirm" + confirm-lore: "Agree to the trade as it stands. Both of you have to." + confirmed: "Confirmed" + confirmed-lore: "Click to take your agreement back." + their-confirm: "{name} has not confirmed" + their-confirmed: "{name} has confirmed" + their-away: "{name} is typing an amount" + status: "How this works" + status-add: "Click an item in your inventory to put it up. Right-click puts up one of it." + status-take: "Click it again here to take it back." + status-both: "Nothing changes hands until both of you confirm." + status-close: "Closing this menu calls the trade off." + status-locked: "The trade just changed. Look it over and confirm again." + # Messages a shop sends to a player, outside the menus themselves. shop: traded: "Traded {amount}x {item} for {price}." @@ -664,6 +717,32 @@ shop: sorted: "Put all {amount} entries in order." +# Player-to-player trading, outside the menu itself. +trade: + done: "Traded with {name}." + money-prompt: "Type how much to put up. You have {amount}." + money-capped: "Put up {amount}, which is everything you have." + + error: + offer-full: "You cannot put up more than {amount} stacks at once." + no-room: "You have no room to take that back." + economy-unavailable: "The economy is not available, so money cannot be traded." + cannot-afford: "You do not have that much." + locked: "The trade just changed. Look it over and confirm again." + gone: "{name} is no longer here." + too-far: "You have moved too far apart." + no-space: "{name} has no room for that." + money: "{name} cannot pay what they put up." + + cancelled: + closed: "The trade was called off. Everything you put up is back in your inventory." + quit: "The other player left, so the trade was called off." + gone: "The other player is no longer here, so the trade was called off." + too-far: "You moved too far apart, so the trade was called off." + away: "Nobody answered the question in chat, so the trade was called off." + shutdown: "The server is stopping. The trade was called off and everything you put up is back." + + # The sidebar. Which lines appear, and in what order, is set per board in # config.yml; the wording and colours are set here. Values are written as # %marker% and are listed in config.yml. diff --git a/src/main/resources/lang/zh_CN.yml b/src/main/resources/lang/zh_CN.yml index 2578415..68ca138 100644 --- a/src/main/resources/lang/zh_CN.yml +++ b/src/main/resources/lang/zh_CN.yml @@ -17,6 +17,7 @@ command: info: "信息" moderation: "管理" shop: "商店" + trade: "交易" admin: "管理" help: @@ -117,6 +118,25 @@ command: disabled: "本服务器已关闭商店功能。" unavailable: "全局商店现在不可用。" + trade: + description: "向其他玩家发起交易" + players-only: "交易在菜单中进行,只有玩家能使用。" + disabled: "本服务器已关闭玩家交易功能。" + usage: "用法:/trade " + self: "你不能和自己交易。" + not-found: "{name} 不在线。" + busy: "{name} 正在和别人交易。" + you-busy: "你已经在交易中了。" + too-far: "你们必须站在一起才能交易。" + sent: "已向 {name} 发起交易请求,对方有 {seconds}秒 时间回应。" + received: "{name} 想和你交易。[接受] [拒绝]" + expired: "你向 {name} 发起的交易请求已过期。" + no-requests: "没有人向你发起交易请求。" + no-request: "{name} 没有向你发起交易请求。" + which-request: "有多个玩家发起了请求,请指明是谁:{names}" + denied: "已拒绝 {name} 的交易请求。" + denied-by: "{name} 拒绝了你的交易请求。" + shop: description: "管理服务器商店" disabled: "本服务器已关闭商店功能。" @@ -191,6 +211,7 @@ money: admin-reset: "由 {admin} 重置" shop-buy: "在 {shop} 购买" shop-sell: "在 {shop} 出售" + trade: "玩家交易" # 资金的来源与去向,管理面板按此归类统计。 flow: @@ -636,6 +657,37 @@ gui: net: "净额:{amount}" reset: "Shift + 单击 将所有数据清零" + + # 两名玩家进行交易的界面。菜单左半边永远是查看者自己放上的东西,右半边是对方的。 + trade: + title: "与 {name} 交易" + take-back: "单击 收回该物品" + their-item: "由 {name} 放上" + partner: "{name}" + partner-lore: "与你交易的玩家。" + money-yours: "你出的金钱" + money-theirs: "{name} 出的金钱" + money-amount: "已放上:{amount}" + money-balance: "你拥有:{amount}" + money-add: "单击 增加 {amount}" + money-remove: "右键 减少 {amount}" + money-bulk: "按住 Shift 每次 {amount}" + money-exact: "按 Q 输入精确金额" + money-none: "经济系统不可用" + confirm: "确认" + confirm-lore: "同意当前的交易内容,双方都要确认。" + confirmed: "已确认" + confirmed-lore: "单击可撤回你的确认。" + their-confirm: "{name} 尚未确认" + their-confirmed: "{name} 已确认" + their-away: "{name} 正在输入金额" + status: "交易说明" + status-add: "单击你背包中的物品即可放上,右键只放上一个。" + status-take: "再次单击菜单中的物品即可收回。" + status-both: "双方都确认之前,任何东西都不会易手。" + status-close: "关闭此菜单将取消交易。" + status-locked: "交易内容刚刚发生变化,请重新确认。" + # 商店在菜单之外发给玩家的消息。 shop: traded: "以 {price} 成交 {amount}个 {item}。" @@ -665,6 +717,32 @@ shop: sorted: "已将全部 {amount} 件商品重新排序。" +# 玩家之间的交易在菜单之外发出的消息。 +trade: + done: "已与 {name} 完成交易。" + money-prompt: "请输入要放上的金额。你拥有 {amount}。" + money-capped: "已放上 {amount},这是你的全部金钱。" + + error: + offer-full: "一次最多只能放上 {amount} 组物品。" + no-room: "你的背包放不下,无法收回。" + economy-unavailable: "经济系统不可用,无法交易金钱。" + cannot-afford: "你没有这么多钱。" + locked: "交易内容刚刚发生变化,请看清后再确认。" + gone: "{name} 已经不在这里了。" + too-far: "你们已经离得太远了。" + no-space: "{name} 的背包放不下。" + money: "{name} 付不出他们放上的金钱。" + + cancelled: + closed: "交易已取消,你放上的东西都已退回背包。" + quit: "对方已离线,交易已取消。" + gone: "对方已经不在这里,交易已取消。" + too-far: "你们离得太远,交易已取消。" + away: "聊天栏中的提问无人回答,交易已取消。" + shutdown: "服务器正在关闭,交易已取消,你放上的东西都已退回。" + + scoreboard: # 服务器名称。各帧之间只有渐变相位不同,循环播放时高光会从名称上扫过。 title: From 4739a1448c1feacc75ece5bb99ae978d55d3662b Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sun, 20 Sep 2026 19:03:03 +0800 Subject: [PATCH 09/10] Improvement: Reformat source code --- .../net/trilleo/mc/plugins/tritown/Main.kt | 6 +----- .../plugins/tritown/guis/shop/ShopAmountGUI.kt | 5 ++++- .../mc/plugins/tritown/guis/shop/ShopEntryGUI.kt | 5 ++++- .../mc/plugins/tritown/guis/shop/ShopGUI.kt | 5 ++++- .../mc/plugins/tritown/guis/shop/ShopRender.kt | 6 +----- .../mc/plugins/tritown/guis/trade/TradeGUI.kt | 16 ++++------------ .../mc/plugins/tritown/guis/trade/TradeRender.kt | 8 ++------ .../tritown/listeners/trade/TradeListener.kt | 7 ++++++- .../mc/plugins/tritown/trades/TradeManager.kt | 2 +- .../mc/plugins/tritown/trades/TradeSession.kt | 2 +- 10 files changed, 28 insertions(+), 34 deletions(-) diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt index 37e8e04..a7e66fc 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt @@ -2,11 +2,7 @@ package net.trilleo.mc.plugins.tritown import com.palmergames.bukkit.towny.TownyEconomyHandler import net.milkbowl.vault.economy.Economy -import net.trilleo.mc.plugins.tritown.config.EconomySettings -import net.trilleo.mc.plugins.tritown.config.PluginConfig -import net.trilleo.mc.plugins.tritown.config.ScoreboardSettings -import net.trilleo.mc.plugins.tritown.config.ShopSettings -import net.trilleo.mc.plugins.tritown.config.TradeSettings +import net.trilleo.mc.plugins.tritown.config.* import net.trilleo.mc.plugins.tritown.data.PlayerDataManager import net.trilleo.mc.plugins.tritown.data.ServerDataManager import net.trilleo.mc.plugins.tritown.economy.* diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopAmountGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopAmountGUI.kt index 220889c..0540a42 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopAmountGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopAmountGUI.kt @@ -5,7 +5,10 @@ import net.trilleo.mc.plugins.tritown.enums.TownyRequirement import net.trilleo.mc.plugins.tritown.registration.GUIManager import net.trilleo.mc.plugins.tritown.registration.PluginGUI import net.trilleo.mc.plugins.tritown.shops.* -import net.trilleo.mc.plugins.tritown.utils.* +import net.trilleo.mc.plugins.tritown.utils.EconomyUtil +import net.trilleo.mc.plugins.tritown.utils.LoreUtil +import net.trilleo.mc.plugins.tritown.utils.itemStack +import net.trilleo.mc.plugins.tritown.utils.tr import org.bukkit.Material import org.bukkit.entity.Player import org.bukkit.event.inventory.InventoryClickEvent diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEntryGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEntryGUI.kt index 7e6a572..51d25ec 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEntryGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEntryGUI.kt @@ -205,7 +205,10 @@ class ShopEntryGUI : PluginGUI( val current = entry.limitOn(side) entry.setLimitOn( side, - if (amount == 0) null else (current ?: ShopLimit(amount, LimitPeriod.DAILY)).copy(amount = amount), + if (amount == 0) null else (current ?: ShopLimit( + amount, + LimitPeriod.DAILY + )).copy(amount = amount), ) ShopManager.save() } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt index f1c9c35..5c7a86c 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt @@ -8,7 +8,10 @@ import net.trilleo.mc.plugins.tritown.enums.TradeSide import net.trilleo.mc.plugins.tritown.registration.GUIManager import net.trilleo.mc.plugins.tritown.registration.PagedPluginGUI import net.trilleo.mc.plugins.tritown.shops.* -import net.trilleo.mc.plugins.tritown.utils.* +import net.trilleo.mc.plugins.tritown.utils.ComponentUtil +import net.trilleo.mc.plugins.tritown.utils.LoreUtil +import net.trilleo.mc.plugins.tritown.utils.itemStack +import net.trilleo.mc.plugins.tritown.utils.tr import org.bukkit.Material import org.bukkit.entity.Player import org.bukkit.event.inventory.ClickType diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt index a382b7c..568d108 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt @@ -11,11 +11,7 @@ import net.trilleo.mc.plugins.tritown.enums.TownyRequirement import net.trilleo.mc.plugins.tritown.shops.ShopCost import net.trilleo.mc.plugins.tritown.shops.ShopEntry import net.trilleo.mc.plugins.tritown.shops.ShopTrade -import net.trilleo.mc.plugins.tritown.utils.ComponentUtil -import net.trilleo.mc.plugins.tritown.utils.EconomyUtil -import net.trilleo.mc.plugins.tritown.utils.LoreUtil -import net.trilleo.mc.plugins.tritown.utils.sendPrefixed -import net.trilleo.mc.plugins.tritown.utils.tr +import net.trilleo.mc.plugins.tritown.utils.* import org.bukkit.Bukkit import org.bukkit.entity.Player import org.bukkit.inventory.ItemStack diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/trade/TradeGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/trade/TradeGUI.kt index a10f28a..cf550f0 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/trade/TradeGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/trade/TradeGUI.kt @@ -6,17 +6,8 @@ import net.trilleo.mc.plugins.tritown.enums.FillMode import net.trilleo.mc.plugins.tritown.registration.GUIFrame import net.trilleo.mc.plugins.tritown.registration.GUIManager import net.trilleo.mc.plugins.tritown.registration.PluginGUI -import net.trilleo.mc.plugins.tritown.trades.TradeExchange -import net.trilleo.mc.plugins.tritown.trades.TradeManager -import net.trilleo.mc.plugins.tritown.trades.TradeOffer -import net.trilleo.mc.plugins.tritown.trades.TradeParty -import net.trilleo.mc.plugins.tritown.trades.TradeSession -import net.trilleo.mc.plugins.tritown.utils.ChatPrompt -import net.trilleo.mc.plugins.tritown.utils.ComponentUtil -import net.trilleo.mc.plugins.tritown.utils.InventoryUtil -import net.trilleo.mc.plugins.tritown.utils.LoreUtil -import net.trilleo.mc.plugins.tritown.utils.sendPrefixed -import net.trilleo.mc.plugins.tritown.utils.tr +import net.trilleo.mc.plugins.tritown.trades.* +import net.trilleo.mc.plugins.tritown.utils.* import org.bukkit.entity.Player import org.bukkit.event.inventory.ClickType import org.bukkit.event.inventory.InventoryClickEvent @@ -206,7 +197,8 @@ class TradeGUI : PluginGUI( you.endPrompt() val typed = input.toDoubleOrNull() - val wanted = typed?.takeIf { it.isFinite() && it >= 0.0 }?.let { runCatching { TradeRender.of(it) }.getOrNull() } + val wanted = + typed?.takeIf { it.isFinite() && it >= 0.0 }?.let { runCatching { TradeRender.of(it) }.getOrNull() } if (wanted == null) { player.sendPrefixed(player.tr("common.invalid-amount")) diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/trade/TradeRender.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/trade/TradeRender.kt index 679cb9c..1d2832a 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/trade/TradeRender.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/trade/TradeRender.kt @@ -4,15 +4,11 @@ import net.kyori.adventure.key.Key import net.kyori.adventure.sound.Sound import net.trilleo.mc.plugins.tritown.economy.CurrencyRegistry import net.trilleo.mc.plugins.tritown.economy.Money +import net.trilleo.mc.plugins.tritown.guis.trade.TradeRender.STEP import net.trilleo.mc.plugins.tritown.trades.TradeManager import net.trilleo.mc.plugins.tritown.trades.TradeParty import net.trilleo.mc.plugins.tritown.trades.TradeSession -import net.trilleo.mc.plugins.tritown.utils.ComponentUtil -import net.trilleo.mc.plugins.tritown.utils.EconomyUtil -import net.trilleo.mc.plugins.tritown.utils.LoreUtil -import net.trilleo.mc.plugins.tritown.utils.itemStack -import net.trilleo.mc.plugins.tritown.utils.sendPrefixed -import net.trilleo.mc.plugins.tritown.utils.tr +import net.trilleo.mc.plugins.tritown.utils.* import org.bukkit.Material import org.bukkit.entity.Player import org.bukkit.inventory.ItemStack diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/trade/TradeListener.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/trade/TradeListener.kt index b0ef6bb..97802be 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/trade/TradeListener.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/trade/TradeListener.kt @@ -44,7 +44,12 @@ class TradeListener : Listener { val player = event.player TradeManager.refusal(player, target)?.let { key -> - player.sendPrefixed(player.tr("common.error", "message" to player.tr(key, "name" to TradeManager.name(target)))) + player.sendPrefixed( + player.tr( + "common.error", + "message" to player.tr(key, "name" to TradeManager.name(target)) + ) + ) return } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeManager.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeManager.kt index 765a473..5aff2f8 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeManager.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeManager.kt @@ -10,7 +10,7 @@ import net.trilleo.mc.plugins.tritown.utils.sendPrefixed import net.trilleo.mc.plugins.tritown.utils.tr import org.bukkit.Bukkit import org.bukkit.entity.Player -import java.util.UUID +import java.util.* import java.util.concurrent.ConcurrentHashMap /** diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeSession.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeSession.kt index e8caa56..a5629a4 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeSession.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/trades/TradeSession.kt @@ -2,7 +2,7 @@ package net.trilleo.mc.plugins.tritown.trades import org.bukkit.Bukkit import org.bukkit.entity.Player -import java.util.UUID +import java.util.* /** One player's side of a live trade: what they have put up, and whether they have agreed to it. */ class TradeParty(val uuid: UUID, val name: String) { From 97232a1e1918fcbab650edc34479ddf9575a860a Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sun, 20 Sep 2026 19:03:51 +0800 Subject: [PATCH 10/10] Update: Plugin version 1.2.0 release --- CHANGELOG.md | 2 ++ gradle.properties | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3492c94..6989437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## Version 1.2.0 + ### New Features #### Trading diff --git a/gradle.properties b/gradle.properties index e216f5b..aa636b2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ kotlin.code.style=official # Plugin Properties -plugin_version=1.1.0 +plugin_version=1.2.0 # Dependency Versions towny_version=0.103.2.7