From 0c1776656a3d7775f762dbc3fc9c8780b5649a33 Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sat, 19 Sep 2026 09:43:06 +0800 Subject: [PATCH 01/14] Backend: Add the FancyNpcs API as a soft dependency Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 7 +++++++ build.gradle.kts | 12 ++++++++++-- gradle.properties | 1 + src/main/resources/plugin.yml | 2 ++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efd2b6b..1989151 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +### Technical Details + +#### Shops + ++ Added [FancyNpcs](https://modrinth.com/plugin/fancynpcs) as an optional dependency. TriTown builds and runs without + it; the parts that need it simply stay off. + ## Version 1.0.0 ### New Features diff --git a/build.gradle.kts b/build.gradle.kts index 813b5c1..dfb5cea 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,6 +23,9 @@ repositories { maven { url = uri("https://jitpack.io") } + maven { + url = uri("https://repo.fancyinnovations.com/releases") + } } val serverPlugins: Configuration by configurations.creating { @@ -35,7 +38,12 @@ dependencies { compileOnly("com.github.MilkBowl:VaultAPI:${providers.gradleProperty("vault_api_version").get()}") { isTransitive = false } + // Optional at runtime: NPCs open shops when FancyNpcs is installed, and the feature stays off when it is not. + compileOnly("de.oliver:FancyNpcs:${providers.gradleProperty("fancynpcs_version").get()}") { + isTransitive = false + } serverPlugins("com.palmergames.bukkit.towny:towny:${providers.gradleProperty("towny_version").get()}") + serverPlugins("de.oliver:FancyNpcs:${providers.gradleProperty("fancynpcs_version").get()}") testImplementation(kotlin("test")) // Aligned with what Paper 26.2 bundles, since the plugin uses these at runtime through paper-api. testImplementation("net.kyori:adventure-api:5.2.0") @@ -73,10 +81,10 @@ tasks.jar { } } -// Copies Towny into the test server, replacing any other Towny version left behind by a version bump. +// Copies the depended-on plugins into the test server, replacing any older version left behind by a version bump. tasks.register("copyServerPlugins") { val pluginsDir = layout.projectDirectory.dir("run/plugins") - doFirst { delete(fileTree(pluginsDir) { include("towny-*.jar") }) } + doFirst { delete(fileTree(pluginsDir) { include("towny-*.jar", "FancyNpcs-*.jar") }) } from(serverPlugins) into(pluginsDir) } diff --git a/gradle.properties b/gradle.properties index 53e449e..503069c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,3 +6,4 @@ plugin_version=1.0.0 # Dependency Versions towny_version=0.103.2.7 vault_api_version=1.7.1 +fancynpcs_version=2.9.2 diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 2f17b0b..0b8492d 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -7,6 +7,8 @@ description: Custom Towny features for the Trilleo server depend: - Towny - Vault +softdepend: + - FancyNpcs commands: tritown: From 908003206de6d83248cb0e6023b5e50b6df11050 Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sat, 19 Sep 2026 09:45:11 +0800 Subject: [PATCH 02/14] Improvement: Route inventory drags and quits through the GUI manager Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 13 +++++++++++++ .../mc/plugins/tritown/data/PlayerData.kt | 9 +++++++++ .../mc/plugins/tritown/data/ServerData.kt | 9 +++++++++ .../tritown/registration/GUIManager.kt | 19 +++++++++++++++++++ .../plugins/tritown/registration/PluginGUI.kt | 14 ++++++++++++++ 5 files changed, 64 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1989151..302367b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### Fixes + +#### Misc + ++ Fixed items being draggable into a plugin menu. Clicks were already blocked, but a drag across the menu was not. + ### Technical Details #### Shops @@ -9,6 +15,13 @@ + Added [FancyNpcs](https://modrinth.com/plugin/fancynpcs) as an optional dependency. TriTown builds and runs without it; the parts that need it simply stay off. +#### Misc + ++ GUIs can now handle a drag through `onDrag`, which cancels the drag by default. The GUI manager also forgets a player + who quits with a menu open. ++ `PlayerData` and `ServerData` gained `getJsonObject`, so a nested object that was written can be read back. + + ## Version 1.0.0 ### New Features diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/data/PlayerData.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/data/PlayerData.kt index 4a8d1a5..44381de 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/data/PlayerData.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/data/PlayerData.kt @@ -66,6 +66,15 @@ open class PlayerData(val uuid: UUID) { fun getJsonArray(key: String): JsonArray = if (json.has(key) && json.get(key).isJsonArray) json.getAsJsonArray(key) else JsonArray() + /** + * Returns the [JsonObject] stored at [key], or an empty [JsonObject] when absent. + * + * The returned object is the stored one, not a copy, so writing to it writes + * through — call [set] afterwards only when the key was absent. + */ + fun getJsonObject(key: String): JsonObject = + if (json.has(key) && json.get(key).isJsonObject) json.getAsJsonObject(key) else JsonObject() + // ── Typed Setters ─────────────────────────────────────────────────── /** Stores a [String] value at [key]. */ diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/data/ServerData.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/data/ServerData.kt index 55684c8..6c91ed8 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/data/ServerData.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/data/ServerData.kt @@ -62,6 +62,15 @@ open class ServerData { fun getJsonArray(key: String): JsonArray = if (json.has(key) && json.get(key).isJsonArray) json.getAsJsonArray(key) else JsonArray() + /** + * Returns the [JsonObject] stored at [key], or an empty [JsonObject] when absent. + * + * The returned object is the stored one, not a copy, so writing to it writes + * through — call [set] afterwards only when the key was absent. + */ + fun getJsonObject(key: String): JsonObject = + if (json.has(key) && json.get(key).isJsonObject) json.getAsJsonObject(key) else JsonObject() + // ── Typed Setters ─────────────────────────────────────────────────── /** Stores a [String] value at [key]. */ 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 78699f1..271139d 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 @@ -9,6 +9,8 @@ import org.bukkit.event.EventHandler import org.bukkit.event.Listener import org.bukkit.event.inventory.InventoryClickEvent import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.event.inventory.InventoryDragEvent +import org.bukkit.event.player.PlayerQuitEvent import org.bukkit.inventory.Inventory import org.bukkit.plugin.java.JavaPlugin @@ -92,6 +94,14 @@ object GUIManager : Listener { gui.onClick(event) } + @EventHandler + fun onInventoryDrag(event: InventoryDragEvent) { + val player = event.whoClicked as? Player ?: return + val (gui, inventory) = openGUIs[player] ?: return + if (event.inventory !== inventory) return + gui.onDrag(event) + } + @EventHandler fun onInventoryClose(event: InventoryCloseEvent) { val player = event.player as? Player ?: return @@ -101,6 +111,15 @@ object GUIManager : Listener { gui.onClose(event) } + /** + * Quitting does not always close the inventory first, and the map is keyed + * by the player object, so the entry would outlive the session. + */ + @EventHandler + fun onPlayerQuit(event: PlayerQuitEvent) { + openGUIs.remove(event.player) + } + /** * Tries to create an instance of [clazz] using a constructor that accepts * a [JavaPlugin]; falls back to a no-arg constructor. diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PluginGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PluginGUI.kt index e8a7a0e..799ed1f 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PluginGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PluginGUI.kt @@ -7,6 +7,7 @@ import net.trilleo.mc.plugins.tritown.utils.tr import org.bukkit.entity.Player import org.bukkit.event.inventory.InventoryClickEvent import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.event.inventory.InventoryDragEvent import org.bukkit.inventory.Inventory /** @@ -84,6 +85,19 @@ abstract class PluginGUI( event.isCancelled = true } + /** + * Called when a player drags a stack across this GUI. + * + * By default, all drags are cancelled, for the same reason clicks are. + * Override when the GUI reads what was dragged, and cancel the event there + * too unless the slot is genuinely meant to accept the stack. + * + * @param event the inventory drag event + */ + open fun onDrag(event: InventoryDragEvent) { + event.isCancelled = true + } + /** * Called when a player closes this GUI. * From f009b37201b561fc6bcac86432eced9ef7f49f43 Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sat, 19 Sep 2026 09:45:11 +0800 Subject: [PATCH 03/14] Improvement: Let EconomyUtil attribute a movement to its source Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 5 +++++ .../mc/plugins/tritown/economy/EconomyContext.kt | 1 + .../plugins/tritown/economy/TransactionReason.kt | 2 ++ .../mc/plugins/tritown/utils/EconomyUtil.kt | 15 +++++++++++++++ 4 files changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 302367b..005e0d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ who quits with a menu open. + `PlayerData` and `ServerData` gained `getJsonObject`, so a nested object that was written can be read back. +#### Economy + ++ `EconomyUtil.withdraw` and `EconomyUtil.deposit` can now name the source and reason of a movement, so a feature no + longer has to reach past them for its transactions to be recorded as anything but an anonymous Vault call. + ## Version 1.0.0 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 a657415..945deef 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 @@ -42,4 +42,5 @@ object EconomyContext { const val SOURCE_COMMAND = "command" const val SOURCE_TOWNY = "towny" + const val SOURCE_SHOP = "shop" } 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 eb9b5cb..5a9fdbc 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 @@ -25,6 +25,8 @@ object TransactionReason { const val TOWN_DELETED = "money.reason.town-deleted" const val ADMIN_SET = "money.reason.admin-set" const val ADMIN_RESET = "money.reason.admin-reset" + const val SHOP_BUY = "money.reason.shop-buy" + const val SHOP_SELL = "money.reason.shop-sell" /** 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/utils/EconomyUtil.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/EconomyUtil.kt index f18c723..66e6802 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/EconomyUtil.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/EconomyUtil.kt @@ -3,6 +3,7 @@ package net.trilleo.mc.plugins.tritown.utils import net.kyori.adventure.text.Component import net.milkbowl.vault.economy.Economy import net.trilleo.mc.plugins.tritown.economy.CurrencyRegistry +import net.trilleo.mc.plugins.tritown.economy.EconomyContext import net.trilleo.mc.plugins.tritown.economy.EconomyFormat import net.trilleo.mc.plugins.tritown.economy.EconomyService import net.trilleo.mc.plugins.tritown.economy.vault.TriTownVaultEconomy @@ -65,6 +66,20 @@ object EconomyUtil { return economy.depositPlayer(player, amount).transactionSuccess() } + /** + * Takes [amount] from [player], recorded as having come from [source] for [reason]. + * + * The attribution reaches the ledger through the thread the call is made on, + * so it lands on the record without this having to know which provider won. + * Another plugin's economy keeps no such record and simply ignores it. + */ + fun withdraw(player: OfflinePlayer, amount: Double, source: String, reason: String): Boolean = + EconomyContext.with(source, reason) { withdraw(player, amount) } + + /** Gives [amount] to [player], recorded as having come from [source] for [reason]. */ + fun deposit(player: OfflinePlayer, amount: Double, source: String, reason: String): Boolean = + EconomyContext.with(source, reason) { deposit(player, amount) } + /** * Moves [amount] from [from] to [to]. * From c3e231bb797e41ef8b0c4e2287b8736e9f2deac6 Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sat, 19 Sep 2026 10:20:03 +0800 Subject: [PATCH 04/14] Feature: Add admin shops Shops the server itself runs, set up in a menu and opened from a FancyNpcs NPC. An entry can be sold, bought back, or both, priced in currency, items, or a mix, and can carry a stock, a per-player limit, a permission node or a requirement to stand somewhere in Towny. Items are stored through Paper's own byte form, so a custom item goes on the shelf exactly as it was made, and are copied out of the administrator's inventory rather than taken, so nothing can be lost while setting one up. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 33 ++ README.md | 64 ++- .../net/trilleo/mc/plugins/tritown/Main.kt | 16 + .../tritown/commands/shop/ShopCommand.kt | 250 +++++++++++ .../mc/plugins/tritown/config/ShopSettings.kt | 63 +++ .../mc/plugins/tritown/enums/LimitPeriod.kt | 19 + .../mc/plugins/tritown/enums/MatchMode.kt | 19 + .../plugins/tritown/enums/TownyRequirement.kt | 29 ++ .../tritown/guis/shop/ShopConfirmGUI.kt | 141 ++++++ .../plugins/tritown/guis/shop/ShopCostGUI.kt | 159 +++++++ .../tritown/guis/shop/ShopEditorGUI.kt | 162 +++++++ .../plugins/tritown/guis/shop/ShopEntryGUI.kt | 423 ++++++++++++++++++ .../mc/plugins/tritown/guis/shop/ShopGUI.kt | 275 ++++++++++++ .../plugins/tritown/guis/shop/ShopListGUI.kt | 78 ++++ .../plugins/tritown/guis/shop/ShopRender.kt | 116 +++++ .../tritown/guis/shop/ShopSettingsGUI.kt | 199 ++++++++ .../plugins/tritown/guis/shop/ShopStatsGUI.kt | 115 +++++ .../tritown/listeners/ChatPromptListener.kt | 34 ++ .../tritown/listeners/shop/ShopNpcListener.kt | 52 +++ .../mc/plugins/tritown/shops/ItemCodec.kt | 34 ++ .../mc/plugins/tritown/shops/ShopAccess.kt | 72 +++ .../mc/plugins/tritown/shops/ShopCost.kt | 29 ++ .../plugins/tritown/shops/ShopDefinition.kt | 31 ++ .../mc/plugins/tritown/shops/ShopEntry.kt | 67 +++ .../mc/plugins/tritown/shops/ShopGate.kt | 29 ++ .../mc/plugins/tritown/shops/ShopInventory.kt | 118 +++++ .../mc/plugins/tritown/shops/ShopLimit.kt | 37 ++ .../mc/plugins/tritown/shops/ShopLimits.kt | 74 +++ .../mc/plugins/tritown/shops/ShopManager.kt | 231 ++++++++++ .../mc/plugins/tritown/shops/ShopPricing.kt | 41 ++ .../mc/plugins/tritown/shops/ShopQuote.kt | 33 ++ .../mc/plugins/tritown/shops/ShopStats.kt | 39 ++ .../mc/plugins/tritown/shops/ShopStock.kt | 57 +++ .../mc/plugins/tritown/shops/ShopTrade.kt | 234 ++++++++++ .../tritown/shops/npc/FancyNpcsAdapter.kt | 24 + .../tritown/shops/npc/ShopNpcBridge.kt | 41 ++ .../tritown/shops/storage/JsonShopStorage.kt | 113 +++++ .../tritown/shops/storage/ShopSchema.kt | 34 ++ .../tritown/shops/storage/ShopStorage.kt | 27 ++ .../tritown/shops/storage/StoredShop.kt | 51 +++ .../tritown/tasks/shop/ShopSaveTask.kt | 34 ++ .../mc/plugins/tritown/utils/ChatPrompt.kt | 77 ++++ src/main/resources/config.yml | 31 ++ src/main/resources/lang/en_US.yml | 200 +++++++++ src/main/resources/lang/zh_CN.yml | 200 +++++++++ .../mc/plugins/tritown/shops/ShopLimitTest.kt | 69 +++ .../plugins/tritown/shops/ShopPricingTest.kt | 63 +++ .../mc/plugins/tritown/shops/ShopStockTest.kt | 89 ++++ .../shops/storage/JsonShopStorageTest.kt | 130 ++++++ 49 files changed, 4552 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/shop/ShopCommand.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/config/ShopSettings.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/LimitPeriod.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/MatchMode.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/TownyRequirement.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopConfirmGUI.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopCostGUI.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEditorGUI.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEntryGUI.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopListGUI.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopSettingsGUI.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopStatsGUI.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/ChatPromptListener.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/shop/ShopNpcListener.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ItemCodec.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopAccess.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopCost.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopDefinition.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopEntry.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopGate.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopInventory.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimit.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimits.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopManager.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopPricing.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopQuote.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStats.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStock.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopTrade.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/npc/FancyNpcsAdapter.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/npc/ShopNpcBridge.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/JsonShopStorage.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopSchema.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopStorage.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/StoredShop.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/shop/ShopSaveTask.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/ChatPrompt.kt create mode 100644 src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimitTest.kt create mode 100644 src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopPricingTest.kt create mode 100644 src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStockTest.kt create mode 100644 src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/JsonShopStorageTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 005e0d3..9ceb40a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ ## Unreleased +### New Features + +#### Shops + ++ Added admin shops: shops the server itself runs, set up entirely in game and opened by clicking an NPC. + + `/tritown shop create ` makes one and drops you straight into the editor. Add an item by clicking it in your + own inventory or dragging it over the menu — your item stays where it is, and everything about it is kept, so a + renamed, enchanted or otherwise custom item is sold exactly as you made it. + + An entry can be sold, bought back, or both. Left-click buys one, shift-left-click buys as many as you can afford + and carry, right-click sells one, and shift-right-click sells everything you are carrying. + + A price can be money, items, or both at once, and so can a payout — so a shop can sell for currency, barter, or + ask for a fee alongside the materials. + + An entry can have a stock that refills on a timer, a per-player limit that resets daily, weekly or never, or + neither. Both are shown on the item, counting down as players buy. + + Entries and whole shops can be locked behind a permission node or a standing in Towny — being in a town or a + nation, or being a mayor or a king. A locked entry either greys out with the reason or is hidden entirely. + + Town and nation members can be given a discount, set under `shops.discounts`. Discounts do not stack; the best + one applies, and the menu shows the saving. + + A purchase above `shops.confirm-above` asks for confirmation first, so a mis-click cannot empty an account. + + Every purchase and sale is recorded in the transaction log and shows up in `/eco history` as a shop movement, + naming the shop it happened at. + + `/tritown shop stats ` shows what a shop has traded and how much currency it has taken in and paid out, per + entry and in total. ++ Shops open from a [FancyNpcs](https://modrinth.com/plugin/fancynpcs) NPC. Bind one with + `/tritown shop bind ` and clicking it opens the shop. The binding follows the NPC rather than its name, so + renaming it in FancyNpcs does not break anything. FancyNpcs is optional — without it everything else still works, and + `/tritown shop open [player]` still opens a shop from the console or for testing. + ### Fixes #### Misc @@ -14,12 +42,17 @@ + Added [FancyNpcs](https://modrinth.com/plugin/fancynpcs) as an optional dependency. TriTown builds and runs without it; the parts that need it simply stay off. ++ Shops are stored in `plugins/TriTown/shops/shops.json`, written atomically with a backup copy in the same way + balances are. A shop you edit is saved immediately; stock levels and sales figures are written every + `shops.save-interval` seconds. #### Misc + GUIs can now handle a drag through `onDrag`, which cancels the drag by default. The GUI manager also forgets a player who quits with a menu open. + `PlayerData` and `ServerData` gained `getJsonObject`, so a nested object that was written can be read back. ++ Added `ChatPrompt`, which asks a player a question in chat and hands the answer back on the server thread. Menus use + it for anything that has to be typed, such as a price or a permission node. #### Economy diff --git a/README.md b/README.md index e86da12..8f26655 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,14 @@ group them, inside a shared header and footer carrying the server name and addre in `config.yml`; the wording lives in the language files, so everyone reads it in their own language. Players turn it on and off with `/tt scoreboard`, and it takes turns with Towny's own plot HUD rather than fighting it for the screen. +**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 +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. + **English and Simplified Chinese.** Every message, menu and item TriTown shows is translated. By default each player sees whichever of the two their Minecraft client is set to, and everyone else sees English. Set `language` in `config.yml` to `en_US` or `zh_CN` to pick one for the whole server, or edit the files in `plugins/TriTown/lang/` to @@ -41,10 +49,12 @@ built on. | Java | 25+ | | Towny | 0.103.2.7+ | | Vault | 1.7+ | +| FancyNpcs | 2.9+ — optional, for shop NPCs | | Economy plugin | Not required — TriTown provides one | TriTown is an addon: Towny and Vault must both be installed, or TriTown will not load. An economy plugin is optional — install one only if you want it to supply the economy instead of TriTown, and set `economy.provider.mode` accordingly. +FancyNpcs is optional too: without it shops still work, they just cannot be opened by clicking an NPC. ## Building @@ -52,9 +62,9 @@ install one only if you want it to supply the economy instead of TriTown, and se ./gradlew build ``` -The compiled JAR is placed in `build/libs/`. Run `./gradlew copyPlugin` to copy it, along with the matching Towny jar, -into `run/plugins/` for the local test server, or `./gradlew startServer` to copy them and start the server. Before the -first start: +The compiled JAR is placed in `build/libs/`. Run `./gradlew copyPlugin` to copy it, along with the matching Towny and +FancyNpcs jars, into `run/plugins/` for the local test server, or `./gradlew startServer` to copy them and start the +server. Before the first start: - download a Paper 26.2 jar from [papermc.io](https://papermc.io/downloads/paper) into `run/`; - put Vault into `run/plugins/`; @@ -73,12 +83,18 @@ 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 | +| `/tt shop …` | Set up the server's shops (OP only) | `/eco` takes `give`, `take` and `set` (` [currency]`), `reset ` back to the starting balance, `info ` for an account's details, `history [player]` to browse recorded transactions in a menu, and `flush` to write changed accounts to disk immediately. Each action has its own permission, `tritown.economy.admin.`; viewing someone else's history additionally needs `tritown.economy.admin.history.others`. +`/tt shop` takes `list` for every shop, `create [name]` to start one, `delete confirm` to remove one, +`edit ` to change what it offers, `open [player]` to open it for somebody, `bind ` and +`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. + Commands are sub-commands of `/tritown` (alias `/tt`) unless noted. The economy commands are registered as top-level commands as well, which `economy.commands.top-level-aliases` turns off — they are then only reachable as `/tt balance`, `/tt pay` and `/tt baltop`. If another plugin already owns one of those names it keeps it, and TriTown's @@ -115,6 +131,11 @@ version stays available as `/tritown:balance` and so on. | `economy.history.retention-days` | `30` | How long rolled log files are kept; `0` keeps them forever | | `economy.history.roll-size-mb` | `16` | Size at which the transaction log is rolled aside | | `economy.history.time-format` | `yyyy-MM-dd HH:mm` | How timestamps are shown in the history view | +| `shops.enabled` | `true` | Turn shops off entirely | +| `shops.save-interval` | `60` | Seconds between writing stock and sales figures; edits are saved immediately | +| `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` | | `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 | @@ -132,6 +153,40 @@ balance on disk is read. Changing it once accounts exist stops the plugin with a its economy with Vault, and its commands with the server, before either can be changed again. Everything else in the table is applied by `/tt reload`. +### Shops + +A shop is created with `/tt shop create `, which opens its editor. Everything else is done in the menus: + +- **Adding what it sells.** Click an item in your own inventory, or drag it over the menu. Nothing leaves your + inventory — the item is copied, with every property it has, so a renamed and enchanted sword goes on the shelf as + that exact sword. The stack size you click becomes the bundle: click a stack of 16 bread and one purchase is 16 + loaves. +- **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. +- **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. +- **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. + +`shops.discounts` takes money off for players who have earned it. Discounts do not stack: a mayor whose nation also has +a rate pays the better of the two, and the menu shows the old price struck through beside the new one. An individual +entry can opt out. + +Players never type a shop command. Bind an NPC with `/tt shop bind ` and clicking it opens the shop. The +binding is stored against the NPC itself rather than its name, so renaming it in FancyNpcs changes nothing, and an NPC +opens one shop at a time — binding it again moves it. + +The goods a shop sells are created and the money paid for them leaves the economy, so a shop is a sink, a faucet, or +both depending on how you price it. `/tt shop stats ` shows which, per entry and in total. Every trade is recorded +in the transaction log and appears in `/eco history` as a shop movement naming the shop. + +Shops live in `plugins/TriTown/shops/shops.json`, written atomically with a `.bak` copy beside it. A shop you edit is +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. + ### The sidebar Each board under `scoreboard.boards` has a `priority`, a `condition`, and a list of `lines`. A player sees the @@ -220,7 +275,8 @@ Full development guides are in the `docs/` directory: - [DEVELOPER_GUIDE.md](docs/DEVELOPER_GUIDE.md) — How to add commands, listeners, GUIs, tasks, items, and recipes, translate every string, use the configuration and data storage, and build on Towny and the Vault economy. - [UTILITY_GUIDE.md](docs/UTILITY_GUIDE.md) — Reference for the utility helpers (`itemStack` DSL, `Lang`, - `EconomyUtil`, `MessageUtil`, `CountdownUtil`, `TeamUtil`, `TagUtil`, `PDCUtil`, `GameRuleUtil`, `LoreUtil`). + `EconomyUtil`, `MessageUtil`, `ChatPrompt`, `CountdownUtil`, `TeamUtil`, `TagUtil`, `PDCUtil`, `GameRuleUtil`, + `LoreUtil`). - [COMMIT_STRUCTURE.md](docs/COMMIT_STRUCTURE.md) — Commit message conventions. - [RELEASING.md](docs/RELEASING.md) — Writing the changelog and publishing a release. 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 e2b86e0..6090d59 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt @@ -5,6 +5,7 @@ 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.data.PlayerDataManager import net.trilleo.mc.plugins.tritown.data.ServerDataManager import net.trilleo.mc.plugins.tritown.economy.CurrencyRegistry @@ -17,6 +18,8 @@ import net.trilleo.mc.plugins.tritown.economy.vault.VaultRegistration import net.trilleo.mc.plugins.tritown.enums.ProviderMode 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.utils.EconomyUtil import net.trilleo.mc.plugins.tritown.utils.Lang import net.trilleo.mc.plugins.tritown.utils.MessageUtil @@ -83,6 +86,13 @@ class Main : JavaPlugin() { TownyAccountNaming.load() EconomyService.start(EconomySettings.snapshot.baltopIncludeTowns) + // Before the registrars, because the menus and commands they build read + // the shops as soon as they are asked to. + ShopSettings.load(pluginConfig) + if (ShopSettings.snapshot.enabled) { + ShopManager.start(JsonShopStorage(dataFolder, logger), logger) + } + ItemRegistrar.registerAll(this) RecipeRegistrar.registerAll(this) @@ -117,6 +127,10 @@ class Main : JavaPlugin() { ScoreboardSettings.load(pluginConfig, logger) ScoreboardService.reload() + + // 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) } override fun onDisable() { @@ -128,6 +142,8 @@ class Main : JavaPlugin() { TaskRegistrar.unregisterAll() RecipeRegistrar.unregisterAll() + ShopManager.shutdown() + PlayerDataManager.saveAll() ServerDataManager.save() 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 new file mode 100644 index 0000000..fdd71e8 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/shop/ShopCommand.kt @@ -0,0 +1,250 @@ +package net.trilleo.mc.plugins.tritown.commands.shop + +import net.trilleo.mc.plugins.tritown.config.ShopSettings +import net.trilleo.mc.plugins.tritown.guis.shop.ShopEditorGUI +import net.trilleo.mc.plugins.tritown.guis.shop.ShopGUI +import net.trilleo.mc.plugins.tritown.guis.shop.ShopListGUI +import net.trilleo.mc.plugins.tritown.guis.shop.ShopStatsGUI +import net.trilleo.mc.plugins.tritown.registration.PluginCommand +import net.trilleo.mc.plugins.tritown.shops.ShopDefinition +import net.trilleo.mc.plugins.tritown.shops.ShopLimits +import net.trilleo.mc.plugins.tritown.shops.ShopManager +import net.trilleo.mc.plugins.tritown.shops.npc.ShopNpcBridge +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 + +/** + * Sets shops up and puts NPCs behind their counters. + * + * Everything that describes a shop is edited in the menus rather than typed + * here; this covers what a menu cannot do — creating and deleting a shop, + * binding an NPC by name, and opening a shop for somebody from the console. + */ +class ShopCommand : PluginCommand( + name = "shop", + description = "Administer the server's shops", + usage = "/tritown shop ", + permission = PERMISSION, +) { + + override val extraPermissions = ACTIONS.map { permissionFor(it) } + + override fun execute(sender: CommandSender, args: Array): Boolean { + if (!ShopSettings.isLoaded || !ShopSettings.snapshot.enabled) { + sender.sendPrefixed(sender.tr("command.shop.disabled")) + return true + } + + if (!ShopManager.isReady) { + sender.sendPrefixed(sender.tr("command.shop.unavailable")) + return true + } + + val action = args.firstOrNull()?.lowercase() + if (action == null || action !in ACTIONS) { + sendUsage(sender) + return true + } + + if (!sender.hasPermission(permissionFor(action))) { + sender.sendPrefixed(sender.tr("command.shop.no-permission-action", "action" to action)) + return true + } + + when (action) { + "list" -> list(sender) + "create" -> create(sender, args) + "delete" -> delete(sender, args) + "edit" -> edit(sender, args) + "open" -> open(sender, args) + "bind" -> bind(sender, args) + "unbind" -> unbind(sender, args) + "stats" -> stats(sender, args) + } + return true + } + + override fun tabComplete(sender: CommandSender, args: Array): List = when (args.size) { + 1 -> ACTIONS.filter { sender.hasPermission(permissionFor(it)) && it.startsWith(args[0], ignoreCase = true) } + + 2 -> when (args[0].lowercase()) { + "unbind" -> ShopNpcBridge.names().filter { it.startsWith(args[1], ignoreCase = true) } + "create" -> emptyList() + else -> ShopManager.ids().filter { it.startsWith(args[1], ignoreCase = true) } + } + + 3 -> when (args[0].lowercase()) { + "bind" -> ShopNpcBridge.names().filter { it.startsWith(args[2], ignoreCase = true) } + "delete" -> listOf(CONFIRM).filter { it.startsWith(args[2], ignoreCase = true) } + "open" -> Bukkit.getOnlinePlayers().map { it.name }.filter { it.startsWith(args[2], ignoreCase = true) } + else -> emptyList() + } + + else -> emptyList() + } + + // ── Actions ───────────────────────────────────────────────────────── + + private fun list(sender: CommandSender) { + val player = sender as? Player ?: return sender.sendPrefixed(sender.tr("command.shop.players-only")) + ShopListGUI.show(player) + } + + private fun create(sender: CommandSender, args: Array) { + val id = args.getOrNull(1) + if (id == null) { + sender.sendPrefixed(sender.tr("command.shop.usage.create")) + return + } + + val name = args.drop(2).joinToString(" ").ifBlank { id } + val shop = ShopManager.create(id, name) + if (shop == null) { + sender.sendPrefixed(sender.tr("command.shop.create-failed", "id" to id)) + return + } + + sender.sendPrefixed(sender.tr("command.shop.created", "id" to shop.id)) + (sender as? Player)?.let { ShopEditorGUI.show(it, shop) } + } + + /** + * Deletes a shop, and only when the word is typed out. + * + * A shop can hold an afternoon of setting up and there is no undo, so the + * confirmation is deliberately something that cannot be reached by pressing + * tab twice. + */ + private fun delete(sender: CommandSender, args: Array) { + val shop = resolve(sender, args.getOrNull(1)) ?: return + + if (!args.getOrNull(2).equals(CONFIRM, ignoreCase = true)) { + sender.sendPrefixed(sender.tr("command.shop.delete-confirm", "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)) + } + + private fun edit(sender: CommandSender, args: Array) { + val player = sender as? Player ?: return sender.sendPrefixed(sender.tr("command.shop.players-only")) + val shop = resolve(sender, args.getOrNull(1)) ?: return + ShopEditorGUI.show(player, shop) + } + + /** Opens a shop for somebody, so a menu can be checked without an NPC and the console can open one. */ + private fun open(sender: CommandSender, args: Array) { + val shop = resolve(sender, args.getOrNull(1)) ?: return + + val target = args.getOrNull(2)?.let { Bukkit.getPlayerExact(it) } ?: sender as? Player + if (target == null) { + sender.sendPrefixed(sender.tr("command.shop.usage.open")) + return + } + + ShopGUI.show(target, shop) + if (target !== sender) { + sender.sendPrefixed(sender.tr("command.shop.opened", "id" to shop.id, "player" to target.name)) + } + } + + private fun bind(sender: CommandSender, args: Array) { + val shop = resolve(sender, args.getOrNull(1)) ?: return + + val npcName = args.getOrNull(2) + if (npcName == null) { + sender.sendPrefixed(sender.tr("command.shop.usage.bind")) + return + } + + if (!ShopNpcBridge.isAvailable) { + sender.sendPrefixed(sender.tr("command.shop.npcs-unavailable", "plugin" to ShopNpcBridge.PLUGIN_NAME)) + return + } + + val npcId = ShopNpcBridge.idOf(npcName) + if (npcId == null) { + sender.sendPrefixed(sender.tr("command.shop.npc-unknown", "npc" to npcName)) + return + } + + // An NPC opens one shop, so binding it to another moves it rather than leaving it ambiguous. + ShopManager.all().forEach { it.npcIds.remove(npcId) } + shop.npcIds += npcId + ShopManager.save() + + sender.sendPrefixed(sender.tr("command.shop.bound", "npc" to npcName, "id" to shop.id)) + } + + private fun unbind(sender: CommandSender, args: Array) { + val npcName = args.getOrNull(1) + if (npcName == null) { + sender.sendPrefixed(sender.tr("command.shop.usage.unbind")) + return + } + + // Resolved through FancyNpcs when it can be, so an NPC that has since been deleted can still be unbound by id. + val npcId = ShopNpcBridge.idOf(npcName) ?: npcName + val shop = ShopManager.all().firstOrNull { npcId in it.npcIds } + if (shop == null) { + sender.sendPrefixed(sender.tr("command.shop.npc-not-bound", "npc" to npcName)) + return + } + + shop.npcIds.remove(npcId) + ShopManager.save() + sender.sendPrefixed(sender.tr("command.shop.unbound", "npc" to npcName, "id" to shop.id)) + } + + private fun stats(sender: CommandSender, args: Array) { + val player = sender as? Player ?: return sender.sendPrefixed(sender.tr("command.shop.players-only")) + val shop = resolve(sender, args.getOrNull(1)) ?: return + ShopStatsGUI.show(player, shop) + } + + // ── Helpers ───────────────────────────────────────────────────────── + + private fun resolve(sender: CommandSender, id: String?): ShopDefinition? { + if (id == null) { + sendUsage(sender) + return null + } + + val shop = ShopManager.get(id) + if (shop == null) sender.sendPrefixed(sender.tr("command.shop.unknown", "id" to id)) + return shop + } + + private fun sendUsage(sender: CommandSender) { + sender.sendPrefixed(sender.tr("command.shop.usage.header")) + for (action in ACTIONS) { + if (sender.hasPermission(permissionFor(action))) sender.sendPrefixed(usageFor(sender, action)) + } + } + + /** Spelled out rather than built from the action, so every line is a key the translation test can see. */ + private fun usageFor(sender: CommandSender, action: String): String = when (action) { + "list" -> sender.tr("command.shop.usage.list") + "create" -> sender.tr("command.shop.usage.create") + "delete" -> sender.tr("command.shop.usage.delete") + "edit" -> sender.tr("command.shop.usage.edit") + "open" -> sender.tr("command.shop.usage.open") + "bind" -> sender.tr("command.shop.usage.bind") + "unbind" -> sender.tr("command.shop.usage.unbind") + else -> sender.tr("command.shop.usage.stats") + } + + private companion object { + const val PERMISSION = "tritown.shop.admin" + const val CONFIRM = "confirm" + + val ACTIONS = listOf("list", "create", "delete", "edit", "open", "bind", "unbind", "stats") + + fun permissionFor(action: String): String = "$PERMISSION.$action" + } +} 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 new file mode 100644 index 0000000..d0c56f3 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/ShopSettings.kt @@ -0,0 +1,63 @@ +package net.trilleo.mc.plugins.tritown.config + +import net.trilleo.mc.plugins.tritown.enums.TownyRequirement + +/** + * An immutable snapshot of the `shops` block of `config.yml`. + * + * Built whole and swapped in on a reload, the same way [EconomySettings] is, so + * a shop being used while the configuration is re-read cannot see half of it. + * + * @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 discounts how much each standing in Towny takes off a price, as a fraction between 0 and 1 + */ +data class ShopSettings( + val enabled: Boolean, + val saveIntervalSeconds: Long, + val confirmAbove: Double, + val sellRate: Double, + val discounts: Map, +) { + + companion object { + + /** 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, + "has-nation" to TownyRequirement.HAS_NATION, + "is-mayor" to TownyRequirement.IS_MAYOR, + "is-king" to TownyRequirement.IS_KING, + ) + + @Volatile + private var current: ShopSettings? = null + + /** The settings in force. */ + val snapshot: ShopSettings + get() = current ?: error("Shop settings have not been loaded yet") + + /** Whether [load] has run. */ + val isLoaded: Boolean + get() = current != null + + /** Reads the `shops` block from [config] and makes it the current snapshot. */ + fun load(config: PluginConfig): ShopSettings = read(config).also { current = it } + + 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) + if (rate <= 0.0) null else requirement to rate + }.toMap() + + return ShopSettings( + enabled = config.getBoolean("shops.enabled", true), + 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), + discounts = discounts, + ) + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/LimitPeriod.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/LimitPeriod.kt new file mode 100644 index 0000000..0f00c1d --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/LimitPeriod.kt @@ -0,0 +1,19 @@ +package net.trilleo.mc.plugins.tritown.enums + +/** + * How often a per-player purchase limit starts over. + * + * Windows are counted from the epoch rather than from the first purchase, so + * every player's limit rolls over at the same moment. + */ +enum class LimitPeriod { + + /** The limit is a lifetime total and never resets. */ + NONE, + + /** The limit resets every 24 hours. */ + DAILY, + + /** The limit resets every 7 days. */ + WEEKLY, +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/MatchMode.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/MatchMode.kt new file mode 100644 index 0000000..75f5d6b --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/MatchMode.kt @@ -0,0 +1,19 @@ +package net.trilleo.mc.plugins.tritown.enums + +/** + * How closely a player's stack has to resemble a shop entry before the shop + * will buy it back, or accept it as part of a price. + */ +enum class MatchMode { + + /** + * Every property must match: name, lore, enchantments, custom model data + * and any custom data a plugin attached. This is the default, so a shop + * that sells a custom item does not buy back a plain one of the same + * material. + */ + EXACT, + + /** Only the material must match, so any plain stack of it is accepted. */ + MATERIAL, +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/TownyRequirement.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/TownyRequirement.kt new file mode 100644 index 0000000..670e43a --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/TownyRequirement.kt @@ -0,0 +1,29 @@ +package net.trilleo.mc.plugins.tritown.enums + +/** + * What a player's standing in Towny has to be before a shop or one of its + * entries is open to them. + * + * Checked against [com.palmergames.bukkit.towny.TownyAPI] at the moment of the + * click, never cached — Towny stays the source of truth. + */ +enum class TownyRequirement { + + /** No requirement at all. */ + NONE, + + /** The player must belong to a town. */ + HAS_TOWN, + + /** The player must not belong to a town, for a newcomers' shop. */ + NO_TOWN, + + /** The player's town must belong to a nation. */ + HAS_NATION, + + /** The player must be the mayor of their town. */ + IS_MAYOR, + + /** The player must be the king of their nation. */ + IS_KING, +} 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 new file mode 100644 index 0000000..88d4f6e --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopConfirmGUI.kt @@ -0,0 +1,141 @@ +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.enums.FillMode +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PluginGUI +import net.trilleo.mc.plugins.tritown.shops.ShopDefinition +import net.trilleo.mc.plugins.tritown.shops.ShopEntry +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 +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.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * A last look at an expensive purchase before it goes through. + * + * Only shown above `shops.confirm-above`, so an ordinary purchase is still a + * single click. The price is re-quoted when the player accepts rather than + * being carried over from here, so a discount that lapsed while the menu sat + * open cannot be spent. + */ +class ShopConfirmGUI : PluginGUI( + id = ID, + titleKey = "gui.shop-confirm.title", + rows = 3, + fillMode = FillMode.DARK, +) { + + private data class Pending(val shopId: String, val entryId: String, val bundles: 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) + 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 lines = buildList { + add(player.tr("gui.shop-confirm.amount", "amount" to entry.bundleSize * bundles)) + 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)) } + } + + inventory.setItem(SLOT_GOODS, ShopRender.withLore(entry.displayStack(), lines)) + inventory.setItem( + SLOT_ACCEPT, + button(player, Material.LIME_CONCRETE, "gui.shop-confirm.accept", "gui.shop-confirm.accept-lore"), + ) + inventory.setItem( + SLOT_CANCEL, + button(player, Material.RED_CONCRETE, "gui.shop-confirm.cancel", "gui.shop-confirm.cancel-lore"), + ) + } + + override fun onClick(event: InventoryClickEvent) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + when (event.rawSlot) { + SLOT_ACCEPT -> accept(player) + SLOT_CANCEL -> back(player) + else -> return + } + } + + override fun onClose(event: InventoryCloseEvent) { + pending.remove((event.player as? Player)?.uniqueId ?: return) + } + + 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())) + ) + } + + back(player) + } + + /** Returns to the shop the purchase came from, so a cancel does not dump the player back into the world. */ + private fun back(player: Player) { + val shop = pending[player.uniqueId]?.let { ShopManager.get(it.shopId) } + if (shop == null) { + player.closeInventory() + return + } + ShopRender.navigate { ShopGUI.show(player, shop) } + } + + private fun resolve(player: Player): Triple? { + 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) + } + + 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))) } + } + + companion object { + const val ID = "shop-confirm" + + 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/ShopCostGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopCostGUI.kt new file mode 100644 index 0000000..1ba35e2 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopCostGUI.kt @@ -0,0 +1,159 @@ +package net.trilleo.mc.plugins.tritown.guis.shop + +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PluginGUI +import net.trilleo.mc.plugins.tritown.shops.ShopCost +import net.trilleo.mc.plugins.tritown.shops.ShopDefinition +import net.trilleo.mc.plugins.tritown.shops.ShopEntry +import net.trilleo.mc.plugins.tritown.shops.ShopManager +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 +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.event.inventory.InventoryDragEvent +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * The items on one side of an entry — what a purchase costs in goods, or what a + * sale pays out in them. + * + * Items are added by clicking a stack in your own inventory, exactly as in the + * shop editor, and the stack's own size becomes the quantity: clicking a stack + * of 8 iron makes the price 8 iron. Nothing leaves the administrator's + * inventory at any point. + */ +class ShopCostGUI : PluginGUI( + id = ID, + titleKey = "gui.shop-cost.title", + rows = 6, + fillMode = FillMode.NONE, +) { + + private data class Target(val shopId: String, val entryId: String, val buying: Boolean) + + private val editing = ConcurrentHashMap() + + /** Opens the item side of [entry]'s price when [buying], or of its payout when not. */ + fun open(player: Player, shop: ShopDefinition, entry: ShopEntry, buying: Boolean) { + editing[player.uniqueId] = Target(shop.id, entry.id, buying) + GUIManager.open(player, ID) + } + + override fun setup(player: Player, inventory: Inventory) { + val (_, entry, buying) = resolve(player) ?: return + + inventory.clear() + cost(entry, buying).items.forEachIndexed { index, item -> + if (index < CONTENT_SLOTS) inventory.setItem(index, describe(player, item)) + } + + inventory.setItem( + SLOT_BACK, + button(player, Material.ARROW, "gui.shop-cost.back", "gui.shop-cost.back-lore"), + ) + inventory.setItem( + SLOT_HINT, + button(player, Material.PAPER, "gui.shop-cost.add", "gui.shop-cost.add-lore"), + ) + } + + override fun onClick(event: InventoryClickEvent) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + val (shop, entry, buying) = resolve(player) ?: return + + if (event.clickedInventory === player.inventory) { + event.currentItem?.let { add(player, entry, buying, it) } + setup(player, event.view.topInventory) + return + } + + when (val slot = event.rawSlot) { + SLOT_BACK -> ShopRender.navigate { ShopEntryGUI.show(player, shop, entry) } + + in 0 until CONTENT_SLOTS -> { + val items = cost(entry, buying).items + if (slot >= items.size) return + apply(entry, buying, items.filterIndexed { index, _ -> index != slot }) + ShopManager.save() + setup(player, event.inventory) + } + + else -> return + } + } + + override fun onDrag(event: InventoryDragEvent) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + val (_, entry, buying) = resolve(player) ?: return + add(player, entry, buying, event.oldCursor) + setup(player, event.view.topInventory) + } + + override fun onClose(event: InventoryCloseEvent) { + editing.remove((event.player as? Player)?.uniqueId ?: return) + } + + private fun add(player: Player, entry: ShopEntry, buying: Boolean, stack: ItemStack) { + if (stack.type.isAir) return + + val items = cost(entry, buying).items + if (items.size >= CONTENT_SLOTS) return + + apply(entry, buying, items + stack.clone()) + ShopManager.save() + } + + private fun apply(entry: ShopEntry, buying: Boolean, items: List) { + if (buying) { + entry.buy = (entry.buy ?: ShopCost.FREE).copy(items = items) + } else { + entry.sell = (entry.sell ?: ShopCost.FREE).copy(items = items) + } + } + + private fun cost(entry: ShopEntry, buying: Boolean): ShopCost = + (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"))) + + 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): Triple? { + val target = editing[player.uniqueId] ?: return null + val shop = ShopManager.get(target.shopId) ?: return null + val entry = shop.entry(target.entryId) ?: return null + return Triple(shop, entry, target.buying) + } + + companion object { + const val ID = "shop-cost" + + /** Everything but the last row, which carries the way back out. */ + private const val CONTENT_SLOTS = 45 + private const val SLOT_BACK = 45 + private const val SLOT_HINT = 49 + + /** Opens the item side of a price through the registered instance. */ + fun show(player: Player, shop: ShopDefinition, entry: ShopEntry, buying: Boolean): Boolean { + val gui = GUIManager.getGUI(ID) as? ShopCostGUI ?: return false + gui.open(player, shop, entry, buying) + return true + } + } +} 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 new file mode 100644 index 0000000..955bfb6 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEditorGUI.kt @@ -0,0 +1,162 @@ +package net.trilleo.mc.plugins.tritown.guis.shop + +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PagedPluginGUI +import net.trilleo.mc.plugins.tritown.shops.ShopCost +import net.trilleo.mc.plugins.tritown.shops.ShopDefinition +import net.trilleo.mc.plugins.tritown.shops.ShopEntry +import net.trilleo.mc.plugins.tritown.shops.ShopManager +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.event.inventory.ClickType +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.event.inventory.InventoryDragEvent +import org.bukkit.inventory.ItemStack +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * What one shop sells, and where entries are added and removed. + * + * An entry is added by clicking a stack in your own inventory, or dragging one + * over the menu. Nothing actually moves: the stack is copied, custom data and + * all, and stays where it was. An editor that took the item would lose it to a + * crash or a mistimed close, and an administrator setting up a shop is usually + * holding the only copy of whatever they are adding. + */ +class ShopEditorGUI : PagedPluginGUI( + id = ID, + titleKey = "gui.shop-editor.title", + rows = 6, + fillMode = FillMode.NONE, +) { + + private val editing = ConcurrentHashMap() + + /** Opens the editor for [shop]. */ + fun open(player: Player, shop: ShopDefinition) { + editing[player.uniqueId] = shop.id + GUIManager.open(player, ID) + } + + override fun getItems(player: Player): List { + val shop = shopOf(player) ?: return emptyList() + return shop.entries.map { entry -> icon(player, entry) } + settingsButton(player, shop) + hint(player) + } + + /** + * The paged base class drops clicks outside its own inventory, but one in + * the administrator's own inventory is how an entry is added, so it is taken + * before the rest is handed on. + */ + override fun onClick(event: InventoryClickEvent) { + val player = event.whoClicked as? Player + if (player != null && event.clickedInventory === player.inventory) { + event.isCancelled = true + val shop = shopOf(player) ?: return + event.currentItem?.let { add(player, shop, it) } + return + } + + super.onClick(event) + } + + override fun onContentClick(event: InventoryClickEvent, page: Int) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + val shop = shopOf(player) ?: return + + val index = page * CONTENT_SLOTS + event.rawSlot + when (index) { + shop.entries.size -> ShopRender.navigate { ShopSettingsGUI.show(player, shop) } + in shop.entries.indices -> click(event.click, player, shop, shop.entries[index]) + else -> return + } + } + + override fun onDrag(event: InventoryDragEvent) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + val shop = shopOf(player) ?: return + add(player, shop, event.oldCursor) + } + + override fun onClose(event: InventoryCloseEvent) { + super.onClose(event) + editing.remove((event.player as? Player)?.uniqueId ?: return) + } + + private fun click(click: ClickType, player: Player, shop: ShopDefinition, entry: ShopEntry) { + if (click == ClickType.SHIFT_LEFT) { + shop.entries.remove(entry) + ShopManager.save() + player.sendPrefixed(player.tr("shop.editor.entry-removed")) + ShopRender.navigate { show(player, shop) } + return + } + + ShopRender.navigate { ShopEntryGUI.show(player, shop, entry) } + } + + /** Adds [stack] as a new entry, priced at nothing until the administrator sets a price. */ + private fun add(player: Player, shop: ShopDefinition, stack: ItemStack) { + if (stack.type.isAir) return + + val entry = ShopEntry(item = stack.clone(), buy = ShopCost.FREE) + shop.entries += entry + ShopManager.save() + + player.sendPrefixed(player.tr("shop.editor.entry-added", "item" to ShopRender.itemName(entry.item))) + ShopRender.navigate { ShopEntryGUI.show(player, shop, entry) } + } + + private fun icon(player: Player, entry: ShopEntry): ItemStack { + val lore = buildList { + if (entry.isBuyable) addAll(buyLines(player, entry)) else add(player.tr("gui.shop-editor.not-buyable")) + if (entry.isSellable) addAll(sellLines(player, entry)) else add(player.tr("gui.shop-editor.not-sellable")) + add(player.tr("gui.shop-editor.click-entry")) + add(player.tr("gui.shop-editor.shift-click-entry")) + } + + return ShopRender.withLore(entry.displayStack(), lore) + } + + private fun buyLines(player: Player, entry: ShopEntry): List = + listOf(player.tr("gui.shop-editor.buy")) + ShopRender.costLines(player, entry.buy) + + private fun sellLines(player: Player, entry: ShopEntry): List = + listOf(player.tr("gui.shop-editor.sell")) + ShopRender.costLines(player, entry.sell) + + private fun settingsButton(player: Player, shop: ShopDefinition): ItemStack = itemStack(Material.COMPARATOR) { + name(player.tr("gui.shop-editor.settings")) + meta { lore(LoreUtil.wrapLore(player.tr("gui.shop-editor.settings-lore", "id" to shop.id))) } + } + + private fun hint(player: Player): ItemStack = itemStack(Material.PAPER) { + name(player.tr("gui.shop-editor.add")) + meta { lore(LoreUtil.wrapLore(player.tr("gui.shop-editor.add-lore"))) } + } + + private fun shopOf(player: Player): ShopDefinition? = editing[player.uniqueId]?.let(ShopManager::get) + + companion object { + const val ID = "shop-editor" + + private const val CONTENT_SLOTS = 45 + + /** Opens the editor for [shop] through the registered instance. */ + fun show(player: Player, shop: ShopDefinition): Boolean { + val gui = GUIManager.getGUI(ID) as? ShopEditorGUI ?: return false + gui.open(player, shop) + return true + } + } +} 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 new file mode 100644 index 0000000..98d6405 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEntryGUI.kt @@ -0,0 +1,423 @@ +package net.trilleo.mc.plugins.tritown.guis.shop + +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.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PluginGUI +import net.trilleo.mc.plugins.tritown.shops.ShopCost +import net.trilleo.mc.plugins.tritown.shops.ShopDefinition +import net.trilleo.mc.plugins.tritown.shops.ShopEntry +import net.trilleo.mc.plugins.tritown.shops.ShopLimit +import net.trilleo.mc.plugins.tritown.shops.ShopManager +import net.trilleo.mc.plugins.tritown.shops.ShopPricing +import net.trilleo.mc.plugins.tritown.shops.ShopStock +import net.trilleo.mc.plugins.tritown.utils.ChatPrompt +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.event.inventory.ClickType +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.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * Everything about one entry: what it costs, what it pays, who may have it and + * how much of it. + * + * Anything that is a number or a name is typed in chat rather than clicked up + * one at a time — a price of 12500 is not something to reach by clicking. A + * left click asks for the value, a right click clears it, and settings with only + * a few states cycle on a click instead. + */ +class ShopEntryGUI : PluginGUI( + id = ID, + titleKey = "gui.shop-entry.title", + rows = 6, + fillMode = FillMode.DARK, +) { + + private data class Target(val shopId: String, val entryId: String) + + private val editing = ConcurrentHashMap() + + /** Opens the editor for [entry] of [shop]. */ + fun open(player: Player, shop: ShopDefinition, entry: ShopEntry) { + editing[player.uniqueId] = Target(shop.id, entry.id) + GUIManager.open(player, ID) + } + + override fun setup(player: Player, inventory: Inventory) { + val (_, entry) = resolve(player) ?: return + + inventory.setItem(SLOT_GOODS, goods(player, entry)) + + inventory.setItem(SLOT_BUY_TOGGLE, toggle(player, entry.isBuyable, "gui.shop-entry.buyable")) + inventory.setItem(SLOT_BUY_MONEY, money(player, entry.buy, "gui.shop-entry.buy-price")) + inventory.setItem(SLOT_BUY_ITEMS, items(player, entry.buy, "gui.shop-entry.buy-items")) + + inventory.setItem(SLOT_SELL_TOGGLE, toggle(player, entry.isSellable, "gui.shop-entry.sellable")) + 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_STOCK, stock(player, entry)) + inventory.setItem(SLOT_PERMISSION, permission(player, entry)) + inventory.setItem(SLOT_TOWNY, towny(player, entry)) + inventory.setItem(SLOT_HIDDEN, toggle(player, entry.gate.hideWhenLocked, "gui.shop-entry.hidden")) + inventory.setItem(SLOT_DISCOUNT, toggle(player, entry.discountable, "gui.shop-entry.discountable")) + inventory.setItem(SLOT_MATCH, match(player, entry)) + + inventory.setItem(SLOT_BACK, button(player, Material.ARROW, "gui.shop-entry.back", "gui.shop-entry.back-lore")) + } + + override fun onClick(event: InventoryClickEvent) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + val (shop, entry) = resolve(player) ?: return + val clear = event.click == ClickType.RIGHT || event.click == ClickType.SHIFT_RIGHT + + when (event.rawSlot) { + SLOT_BUY_TOGGLE -> entry.buy = if (entry.isBuyable) null else ShopCost.FREE + SLOT_SELL_TOGGLE -> entry.sell = if (entry.isSellable) null else suggestedSell(entry) + SLOT_BUY_MONEY -> return askMoney(player, shop, entry, buying = true, clear = clear) + SLOT_SELL_MONEY -> return askMoney(player, shop, entry, buying = false, clear = clear) + 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_LIMIT -> return editLimit(player, shop, entry, 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)) + SLOT_HIDDEN -> entry.gate = entry.gate.copy(hideWhenLocked = !entry.gate.hideWhenLocked) + SLOT_DISCOUNT -> entry.discountable = !entry.discountable + SLOT_MATCH -> entry.matchMode = + if (entry.matchMode == MatchMode.EXACT) MatchMode.MATERIAL else MatchMode.EXACT + SLOT_BACK -> return ShopRender.navigate { ShopEditorGUI.show(player, shop) } + else -> return + } + + ShopManager.save() + setup(player, event.inventory) + } + + override fun onClose(event: InventoryCloseEvent) { + editing.remove((event.player as? Player)?.uniqueId ?: return) + } + + // ── Editing ───────────────────────────────────────────────────────── + + private fun askMoney(player: Player, shop: ShopDefinition, entry: ShopEntry, buying: Boolean, clear: Boolean) { + if (clear) { + applyMoney(entry, buying, 0.0) + ShopManager.save() + ShopRender.navigate { show(player, shop, entry) } + return + } + + prompt(player, shop, entry, player.tr("gui.shop-entry.prompt-price")) { input -> + val amount = input.toDoubleOrNull() + if (amount == null || amount < 0.0) { + player.sendPrefixed(player.tr("common.error", "message" to player.tr("common.invalid-amount"))) + } else { + applyMoney(entry, buying, amount) + ShopManager.save() + } + } + } + + private fun applyMoney(entry: ShopEntry, buying: Boolean, amount: Double) { + if (buying) { + entry.buy = (entry.buy ?: ShopCost.FREE).copy(money = amount) + } else { + entry.sell = (entry.sell ?: ShopCost.FREE).copy(money = amount) + } + } + + private fun askPermission(player: Player, shop: ShopDefinition, entry: ShopEntry, clear: Boolean) { + if (clear) { + entry.gate = entry.gate.copy(permission = null) + ShopManager.save() + ShopRender.navigate { show(player, shop, entry) } + return + } + + prompt(player, shop, entry, player.tr("gui.shop-entry.prompt-permission")) { input -> + entry.gate = entry.gate.copy(permission = input.ifBlank { null }) + ShopManager.save() + } + } + + /** 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) { + when (click) { + ClickType.RIGHT, ClickType.SHIFT_RIGHT -> { + entry.limit = 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)) + 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) + } + ShopManager.save() + } + } + } + } + + /** Asks for `max` and `restock seconds` on one line, because they only make sense together. */ + private fun editStock(player: Player, shop: ShopDefinition, entry: ShopEntry, clear: Boolean) { + if (clear) { + entry.stock = null + ShopManager.save() + ShopRender.navigate { show(player, shop, entry) } + return + } + + prompt(player, shop, entry, player.tr("gui.shop-entry.prompt-stock")) { input -> + val parts = input.split(' ', limit = 2) + val max = parts.getOrNull(0)?.toIntOrNull() + val seconds = parts.getOrNull(1)?.toLongOrNull() ?: 0L + + if (max == null || max < 0 || seconds < 0L) { + player.sendPrefixed(player.tr("common.error", "message" to player.tr("common.invalid-amount"))) + } else { + entry.stock = if (max == 0) null else ShopStock(max, seconds, max, System.currentTimeMillis()) + ShopManager.save() + } + } + } + + /** + * Closes the menu, asks [question], and reopens on the answer. + * + * Reopened either way, so a mistyped price does not leave the administrator + * standing in the world wondering where the editor went. + */ + private fun prompt( + player: Player, + shop: ShopDefinition, + entry: ShopEntry, + question: String, + onInput: (String) -> Unit, + ) { + player.closeInventory() + ChatPrompt.ask(player, question) { input -> + onInput(input) + show(player, shop, entry) + } + } + + private fun suggestedSell(entry: ShopEntry): ShopCost { + val rate = if (ShopSettings.isLoaded) ShopSettings.snapshot.sellRate else 0.5 + val buy = entry.buy?.money ?: 0.0 + return ShopCost(ShopPricing.round(buy * rate, MONEY_SCALE)) + } + + private inline fun > cycle(value: T, click: ClickType): T { + val values = enumValues() + val step = if (click == ClickType.RIGHT || click == ClickType.SHIFT_RIGHT) -1 else 1 + return values[(value.ordinal + step + values.size) % values.size] + } + + // ── Drawing ───────────────────────────────────────────────────────── + + private fun goods(player: Player, entry: ShopEntry): ItemStack = ShopRender.withLore( + entry.displayStack(), + listOf(player.tr("gui.shop-entry.bundle", "amount" to entry.bundleSize)), + ) + + private fun toggle(player: Player, on: Boolean, nameKey: String): ItemStack = + itemStack(if (on) Material.LIME_DYE else Material.GRAY_DYE) { + name(player.tr(nameKey)) + meta { + lore( + LoreUtil.wrapLore( + player.tr("gui.shop-entry.state", "state" to player.tr(if (on) "common.on" else "common.off")) + + "" + player.tr("gui.shop-entry.click-toggle") + ) + ) + } + } + + private fun money(player: Player, cost: ShopCost?, nameKey: String): ItemStack = itemStack(Material.GOLD_INGOT) { + name(player.tr(nameKey)) + meta { + lore( + LoreUtil.wrapLore( + player.tr("gui.shop-entry.amount", "amount" to ShopRender.money(cost?.money ?: 0.0)) + + "" + player.tr("gui.shop-entry.click-set") + + "" + player.tr("gui.shop-entry.right-click-clear") + ) + ) + } + } + + private fun items(player: Player, cost: ShopCost?, nameKey: String): ItemStack = itemStack(Material.CHEST) { + name(player.tr(nameKey)) + meta { + lore( + LoreUtil.wrapLore( + player.tr("gui.shop-entry.item-count", "amount" to (cost?.items?.size ?: 0)) + + "" + player.tr("gui.shop-entry.click-open") + ) + ) + } + } + + private fun limit(player: Player, entry: ShopEntry): ItemStack = itemStack(Material.CLOCK) { + name(player.tr("gui.shop-entry.limit")) + meta { + val current = entry.limit + val value = if (current == null) { + player.tr("common.none") + } else { + player.tr( + "gui.shop-entry.limit-value", + "amount" to current.amount, + "period" to ShopRender.periodName(player, current.period), + ) + } + + lore( + LoreUtil.wrapLore( + player.tr("gui.shop-entry.amount", "amount" to value) + + "" + player.tr("gui.shop-entry.click-set") + + "" + player.tr("gui.shop-entry.middle-click-period") + + "" + player.tr("gui.shop-entry.right-click-clear") + ) + ) + } + } + + private fun stock(player: Player, entry: ShopEntry): ItemStack = itemStack(Material.BARREL) { + name(player.tr("gui.shop-entry.stock")) + meta { + val current = entry.stock + val value = if (current == null) { + player.tr("gui.shop-entry.stock-unlimited") + } else { + player.tr( + "gui.shop-entry.stock-value", + "amount" to current.remaining, + "max" to current.max, + "seconds" to current.restockSeconds, + ) + } + + lore( + LoreUtil.wrapLore( + player.tr("gui.shop-entry.amount", "amount" to value) + + "" + player.tr("gui.shop-entry.click-set") + + "" + player.tr("gui.shop-entry.right-click-clear") + ) + ) + } + } + + private fun permission(player: Player, entry: ShopEntry): ItemStack = itemStack(Material.NAME_TAG) { + name(player.tr("gui.shop-entry.permission")) + meta { + lore( + LoreUtil.wrapLore( + player.tr( + "gui.shop-entry.amount", + "amount" to (entry.gate.permission ?: player.tr("common.none")), + ) + + "" + player.tr("gui.shop-entry.click-set") + + "" + player.tr("gui.shop-entry.right-click-clear") + ) + ) + } + } + + private fun towny(player: Player, entry: ShopEntry): ItemStack = itemStack(Material.OAK_SIGN) { + name(player.tr("gui.shop-entry.towny")) + meta { + lore( + LoreUtil.wrapLore( + player.tr( + "gui.shop-entry.amount", + "amount" to ShopRender.requirementName(player, entry.gate.towny), + ) + "" + player.tr("gui.shop-entry.click-cycle") + ) + ) + } + } + + private fun match(player: Player, entry: ShopEntry): ItemStack = itemStack(Material.COMPARATOR) { + name(player.tr("gui.shop-entry.match")) + meta { + lore( + LoreUtil.wrapLore( + player.tr("gui.shop-entry.amount", "amount" to ShopRender.matchName(player, entry.matchMode)) + + "" + player.tr("gui.shop-entry.match-lore") + + "" + player.tr("gui.shop-entry.click-toggle") + ) + ) + } + } + + 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 = editing[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-entry" + + /** Money is only ever shown here, so the currency's own scale is not worth reaching for. */ + private const val MONEY_SCALE = 2 + + private const val SLOT_GOODS = 4 + private const val SLOT_BUY_TOGGLE = 19 + private const val SLOT_BUY_MONEY = 20 + private const val SLOT_BUY_ITEMS = 21 + 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_TOWNY = 38 + private const val SLOT_HIDDEN = 40 + private const val SLOT_DISCOUNT = 42 + private const val SLOT_MATCH = 44 + private const val SLOT_BACK = 49 + + /** Opens the entry editor through the registered instance. */ + fun show(player: Player, shop: ShopDefinition, entry: ShopEntry): Boolean { + val gui = GUIManager.getGUI(ID) as? ShopEntryGUI ?: return false + gui.open(player, shop, entry) + return true + } + } +} 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 new file mode 100644 index 0000000..a9415a4 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt @@ -0,0 +1,275 @@ +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.TownyRequirement +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PagedPluginGUI +import net.trilleo.mc.plugins.tritown.shops.ShopAccess +import net.trilleo.mc.plugins.tritown.shops.ShopDefinition +import net.trilleo.mc.plugins.tritown.shops.ShopEntry +import net.trilleo.mc.plugins.tritown.shops.ShopLimits +import net.trilleo.mc.plugins.tritown.shops.ShopManager +import net.trilleo.mc.plugins.tritown.shops.ShopTrade +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.sendPrefixed +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Material +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.ItemStack +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * What a player sees when they click a shopkeeper. + * + * GUIs are singletons, so which shop is open and what was drawn for it are held + * per viewer. The drawing is done once when the menu opens rather than in + * [getItems], which the paging code calls on every render and again for every + * page count. + * + * A trade redraws only the entry that was traded. Rebuilding the whole menu + * would throw the viewer back to the first page, and nothing else on the page + * changed. + */ +class ShopGUI : PagedPluginGUI( + id = ID, + titleKey = "gui.shop.title", + rows = 6, + fillMode = FillMode.NONE, +) { + + /** + * @param entryIds the entry drawn in each content slot, in order, with `null` where nothing is + */ + private data class View( + val shopId: String, + val entryIds: MutableList, + val items: MutableList, + ) + + private val views = ConcurrentHashMap() + + /** Opens [shop] for [viewer], drawing only what they are allowed to see. */ + fun open(viewer: Player, shop: ShopDefinition) { + views[viewer.uniqueId] = render(viewer, shop) + GUIManager.open(viewer, ID) + } + + override fun title(player: Player): Component { + val shop = shopOf(player) + val name = shop?.displayName ?: player.tr("gui.shop.unknown") + return ComponentUtil.parse(player.tr("gui.shop.title", "name" to name)) + } + + override fun getItems(player: Player): List = views[player.uniqueId]?.items ?: emptyList() + + override fun onContentClick(event: InventoryClickEvent, page: Int) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + val view = views[player.uniqueId] ?: return + val shop = ShopManager.get(view.shopId) ?: return + + val index = page * CONTENT_SLOTS + event.rawSlot + 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.SHIFT_RIGHT -> sellMax(player, shop, entry) + else -> return + } ?: return + + when (result) { + is ShopTrade.Result.Success -> { + 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)) + } + } + } + + override fun onClose(event: InventoryCloseEvent) { + super.onClose(event) + views.remove((event.player as? Player)?.uniqueId ?: return) + } + + // ── Trading ───────────────────────────────────────────────────────── + + /** + * 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 ShopTrade.Result.Failure("shop.error.cannot-afford", listOf("price" to unitPrice(player, 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 + } + + return ShopTrade.buy(player, shop, entry, bundles) + } + + 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) + } + + 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, + index: Int, + player: Player, + shop: ShopDefinition, + entry: ShopEntry, + ) { + val refreshed = draw(player, shop, entry, ShopAccess.standing(player)) + view.items[index] = refreshed + event.inventory.setItem(event.rawSlot, refreshed) + } + + // ── Drawing ───────────────────────────────────────────────────────── + + private fun render(viewer: Player, shop: ShopDefinition): View { + val standing = ShopAccess.standing(viewer) + val entryIds = mutableListOf() + val items = mutableListOf() + + for (entry in shop.entries) { + val refusal = ShopAccess.refusalKey(viewer, entry.gate, standing) + if (refusal != null && entry.gate.hideWhenLocked) continue + + entryIds += entry.id + items += draw(viewer, shop, entry, standing) + } + + if (items.isEmpty()) { + entryIds += null + items += itemStack(Material.BARRIER) { + name(viewer.tr("gui.shop.empty")) + meta { lore(LoreUtil.wrapLore(viewer.tr("gui.shop.empty-lore"))) } + } + } + + return View(shop.id, entryIds, items) + } + + 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) } + } + + 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) } + } + } + + entry.stock?.let { stock -> + lines += viewer.tr( + "gui.shop.stock", + "amount" to stock.available(System.currentTimeMillis()), + "max" to stock.max, + ) + } + + entry.limit?.let { limit -> + lines += viewer.tr( + "gui.shop.limit", + "amount" to (ShopLimits.remaining(viewer, shop, entry) ?: limit.amount), + "period" to ShopRender.periodName(viewer, limit.period), + ) + } + + 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 ShopRender.withLore(entry.displayStack(), lines) + } + + 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" + + /** Content slots on one page: everything but the navigation row. */ + private const val CONTENT_SLOTS = 45 + + /** Opens [shop] for [viewer] through the registered instance. */ + fun show(viewer: Player, shop: ShopDefinition): Boolean { + val gui = GUIManager.getGUI(ID) as? ShopGUI ?: return false + gui.open(viewer, shop) + return true + } + } +} 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 new file mode 100644 index 0000000..cdf1cdc --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopListGUI.kt @@ -0,0 +1,78 @@ +package net.trilleo.mc.plugins.tritown.guis.shop + +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.registration.GUIManager +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.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 +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.inventory.ItemStack + +/** + * Every shop on the server, for an administrator to pick one to work on. + * + * Read straight out of [ShopManager] on each render, because the list is short + * and an administrator who has just created a shop expects to see it. + */ +class ShopListGUI : PagedPluginGUI( + id = ID, + titleKey = "gui.shop-list.title", + rows = 6, + fillMode = FillMode.NONE, +) { + + override fun getItems(player: Player): List { + val shops = ShopManager.all() + if (shops.isEmpty()) { + return listOf( + itemStack(Material.BARRIER) { + name(player.tr("gui.shop-list.empty")) + meta { lore(LoreUtil.wrapLore(player.tr("gui.shop-list.empty-lore"))) } + } + ) + } + + return shops.map { shop -> icon(player, shop) } + } + + override fun onContentClick(event: InventoryClickEvent, page: Int) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + val shop = ShopManager.all().getOrNull(page * CONTENT_SLOTS + event.rawSlot) ?: return + + ShopRender.navigate { + if (event.click == ClickType.SHIFT_LEFT) ShopGUI.show(player, shop) else ShopEditorGUI.show(player, shop) + } + } + + private fun icon(player: Player, shop: ShopDefinition): ItemStack { + 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)) + add(player.tr("gui.shop-list.click-edit")) + add(player.tr("gui.shop-list.click-preview")) + } + + return itemStack(Material.CHEST) { + name(shop.displayName) + meta { lore(LoreUtil.wrapLore(lore.joinToString(""))) } + } + } + + companion object { + const val ID = "shop-list" + + private const val CONTENT_SLOTS = 45 + + /** Opens the list for [player] through the registered instance. */ + fun show(player: Player): Boolean = GUIManager.open(player, ID) + } +} 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 new file mode 100644 index 0000000..49290a3 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt @@ -0,0 +1,116 @@ +package net.trilleo.mc.plugins.tritown.guis.shop + +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +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.Main +import net.trilleo.mc.plugins.tritown.shops.ShopCost +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.tr +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import org.bukkit.inventory.ItemStack + +/** + * The pieces every shop menu draws with. + * + * Kept in one place because the player's menu, the editor and the statistics + * view all describe the same things — a price, a stock level, a requirement — + * and they should read identically wherever they appear. + */ +object ShopRender { + + private val plain = PlainTextComponentSerializer.plainText() + + /** + * Opens the next menu on the following tick. + * + * A click is still being delivered while its handler runs, and opening an + * inventory from inside that delivery leaves the server and the client + * disagreeing about what is on screen. + */ + fun navigate(open: () -> Unit) { + Bukkit.getScheduler().runTask(Main.instance, Runnable { open() }) + } + + /** + * What to call [item] in a line of text. + * + * A renamed item is called by the name it was given, escaped because an + * administrator wrote it. Anything else uses the client's own translation, + * so a Chinese player reads Chinese item names without TriTown shipping a + * copy of Minecraft's dictionary. + */ + fun itemName(item: ItemStack): String { + val meta = item.itemMeta + val custom: Component? = if (meta != null && meta.hasDisplayName()) meta.displayName() else null + 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), + ) + + /** 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 { + if (cost == null || cost.isFree) return emptyList() + + val lines = mutableListOf() + if (cost.hasMoney) lines += money(cost.money * multiplier) + cost.items.forEach { lines += itemLine(player, it, multiplier) } + return lines + } + + /** [amount] as the server's currency, or a bare number when no economy is available. */ + fun money(amount: Double): String = + if (EconomyUtil.isAvailable) ComponentUtil.escape(EconomyUtil.format(amount)) else amount.toString() + + /** A Towny requirement's name in [player]'s language. */ + fun requirementName(player: Player, requirement: TownyRequirement): String = when (requirement) { + TownyRequirement.NONE -> player.tr("gui.shop.requirement-none") + TownyRequirement.HAS_TOWN -> player.tr("gui.shop.requirement-has-town") + TownyRequirement.NO_TOWN -> player.tr("gui.shop.requirement-no-town") + TownyRequirement.HAS_NATION -> player.tr("gui.shop.requirement-has-nation") + TownyRequirement.IS_MAYOR -> player.tr("gui.shop.requirement-is-mayor") + TownyRequirement.IS_KING -> player.tr("gui.shop.requirement-is-king") + } + + /** A limit period's name in [player]'s language. */ + fun periodName(player: Player, period: LimitPeriod): String = when (period) { + LimitPeriod.NONE -> player.tr("gui.shop.period-none") + LimitPeriod.DAILY -> player.tr("gui.shop.period-daily") + LimitPeriod.WEEKLY -> player.tr("gui.shop.period-weekly") + } + + /** A match mode's name in [player]'s language. */ + fun matchName(player: Player, mode: MatchMode): String = when (mode) { + MatchMode.EXACT -> player.tr("gui.shop.match-exact") + MatchMode.MATERIAL -> player.tr("gui.shop.match-material") + } + + /** + * 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/ShopSettingsGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopSettingsGUI.kt new file mode 100644 index 0000000..3cd051c --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopSettingsGUI.kt @@ -0,0 +1,199 @@ +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.ShopDefinition +import net.trilleo.mc.plugins.tritown.shops.ShopManager +import net.trilleo.mc.plugins.tritown.utils.ChatPrompt +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 +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.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * A shop as a whole: what it is called, who may open it, and which NPCs stand + * behind the counter. + * + * The NPC list is shown but not edited here — binding needs an NPC's name, which + * is a command's job, and the command tab-completes those names. + */ +class ShopSettingsGUI : PluginGUI( + id = ID, + titleKey = "gui.shop-settings.title", + rows = 3, + fillMode = FillMode.DARK, +) { + + private val editing = ConcurrentHashMap() + + /** Opens the settings of [shop]. */ + fun open(player: Player, shop: ShopDefinition) { + editing[player.uniqueId] = shop.id + GUIManager.open(player, ID) + } + + override fun setup(player: Player, inventory: Inventory) { + val shop = shopOf(player) ?: return + + inventory.setItem(SLOT_NAME, name(player, shop)) + inventory.setItem(SLOT_PERMISSION, permission(player, shop)) + inventory.setItem(SLOT_TOWNY, towny(player, shop)) + inventory.setItem(SLOT_NPCS, npcs(player, shop)) + inventory.setItem( + SLOT_STATS, + button(player, Material.WRITABLE_BOOK, "gui.shop-settings.stats", "gui.shop-settings.stats-lore"), + ) + inventory.setItem( + SLOT_BACK, + button(player, Material.ARROW, "gui.shop-settings.back", "gui.shop-settings.back-lore"), + ) + } + + override fun onClick(event: InventoryClickEvent) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + val shop = shopOf(player) ?: return + val clear = event.click == ClickType.RIGHT || event.click == ClickType.SHIFT_RIGHT + + when (event.rawSlot) { + SLOT_NAME -> return prompt(player, shop, player.tr("gui.shop-settings.prompt-name")) { input -> + if (input.isNotBlank()) { + shop.displayName = input + ShopManager.save() + } + } + + SLOT_PERMISSION -> { + if (clear) { + shop.gate = shop.gate.copy(permission = null) + ShopManager.save() + } else { + return prompt(player, shop, player.tr("gui.shop-settings.prompt-permission")) { input -> + shop.gate = shop.gate.copy(permission = input.ifBlank { null }) + ShopManager.save() + } + } + } + + SLOT_TOWNY -> { + shop.gate = shop.gate.copy(towny = cycle(shop.gate.towny, clear)) + ShopManager.save() + } + + SLOT_STATS -> return ShopRender.navigate { ShopStatsGUI.show(player, shop) } + SLOT_BACK -> return ShopRender.navigate { ShopEditorGUI.show(player, shop) } + else -> return + } + + setup(player, event.inventory) + } + + override fun onClose(event: InventoryCloseEvent) { + editing.remove((event.player as? Player)?.uniqueId ?: return) + } + + private fun prompt(player: Player, shop: ShopDefinition, question: String, onInput: (String) -> Unit) { + player.closeInventory() + ChatPrompt.ask(player, question) { input -> + onInput(input) + show(player, shop) + } + } + + private fun cycle(value: TownyRequirement, backwards: Boolean): TownyRequirement { + val values = TownyRequirement.entries + val step = if (backwards) -1 else 1 + return values[(value.ordinal + step + values.size) % values.size] + } + + private fun name(player: Player, shop: ShopDefinition): ItemStack = itemStack(Material.NAME_TAG) { + name(shop.displayName) + meta { + lore( + LoreUtil.wrapLore( + player.tr("gui.shop-settings.id", "id" to shop.id) + + "" + player.tr("gui.shop-settings.click-rename") + ) + ) + } + } + + private fun permission(player: Player, shop: ShopDefinition): ItemStack = itemStack(Material.PAPER) { + name(player.tr("gui.shop-settings.permission")) + meta { + lore( + LoreUtil.wrapLore( + player.tr( + "gui.shop-settings.value", + "value" to (shop.gate.permission ?: player.tr("common.none")), + ) + + "" + player.tr("gui.shop-settings.click-set") + + "" + player.tr("gui.shop-settings.right-click-clear") + ) + ) + } + } + + private fun towny(player: Player, shop: ShopDefinition): ItemStack = itemStack(Material.OAK_SIGN) { + name(player.tr("gui.shop-settings.towny")) + meta { + lore( + LoreUtil.wrapLore( + player.tr( + "gui.shop-settings.value", + "value" to ShopRender.requirementName(player, shop.gate.towny), + ) + "" + player.tr("gui.shop-settings.click-cycle") + ) + ) + } + } + + private fun npcs(player: Player, shop: ShopDefinition): ItemStack = itemStack(Material.PLAYER_HEAD) { + name(player.tr("gui.shop-settings.npcs")) + meta { + lore( + LoreUtil.wrapLore( + player.tr("gui.shop-settings.npc-count", "amount" to shop.npcIds.size) + + "" + player.tr("gui.shop-settings.npc-lore", "id" to shop.id) + ) + ) + } + } + + 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 shopOf(player: Player): ShopDefinition? = editing[player.uniqueId]?.let(ShopManager::get) + + companion object { + const val ID = "shop-settings" + + private const val SLOT_NAME = 10 + private const val SLOT_PERMISSION = 11 + private const val SLOT_TOWNY = 12 + private const val SLOT_NPCS = 14 + private const val SLOT_STATS = 16 + private const val SLOT_BACK = 22 + + /** Opens a shop's settings through the registered instance. */ + fun show(player: Player, shop: ShopDefinition): Boolean { + val gui = GUIManager.getGUI(ID) as? ShopSettingsGUI ?: return false + gui.open(player, shop) + return true + } + } +} 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 new file mode 100644 index 0000000..e66c71a --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopStatsGUI.kt @@ -0,0 +1,115 @@ +package net.trilleo.mc.plugins.tritown.guis.shop + +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PagedPluginGUI +import net.trilleo.mc.plugins.tritown.shops.ShopDefinition +import net.trilleo.mc.plugins.tritown.shops.ShopEntry +import net.trilleo.mc.plugins.tritown.shops.ShopManager +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.event.inventory.ClickType +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.inventory.ItemStack +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * What a shop has actually traded. + * + * The header totals the whole shop, so an owner can see at a glance whether it + * is draining currency out of the economy or feeding it in, and each entry below + * shows which line is responsible. + */ +class ShopStatsGUI : PagedPluginGUI( + id = ID, + titleKey = "gui.shop-stats.title", + rows = 6, + fillMode = FillMode.NONE, +) { + + private val viewing = ConcurrentHashMap() + + /** Opens the figures for [shop]. */ + fun open(player: Player, shop: ShopDefinition) { + viewing[player.uniqueId] = shop.id + GUIManager.open(player, ID) + } + + override fun getItems(player: Player): List { + val shop = shopOf(player) ?: return emptyList() + return listOf(header(player, shop)) + shop.entries.map { entry -> row(player, entry) } + } + + override fun onContentClick(event: InventoryClickEvent, page: Int) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + val shop = shopOf(player) ?: return + + val index = page * CONTENT_SLOTS + event.rawSlot + if (index != 0 || event.click != ClickType.SHIFT_LEFT) return + + shop.entries.forEach { it.stats.reset() } + ShopManager.save() + player.sendPrefixed(player.tr("shop.stats-reset", "shop" to shop.displayName)) + ShopRender.navigate { show(player, shop) } + } + + override fun onClose(event: InventoryCloseEvent) { + super.onClose(event) + viewing.remove((event.player as? Player)?.uniqueId ?: return) + } + + private fun header(player: Player, shop: ShopDefinition): ItemStack { + val bought = shop.entries.sumOf { it.stats.bought } + val sold = shop.entries.sumOf { it.stats.sold } + val moneyIn = shop.entries.sumOf { it.stats.moneyIn } + val moneyOut = shop.entries.sumOf { it.stats.moneyOut } + + val lore = listOf( + player.tr("gui.shop-stats.bought", "amount" to bought), + player.tr("gui.shop-stats.sold", "amount" to sold), + player.tr("gui.shop-stats.money-in", "amount" to ShopRender.money(moneyIn)), + player.tr("gui.shop-stats.money-out", "amount" to ShopRender.money(moneyOut)), + player.tr("gui.shop-stats.net", "amount" to ShopRender.money(moneyIn - moneyOut)), + player.tr("gui.shop-stats.reset"), + ) + + return itemStack(Material.WRITABLE_BOOK) { + name(shop.displayName) + meta { lore(LoreUtil.wrapLore(lore.joinToString(""))) } + } + } + + private fun row(player: Player, entry: ShopEntry): ItemStack { + val lore = listOf( + player.tr("gui.shop-stats.bought", "amount" to entry.stats.bought), + player.tr("gui.shop-stats.sold", "amount" to entry.stats.sold), + player.tr("gui.shop-stats.money-in", "amount" to ShopRender.money(entry.stats.moneyIn)), + player.tr("gui.shop-stats.money-out", "amount" to ShopRender.money(entry.stats.moneyOut)), + ) + + return ShopRender.withLore(entry.displayStack(), lore) + } + + private fun shopOf(player: Player): ShopDefinition? = viewing[player.uniqueId]?.let(ShopManager::get) + + companion object { + const val ID = "shop-stats" + + private const val CONTENT_SLOTS = 45 + + /** Opens a shop's figures through the registered instance. */ + fun show(player: Player, shop: ShopDefinition): Boolean { + val gui = GUIManager.getGUI(ID) as? ShopStatsGUI ?: return false + gui.open(player, shop) + return true + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/ChatPromptListener.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/ChatPromptListener.kt new file mode 100644 index 0000000..3f38fe8 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/ChatPromptListener.kt @@ -0,0 +1,34 @@ +package net.trilleo.mc.plugins.tritown.listeners + +import io.papermc.paper.event.player.AsyncChatEvent +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import net.trilleo.mc.plugins.tritown.utils.ChatPrompt +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerQuitEvent + +/** + * Feeds chat into [ChatPrompt] when a player is answering a question. + * + * Runs at the lowest priority so an answer is taken before any chat plugin + * formats or broadcasts it, and the event is cancelled so the answer — which + * may be a permission node or a price — never reaches the channel. + */ +class ChatPromptListener : Listener { + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + fun onChat(event: AsyncChatEvent) { + if (!ChatPrompt.isWaiting(event.player)) return + + val message = PlainTextComponentSerializer.plainText().serialize(event.message()) + if (ChatPrompt.consume(event.player, message)) { + event.isCancelled = true + } + } + + @EventHandler + fun onQuit(event: PlayerQuitEvent) { + ChatPrompt.cancel(event.player) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/shop/ShopNpcListener.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/shop/ShopNpcListener.kt new file mode 100644 index 0000000..f5847c2 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/shop/ShopNpcListener.kt @@ -0,0 +1,52 @@ +package net.trilleo.mc.plugins.tritown.listeners.shop + +import de.oliver.fancynpcs.api.actions.ActionTrigger +import de.oliver.fancynpcs.api.events.NpcInteractEvent +import net.trilleo.mc.plugins.tritown.config.ShopSettings +import net.trilleo.mc.plugins.tritown.guis.shop.ShopGUI +import net.trilleo.mc.plugins.tritown.shops.ShopAccess +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.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener + +/** + * Opens a shop when a player clicks the NPC standing behind its counter. + * + * This is the only class in the plugin that names a FancyNpcs type in a + * signature, and that is deliberate: the package scanner skips a class it cannot + * load, so on a server without FancyNpcs this listener quietly never registers + * and the rest of the feature is unaffected. + */ +class ShopNpcListener : Listener { + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + fun onNpcInteract(event: NpcInteractEvent) { + if (!ShopManager.isReady) return + if (!ShopSettings.isLoaded || !ShopSettings.snapshot.enabled) return + + // Only a click opens a shop; anything else an NPC reacts to is left alone. + if (event.interactionType != ActionTrigger.ANY_CLICK && + event.interactionType != ActionTrigger.LEFT_CLICK && + event.interactionType != ActionTrigger.RIGHT_CLICK + ) { + return + } + + val shop = ShopManager.byNpc(event.npc.data.id) ?: return + val player = event.player + + // Cancelled either way, so the NPC's own actions do not fire alongside + // the shop, or on top of the refusal. + event.isCancelled = true + + if (!ShopAccess.canOpen(player, shop)) { + player.sendPrefixed(player.tr("common.error", "message" to player.tr("shop.error.locked"))) + return + } + + ShopGUI.show(player, shop) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ItemCodec.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ItemCodec.kt new file mode 100644 index 0000000..33addc0 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ItemCodec.kt @@ -0,0 +1,34 @@ +package net.trilleo.mc.plugins.tritown.shops + +import org.bukkit.inventory.ItemStack +import java.util.Base64 + +/** + * Turns an [ItemStack] into a string a shop file can hold, and back again. + * + * Paper's own byte form is used rather than a field-by-field description, + * because it is the only round-trip that keeps every data component: a renamed, + * enchanted, custom-model stack, a TriTown custom item, or an item another + * plugin invented all come back exactly as they went in. Paper stamps the game + * version into those bytes and upgrades them on the way out, so a shop survives + * a Minecraft update. + * + * Decoding never throws. One unreadable entry must not take a whole shop with + * it, so a failure is reported as `null` and left for the caller to log and skip. + */ +object ItemCodec { + + /** [item] as Base64 text. */ + fun encode(item: ItemStack): String = Base64.getEncoder().encodeToString(item.serializeAsBytes()) + + /** The stack [encoded] holds, or `null` when it cannot be read. */ + fun decode(encoded: String): ItemStack? = runCatching { + ItemStack.deserializeBytes(Base64.getDecoder().decode(encoded)) + }.getOrNull() + + /** [items] as Base64 text, in order. */ + fun encodeAll(items: List): List = items.map(::encode) + + /** The stacks [encoded] holds, silently dropping any that cannot be read. */ + fun decodeAll(encoded: List): List = encoded.mapNotNull(::decode) +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopAccess.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopAccess.kt new file mode 100644 index 0000000..397de0f --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopAccess.kt @@ -0,0 +1,72 @@ +package net.trilleo.mc.plugins.tritown.shops + +import com.palmergames.bukkit.towny.TownyAPI +import net.trilleo.mc.plugins.tritown.enums.TownyRequirement +import org.bukkit.entity.Player + +/** + * Decides whether a player may use a shop or one of its entries. + * + * Towny is read here and nowhere else in the feature, and it is read fresh on + * every check: a player who joins a town mid-session sees the town's prices + * without relogging, and nothing TriTown holds can fall out of step with Towny. + */ +object ShopAccess { + + /** + * Every Towny requirement [player] currently satisfies. + * + * Read once per menu render rather than once per entry, because a shop can + * hold dozens of entries and they all ask the same questions. + */ + fun standing(player: Player): Set { + val standing = linkedSetOf(TownyRequirement.NONE) + val resident = TownyAPI.getInstance().getResident(player) + + if (resident == null || !resident.hasTown()) { + standing += TownyRequirement.NO_TOWN + return standing + } + + standing += TownyRequirement.HAS_TOWN + if (resident.isMayor) standing += TownyRequirement.IS_MAYOR + if (resident.hasNation()) { + standing += TownyRequirement.HAS_NATION + if (resident.isKing) standing += TownyRequirement.IS_KING + } + + return standing + } + + /** Whether [gate] lets [player] through, given the [standing] already read for them. */ + fun allows(player: Player, gate: ShopGate, standing: Set): Boolean { + val permission = gate.permission + if (!permission.isNullOrBlank() && !player.hasPermission(permission)) return false + return gate.towny in standing + } + + /** Whether [player] may open [shop] at all. */ + fun canOpen(player: Player, shop: ShopDefinition): Boolean = + allows(player, shop.gate, standing(player)) + + /** + * Why [gate] refused, as a translation key, or `null` when it did not. + * + * The permission side is deliberately vague: a player has no use for the + * node's name, and printing it advertises the server's permission layout. + */ + fun refusalKey(player: Player, gate: ShopGate, standing: Set): String? { + val permission = gate.permission + if (!permission.isNullOrBlank() && !player.hasPermission(permission)) return "gui.shop.locked-permission" + if (gate.towny in standing) return null + + return when (gate.towny) { + TownyRequirement.NONE -> null + TownyRequirement.HAS_TOWN -> "gui.shop.locked-has-town" + TownyRequirement.NO_TOWN -> "gui.shop.locked-no-town" + TownyRequirement.HAS_NATION -> "gui.shop.locked-has-nation" + TownyRequirement.IS_MAYOR -> "gui.shop.locked-is-mayor" + TownyRequirement.IS_KING -> "gui.shop.locked-is-king" + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopCost.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopCost.kt new file mode 100644 index 0000000..b2c2607 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopCost.kt @@ -0,0 +1,29 @@ +package net.trilleo.mc.plugins.tritown.shops + +import org.bukkit.inventory.ItemStack + +/** + * What one bundle costs, or what one bundle pays out. + * + * Money and items are both optional and both may be set at once, so an entry can + * be sold for currency, bartered for items, or priced as a mix of the two. + * + * @param money the currency amount, before any discount + * @param items the stacks required alongside it; each stack's amount is its quantity + */ +data class ShopCost(val money: Double = 0.0, val items: List = emptyList()) { + + /** Whether this costs nothing at all. */ + val isFree: Boolean get() = money <= 0.0 && items.isEmpty() + + /** Whether any currency changes hands. */ + val hasMoney: Boolean get() = money > 0.0 + + /** A deep copy, so a stored cost cannot be mutated through a stack handed out of it. */ + fun copyDeep(): ShopCost = ShopCost(money, items.map { it.clone() }) + + companion object { + /** A cost of nothing, used for a giveaway or an item-only trade. */ + val FREE = ShopCost() + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopDefinition.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopDefinition.kt new file mode 100644 index 0000000..38b41a9 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopDefinition.kt @@ -0,0 +1,31 @@ +package net.trilleo.mc.plugins.tritown.shops + +/** + * One shop: a title, a gate, and the entries it offers. + * + * The [id] is what NPC bindings and purchase counters are keyed by, so it never + * changes once a shop exists; [displayName] is what players read and can be + * edited freely. That name is written by an administrator as MiniMessage rather + * than being a translation key, because a server's own shops are named in the + * server's own words. + * + * @param npcIds FancyNpcs ids, not names, so renaming an NPC does not break the binding + */ +data class ShopDefinition( + val id: String, + var displayName: String, + var gate: ShopGate = ShopGate.OPEN, + val entries: MutableList = mutableListOf(), + val npcIds: MutableSet = linkedSetOf(), +) { + + /** The entry with [entryId], or `null` when it has been removed since the menu was opened. */ + fun entry(entryId: String): ShopEntry? = entries.firstOrNull { it.id == entryId } + + companion object { + /** Whether [id] is usable: lowercase letters, digits, dashes and underscores only. */ + fun isValidId(id: String): Boolean = id.isNotEmpty() && id.length <= 32 && ID_PATTERN.matches(id) + + private val ID_PATTERN = Regex("[a-z0-9][a-z0-9_-]*") + } +} 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 new file mode 100644 index 0000000..8d86dd9 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopEntry.kt @@ -0,0 +1,67 @@ +package net.trilleo.mc.plugins.tritown.shops + +import net.trilleo.mc.plugins.tritown.enums.MatchMode +import org.bukkit.inventory.ItemStack +import java.util.UUID + +/** + * One line of goods in a shop. + * + * The [item] is stored verbatim, custom data and all, and its own stack size is + * the bundle: an entry holding 16 bread sells sixteen loaves per click. Buying + * and 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 + */ +data class ShopEntry( + val id: String = UUID.randomUUID().toString(), + var item: ItemStack, + var buy: ShopCost? = null, + var sell: ShopCost? = null, + var gate: ShopGate = ShopGate.OPEN, + var limit: ShopLimit? = null, + var stock: ShopStock? = null, + var discountable: Boolean = true, + var matchMode: MatchMode = MatchMode.EXACT, + var stats: ShopStats = ShopStats(), +) { + + /** How many items one bundle is. */ + val bundleSize: Int get() = item.amount.coerceAtLeast(1) + + /** Whether a player can buy this. */ + val isBuyable: Boolean get() = buy != null + + /** Whether the shop buys this back. */ + val isSellable: Boolean get() = sell != null + + /** One bundle as a single stack, for a menu slot rather than for handing over. */ + fun displayStack(): ItemStack = item.clone().apply { amount = bundleSize.coerceAtMost(item.maxStackSize) } + + /** + * [bundles] bundles 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 + val perStack = item.maxStackSize.coerceAtLeast(1) + + return buildList { + var outstanding = total + while (outstanding > 0) { + val size = minOf(outstanding, perStack) + add(item.clone().apply { amount = size }) + outstanding -= size + } + } + } + + /** Whether [stack] is close enough to the goods to count, under this entry's [matchMode]. */ + fun matches(stack: ItemStack): Boolean = when (matchMode) { + MatchMode.EXACT -> item.isSimilar(stack) + MatchMode.MATERIAL -> item.type == stack.type + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopGate.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopGate.kt new file mode 100644 index 0000000..1024c5a --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopGate.kt @@ -0,0 +1,29 @@ +package net.trilleo.mc.plugins.tritown.shops + +import net.trilleo.mc.plugins.tritown.enums.TownyRequirement + +/** + * Who may use a shop, or one entry inside it. + * + * [permission] is written by whoever set the shop up, so it cannot be registered + * at startup the way a command's nodes are — it is checked as it stands and + * defined in the server's permissions plugin. + * + * @param permission a node the player must hold, or `null` for no check + * @param towny what the player's standing in Towny must be + * @param hideWhenLocked whether a player who fails the check sees the entry greyed out or not at all + */ +data class ShopGate( + val permission: String? = null, + val towny: TownyRequirement = TownyRequirement.NONE, + val hideWhenLocked: Boolean = false, +) { + + /** Whether this gate checks anything at all. */ + val isOpen: Boolean get() = permission.isNullOrBlank() && towny == TownyRequirement.NONE + + companion object { + /** A gate that lets everybody through. */ + val OPEN = ShopGate() + } +} 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 new file mode 100644 index 0000000..cf7722a --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopInventory.kt @@ -0,0 +1,118 @@ +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. + * + * 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. + */ +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. + * + * @return the stacks that were removed, or `null` when the player did not + * have enough — in which case nothing is taken at all + */ + fun remove(player: Player, template: ItemStack, mode: MatchMode, amount: Int): List? { + if (amount <= 0) return emptyList() + if (count(player, template, mode) < amount) return null + + val removed = mutableListOf() + var outstanding = amount + val contents = player.inventory.storageContents + + for (index in contents.indices) { + if (outstanding == 0) break + val stack = contents[index] ?: continue + if (!matches(template, stack, mode)) continue + + val taken = minOf(outstanding, stack.amount) + removed += stack.clone().apply { this.amount = taken } + outstanding -= taken + + if (taken == stack.amount) { + contents[index] = null + } else { + stack.amount -= taken + } + } + + player.inventory.storageContents = contents + 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/ShopLimit.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimit.kt new file mode 100644 index 0000000..1d2df95 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimit.kt @@ -0,0 +1,37 @@ +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. + * + * 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 period how often the window starts over + */ +data class ShopLimit(val amount: Int, val period: LimitPeriod) { + + /** The window [epochMillis] falls in. A lifetime limit has only one window. */ + fun windowAt(epochMillis: Long): Long = when (period) { + LimitPeriod.NONE -> 0L + LimitPeriod.DAILY -> Math.floorDiv(epochMillis, DAY_MILLIS) + LimitPeriod.WEEKLY -> Math.floorDiv(epochMillis, 7L * DAY_MILLIS) + } + + /** + * How many bundles are still available to a player who has bought [used] of + * them in window [usedWindow]. + * + * A count from an earlier window is spent, so it is ignored rather than + * having to be cleared when the window turns over. + */ + fun remaining(used: Int, usedWindow: Long, epochMillis: Long): Int = + if (usedWindow != windowAt(epochMillis)) amount else (amount - used).coerceAtLeast(0) + + private companion object { + const val DAY_MILLIS = 24L * 60L * 60L * 1000L + } +} 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 new file mode 100644 index 0000000..56e5a11 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimits.kt @@ -0,0 +1,74 @@ +package net.trilleo.mc.plugins.tritown.shops + +import com.google.gson.JsonObject +import net.trilleo.mc.plugins.tritown.data.PlayerDataManager +import org.bukkit.entity.Player + +/** + * How much 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 + * means deleting a shop cannot leave a growing table of dead counters behind. + * + * 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. + */ +object ShopLimits { + + private const val ROOT_KEY = "shop-limits" + 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. */ + fun remaining( + player: Player, + shop: ShopDefinition, + entry: ShopEntry, + now: Long = System.currentTimeMillis(), + ): Int? { + val limit = entry.limit ?: return null + val record = record(player, shop, entry) ?: return limit.amount + return limit.remaining(record.first, record.second, now) + } + + /** Records [bundles] bought, resetting the count first when the window has turned over. */ + fun record( + player: Player, + shop: ShopDefinition, + entry: ShopEntry, + bundles: 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 root = root(player) + root.add(key(shop, entry), JsonObject().apply { + addProperty(COUNT, count) + addProperty(WINDOW, window) + }) + PlayerDataManager.get(player).set(ROOT_KEY, root) + } + + /** Forgets every counter [player] holds for [shop], for a shop that has been deleted. */ + fun forget(player: Player, shop: ShopDefinition) { + val root = root(player) + val prefix = "${shop.id}/" + root.keySet().filter { it.startsWith(prefix) }.forEach(root::remove) + 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 + val count = stored.get(COUNT)?.asInt ?: return null + val window = stored.get(WINDOW)?.asLong ?: return null + return count to window + } + + private fun root(player: Player): JsonObject = PlayerDataManager.get(player).getJsonObject(ROOT_KEY) + + private fun key(shop: ShopDefinition, entry: ShopEntry): String = "${shop.id}/${entry.id}" +} 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 new file mode 100644 index 0000000..1fb9a82 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopManager.kt @@ -0,0 +1,231 @@ +package net.trilleo.mc.plugins.tritown.shops + +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.storage.ShopStorage +import net.trilleo.mc.plugins.tritown.shops.storage.ShopStorageException +import net.trilleo.mc.plugins.tritown.shops.storage.StoredCost +import net.trilleo.mc.plugins.tritown.shops.storage.StoredEntry +import net.trilleo.mc.plugins.tritown.shops.storage.StoredShop +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.logging.Logger + +/** + * Every shop the server has, and the only thing that reads or writes them. + * + * Definitions are edited rarely and must never be lost, so an edit is written + * out the moment it is made. Stock and statistics change on every purchase, so + * they only mark the shops dirty and are flushed by + * [net.trilleo.mc.plugins.tritown.tasks.shop.ShopSaveTask] — a crash costs at + * most one flush interval of counters, never a definition. + * + * Shops are read from the server thread when a menu opens and written from the + * flush task, so the registry is concurrent and a write takes a snapshot. + */ +object ShopManager { + + private val shops = ConcurrentHashMap() + private val dirty = AtomicBoolean(false) + + private lateinit var storage: ShopStorage + private lateinit var logger: Logger + + @Volatile + var isReady: Boolean = false + private set + + /** + * Loads every stored shop. + * + * A store that cannot be read at all leaves the feature off rather than + * starting empty, because an empty start would be written back over the + * real shops at the next save. + */ + fun start(store: ShopStorage, pluginLogger: Logger) { + storage = store + logger = pluginLogger + + val loaded = try { + store.loadAll() + } catch (e: ShopStorageException) { + logger.severe("Shops are unavailable: ${e.message}") + isReady = false + return + } + + shops.clear() + for (stored in loaded) { + val shop = toDefinition(stored) + shops[shop.id] = shop + } + + dirty.set(false) + isReady = true + logger.info("Loaded ${shops.size} shop(s)") + } + + /** Writes anything outstanding and stops serving shops. */ + fun shutdown() { + if (!isReady) return + flush() + isReady = false + shops.clear() + } + + // ── Reading ───────────────────────────────────────────────────────── + + /** The shop with [id], or `null` when there is none. */ + fun get(id: String): ShopDefinition? = shops[id.lowercase()] + + /** Every shop, ordered by id so a listing does not shuffle between restarts. */ + fun all(): List = shops.values.sortedBy { it.id } + + /** Every shop id, ordered. */ + fun ids(): List = shops.keys.sorted() + + /** The shop bound to the FancyNpcs NPC with [npcId], or `null` when that NPC opens nothing. */ + fun byNpc(npcId: String): ShopDefinition? = shops.values.firstOrNull { npcId in it.npcIds } + + // ── Editing ───────────────────────────────────────────────────────── + + /** + * Creates an empty shop called [id]. + * + * @return the new shop, or `null` when [id] is malformed or already taken + */ + fun create(id: String, displayName: String): ShopDefinition? { + val key = id.lowercase() + if (!ShopDefinition.isValidId(key) || shops.containsKey(key)) return null + + val shop = ShopDefinition(id = key, displayName = displayName) + shops[key] = shop + save() + return shop + } + + /** Removes the shop with [id]. Returns `false` when there was none. */ + fun delete(id: String): Boolean { + val removed = shops.remove(id.lowercase()) != null + if (removed) save() + return removed + } + + /** Writes every shop out now, for a change to a definition that must not be lost. */ + fun save() { + if (!isReady) return + storage.saveAll(snapshot()) + dirty.set(false) + } + + /** Records that stock or statistics changed, to be written by the next flush. */ + fun markDirty() { + dirty.set(true) + } + + /** Writes the shops out when anything has changed since the last write. */ + fun flush() { + if (!isReady) return + if (!dirty.compareAndSet(true, false)) return + storage.saveAll(snapshot()) + } + + private fun snapshot(): List = shops.values.sortedBy { it.id }.map(::toStored) + + // ── Conversion ────────────────────────────────────────────────────── + + private fun toDefinition(stored: StoredShop): ShopDefinition { + val entries = stored.entries.mapNotNull { entry -> toEntry(stored.id, entry) } + + return ShopDefinition( + id = stored.id.lowercase(), + displayName = stored.displayName.ifBlank { stored.id }, + gate = ShopGate(stored.permission, requirement(stored.towny), stored.hideWhenLocked), + entries = entries.toMutableList(), + npcIds = stored.npcIds.toCollection(linkedSetOf()), + ) + } + + private fun toEntry(shopId: String, entry: StoredEntry): ShopEntry? { + val item = ItemCodec.decode(entry.item) ?: run { + logger.warning("Dropped an unreadable item from shop $shopId") + return null + } + + val limit = if (entry.limitAmount > 0) { + ShopLimit(entry.limitAmount, enumOrDefault(entry.limitPeriod, LimitPeriod.NONE)) + } else { + null + } + + val stock = if (entry.stockMax > 0) { + ShopStock( + max = entry.stockMax, + restockSeconds = entry.stockRestockSeconds, + remaining = entry.stockRemaining.coerceIn(0, entry.stockMax), + lastRestock = entry.stockLastRestock, + ) + } else { + null + } + + return ShopEntry( + id = entry.id.ifBlank { UUID.randomUUID().toString() }, + item = item, + buy = toCost(entry.buy), + sell = toCost(entry.sell), + gate = ShopGate(entry.permission, requirement(entry.towny), entry.hideWhenLocked), + limit = limit, + stock = stock, + discountable = entry.discountable, + matchMode = enumOrDefault(entry.matchMode, MatchMode.EXACT), + stats = ShopStats(entry.bought, entry.sold, entry.moneyIn, entry.moneyOut), + ) + } + + private fun toStored(shop: ShopDefinition): StoredShop = StoredShop( + id = shop.id, + displayName = shop.displayName, + permission = shop.gate.permission, + towny = shop.gate.towny.name, + hideWhenLocked = shop.gate.hideWhenLocked, + npcIds = shop.npcIds.toList(), + entries = shop.entries.map { entry -> + StoredEntry( + id = entry.id, + item = ItemCodec.encode(entry.item), + buy = toStoredCost(entry.buy), + sell = toStoredCost(entry.sell), + 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, + stockMax = entry.stock?.max ?: 0, + stockRestockSeconds = entry.stock?.restockSeconds ?: 0L, + stockRemaining = entry.stock?.remaining ?: 0, + stockLastRestock = entry.stock?.lastRestock ?: 0L, + discountable = entry.discountable, + matchMode = entry.matchMode.name, + bought = entry.stats.bought, + sold = entry.stats.sold, + moneyIn = entry.stats.moneyIn, + moneyOut = entry.stats.moneyOut, + ) + }, + ) + + private fun toCost(stored: StoredCost?): ShopCost? = + stored?.let { ShopCost(it.money, ItemCodec.decodeAll(it.items)) } + + private fun toStoredCost(cost: ShopCost?): StoredCost? = + cost?.let { StoredCost(it.money, ItemCodec.encodeAll(it.items)) } + + private fun requirement(name: String): TownyRequirement = enumOrDefault(name, TownyRequirement.NONE) + + /** A stored name that no longer exists falls back rather than failing the whole load. */ + private inline fun > enumOrDefault(name: String, default: T): T = + enumValues().firstOrNull { it.name.equals(name, ignoreCase = true) } ?: default +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopPricing.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopPricing.kt new file mode 100644 index 0000000..6469a71 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopPricing.kt @@ -0,0 +1,41 @@ +package net.trilleo.mc.plugins.tritown.shops + +import net.trilleo.mc.plugins.tritown.enums.TownyRequirement +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * Works out what a buyer actually pays. + * + * Kept free of Bukkit and Towny so the arithmetic can be tested on its own; the + * caller reads the player's standing out of Towny and passes it in. + */ +object ShopPricing { + + /** + * The single best discount [standing] earns, as a fraction between 0 and 1. + * + * Discounts do not stack: a mayor whose nation also gets one pays the better + * of the two, not both. Stacking them would make a small change to one rate + * move prices no owner intended to touch. + */ + fun discount(rates: Map, standing: Set): Double = + standing.mapNotNull { rates[it] }.maxOrNull()?.coerceIn(0.0, 1.0) ?: 0.0 + + /** + * [money] with [discount] taken off, rounded to [scale] digits. + * + * Rounded down, so a discount is never worth less than it says. + */ + fun apply(money: Double, discount: Double, scale: Int): Double { + if (money <= 0.0 || discount <= 0.0) return money + return BigDecimal.valueOf(money) + .multiply(BigDecimal.ONE.subtract(BigDecimal.valueOf(discount))) + .setScale(scale, RoundingMode.DOWN) + .toDouble() + } + + /** [money] rounded to [scale] digits, for a price the owner typed or a total built from one. */ + fun round(money: Double, scale: Int): Double = + BigDecimal.valueOf(money).setScale(scale, RoundingMode.HALF_UP).toDouble() +} 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 new file mode 100644 index 0000000..87bff4d --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopQuote.kt @@ -0,0 +1,33 @@ +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 + * player at this moment. + * + * Prices are worked out once and then both shown and charged from the same + * quote, so a discount can never appear in the menu without being applied at + * the till. + * + * @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] + */ +data class ShopQuote( + val bundles: Int, + val money: Double, + val fullMoney: Double, + val items: List, + val discount: Double, +) { + + /** Whether a discount was applied. */ + val isDiscounted: Boolean get() = discount > 0.0 && money < fullMoney + + /** Whether any currency changes hands. */ + val hasMoney: Boolean get() = money > 0.0 + + /** Whether this quote asks for nothing at all. */ + val isFree: Boolean get() = money <= 0.0 && items.isEmpty() +} 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 new file mode 100644 index 0000000..7a297ff --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStats.kt @@ -0,0 +1,39 @@ +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. + */ +data class ShopStats( + var bought: Long = 0L, + var sold: Long = 0L, + var moneyIn: Double = 0.0, + var moneyOut: Double = 0.0, +) { + + /** Records a player buying [bundles] for [money]. */ + fun recordBuy(bundles: Int, money: Double) { + bought += bundles + moneyIn += money + } + + /** Records a player selling [bundles] for [money]. */ + fun recordSell(bundles: Int, money: Double) { + sold += bundles + moneyOut += money + } + + /** Whether anything has been traded at all. */ + val isEmpty: Boolean get() = bought == 0L && sold == 0L + + /** Clears every counter. */ + fun reset() { + bought = 0L + sold = 0L + moneyIn = 0.0 + moneyOut = 0.0 + } +} 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 new file mode 100644 index 0000000..1109d3c --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStock.kt @@ -0,0 +1,57 @@ +package net.trilleo.mc.plugins.tritown.shops + +/** + * A finite supply of one entry, shared by everybody, that refills on a timer. + * + * 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. + * + * 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 restockSeconds how long a restock takes; 0 never restocks, making the supply one-off + */ +data class ShopStock( + val max: Int, + val restockSeconds: Long, + var remaining: Int = max, + var lastRestock: Long = 0L, +) { + + /** How many bundles can be bought right now, restocking first if one is due. */ + fun available(now: Long): Int { + restock(now) + return remaining + } + + /** Brings the supply up to date, filling it when at least one restock period has passed. */ + fun restock(now: Long) { + if (restockSeconds <= 0L) return + if (lastRestock == 0L) { + lastRestock = now + return + } + + val period = restockSeconds * 1000L + val elapsed = now - lastRestock + if (elapsed < period) return + + remaining = max + // Advanced by whole periods so the refill time does not drift later on every restock. + lastRestock += elapsed / period * period + } + + /** Takes [count] bundles, 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. */ + 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 new file mode 100644 index 0000000..53f1396 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopTrade.kt @@ -0,0 +1,234 @@ +package net.trilleo.mc.plugins.tritown.shops + +import net.trilleo.mc.plugins.tritown.config.ShopSettings +import net.trilleo.mc.plugins.tritown.economy.CurrencyRegistry +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.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. + * + * Every trade runs on the server thread, because it touches an inventory. The + * ordering is what makes it safe: everything that can refuse is asked before + * anything is taken, and anything that is taken is remembered so it can be put + * back if a later step still fails. A player can end a failed trade poorer in + * neither money nor items. + * + * The goods a shop sells are created and the money paid for them leaves the + * economy, which is what an admin shop is for — there is no shop account behind + * it holding either. + */ +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 outcome of a trade. */ + sealed interface Result { + + /** The trade went through. */ + data class Success(val bundles: 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 + } + + // ── Quoting ───────────────────────────────────────────────────────── + + /** What [bundles] of [entry] would cost [player], with any discount applied. */ + fun quoteBuy( + player: Player, + entry: ShopEntry, + bundles: Int, + standing: Set = ShopAccess.standing(player), + ): ShopQuote? { + val cost = entry.buy ?: return null + val discount = if (entry.discountable) discountFor(standing) else 0.0 + val full = ShopPricing.round(cost.money * bundles, scale()) + + return ShopQuote( + bundles = bundles, + money = ShopPricing.apply(full, discount, scale()), + fullMoney = full, + items = multiply(cost.items, bundles), + discount = discount, + ) + } + + /** What the shop would pay [player] for [bundles] of [entry]. Discounts never apply to a payout. */ + fun quoteSell(entry: ShopEntry, bundles: 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) + } + + /** + * 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. + */ + fun maxBuyable( + player: Player, + shop: ShopDefinition, + entry: ShopEntry, + standing: Set = ShopAccess.standing(player), + ): Int { + val cost = entry.buy ?: return 0 + var max = MAX_BUNDLES + + entry.stock?.let { max = minOf(max, it.available(System.currentTimeMillis())) } + ShopLimits.remaining(player, shop, entry)?.let { max = minOf(max, it) } + + 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] is holding, for a shift-click that sells the lot. */ + fun maxSellable(player: Player, entry: ShopEntry): Int { + if (!entry.isSellable) return 0 + val held = ShopInventory.count(player, entry.item, entry.matchMode) + return minOf(MAX_BUNDLES, held / entry.bundleSize) + } + + // ── Buying ────────────────────────────────────────────────────────── + + /** Sells [bundles] of [entry] to [player]. */ + fun buy(player: Player, shop: ShopDefinition, entry: ShopEntry, bundles: Int): Result { + if (!ShopManager.isReady) return Result.Failure("shop.error.unavailable") + if (bundles !in 1..MAX_BUNDLES) 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) } + + ShopLimits.remaining(player, shop, entry)?.let { left -> + if (left < bundles) 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) { + 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") + + if (quote.hasMoney && !EconomyUtil.isAvailable) return Result.Failure("shop.error.economy-unavailable") + + // 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(bundles, now)) { + ShopInventory.give(player, taken) + return Result.Failure("shop.error.out-of-stock", listOf("amount" to stock.remaining)) + } + + 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) + 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) + entry.stats.recordBuy(bundles, quote.money) + ShopManager.markDirty() + + return Result.Success(bundles, quote.money) + } + + // ── Selling ───────────────────────────────────────────────────────── + + /** Buys [bundles] of [entry] back from [player]. */ + fun sell(player: Player, shop: ShopDefinition, entry: ShopEntry, bundles: 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") + + 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 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) + ?: return Result.Failure("shop.error.missing-goods") + + 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) + return Result.Failure("shop.error.payout-refused") + } + } + + ShopInventory.give(player, quote.items) + entry.stats.recordSell(bundles, quote.money) + ShopManager.markDirty() + + return Result.Success(bundles, quote.money) + } + + // ── Helpers ───────────────────────────────────────────────────────── + + /** + * Takes every stack in [required], putting back anything already taken if + * one of them turns out to be short. + */ + private fun takeItems(player: Player, required: List, mode: MatchMode): List? { + val taken = mutableListOf() + for (item in required) { + val removed = ShopInventory.remove(player, item, mode, item.amount) + if (removed == null) { + ShopInventory.give(player, taken) + return null + } + taken += removed + } + return taken + } + + private fun multiply(items: List, bundles: Int): List = + items.map { it.clone().apply { amount = it.amount * bundles } } + + private fun discountFor(standing: Set): Double = + if (ShopSettings.isLoaded) ShopPricing.discount(ShopSettings.snapshot.discounts, standing) else 0.0 + + private fun scale(): Int = if (CurrencyRegistry.isLoaded) CurrencyRegistry.primary.fractionalDigits else 2 + + private fun format(money: Double): String = + if (EconomyUtil.isAvailable) EconomyUtil.format(money) else money.toString() +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/npc/FancyNpcsAdapter.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/npc/FancyNpcsAdapter.kt new file mode 100644 index 0000000..c47b912 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/npc/FancyNpcsAdapter.kt @@ -0,0 +1,24 @@ +package net.trilleo.mc.plugins.tritown.shops.npc + +import de.oliver.fancynpcs.api.FancyNpcsPlugin + +/** + * The only class in TriTown that names a FancyNpcs type outside the listener. + * + * Kept separate from [ShopNpcBridge] on purpose: the JVM resolves a class the + * first time a method actually reaches it, so as long as nothing here appears in + * the bridge's own signatures, this class is never loaded on a server without + * FancyNpcs and the missing plugin is simply invisible. + */ +internal object FancyNpcsAdapter { + + /** Every NPC's name, for tab completion. */ + fun names(): List = + FancyNpcsPlugin.get().npcManager.allNpcs.mapNotNull { it.data?.name }.sorted() + + /** The stable id of the NPC called [name], or `null` when there is none. */ + fun idOf(name: String): String? = FancyNpcsPlugin.get().npcManager.getNpc(name)?.data?.id + + /** What the NPC with [id] is currently called, or `null` when it no longer exists. */ + fun nameOf(id: String): String? = FancyNpcsPlugin.get().npcManager.getNpcById(id)?.data?.name +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/npc/ShopNpcBridge.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/npc/ShopNpcBridge.kt new file mode 100644 index 0000000..cb5e167 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/npc/ShopNpcBridge.kt @@ -0,0 +1,41 @@ +package net.trilleo.mc.plugins.tritown.shops.npc + +import org.bukkit.Bukkit + +/** + * Looks NPCs up for the shop command, without needing FancyNpcs to be installed. + * + * Nothing here exposes a FancyNpcs type, so a command or menu that calls it + * loads normally on a server that does not have the plugin — every method just + * reports that there are no NPCs. + */ +object ShopNpcBridge { + + /** The plugin shops bind their NPCs from. */ + const val PLUGIN_NAME = "FancyNpcs" + + /** Whether FancyNpcs is installed and running. */ + val isAvailable: Boolean + get() = Bukkit.getPluginManager().isPluginEnabled(PLUGIN_NAME) + + /** Every NPC name on the server, or an empty list when FancyNpcs is absent. */ + fun names(): List = guard { FancyNpcsAdapter.names() } ?: emptyList() + + /** The stable id of the NPC called [name], which is what a binding stores. */ + fun idOf(name: String): String? = guard { FancyNpcsAdapter.idOf(name) } + + /** What the NPC with [id] is called now, or `null` when it has been deleted. */ + fun nameOf(id: String): String? = guard { FancyNpcsAdapter.nameOf(id) } + + /** + * Runs [block] only when FancyNpcs is there. + * + * The throwable is caught as well as the plugin being checked, because a + * FancyNpcs whose API has moved on should leave shops working rather than + * spilling an error into an administrator's command. + */ + private fun guard(block: () -> T): T? { + if (!isAvailable) return null + return runCatching(block).getOrNull() + } +} 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 new file mode 100644 index 0000000..7b70433 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/JsonShopStorage.kt @@ -0,0 +1,113 @@ +package net.trilleo.mc.plugins.tritown.shops.storage + +import com.google.gson.GsonBuilder +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import java.io.File +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.util.logging.Logger + +/** + * Keeps every shop in one JSON file under `/shops/`. + * + * Writes go to a temporary file which is then moved into place, with the + * previous copy kept as `.bak`, so a crash mid-write leaves either the old file + * or the new one and never a truncated one. A file that will not parse falls + * back to the backup rather than starting empty — an empty start would be + * written back over the real data on the next save. + * + * A single entry that will not parse is skipped with a warning. Losing one line + * of one shop is recoverable; refusing to open the server is not. + */ +class JsonShopStorage(directory: File, private val logger: Logger) : ShopStorage { + + private val root = File(directory, DIRECTORY) + private val shopsFile = File(root, SHOPS_FILE) + private val backupFile = File(root, "$SHOPS_FILE.bak") + private val gson = GsonBuilder().setPrettyPrinting().create() + private val ioLock = Any() + + override val schemaVersion: Int = ShopSchema.CURRENT + + override fun loadAll(): List = synchronized(ioLock) { + root.mkdirs() + + val document = read(shopsFile) ?: run { + if (shopsFile.exists()) { + logger.warning("${shopsFile.name} could not be read; falling back to the backup") + } + read(backupFile) ?: run { + if (backupFile.exists()) { + throw ShopStorageException("Neither ${shopsFile.name} nor its backup could be read") + } + return emptyList() + } + } + + val version = document.get(KEY_VERSION)?.asInt ?: ShopSchema.CURRENT + ShopSchema.checkReadable(version, shopsFile.name) + + val array = document.getAsJsonArray(KEY_SHOPS) ?: return emptyList() + array.mapNotNull { element -> + runCatching { gson.fromJson(element, StoredShop::class.java) } + .getOrNull() + ?.takeIf { it.id.isNotBlank() } + ?: run { + logger.warning("Skipped an unreadable shop in ${shopsFile.name}") + null + } + } + } + + override fun saveAll(shops: List) = synchronized(ioLock) { + root.mkdirs() + + val document = JsonObject().apply { + addProperty(KEY_VERSION, ShopSchema.CURRENT) + add(KEY_SHOPS, gson.toJsonTree(shops)) + } + + try { + writeAtomically(shopsFile.toPath(), gson.toJson(document)) + } catch (e: Exception) { + logger.severe("Failed to write ${shopsFile.name}: [${e.javaClass.simpleName}] ${e.message}") + } + } + + private fun read(file: File): JsonObject? { + if (!file.exists()) return null + return runCatching { JsonParser.parseString(file.readText()).asJsonObject }.getOrNull() + } + + private fun writeAtomically(target: Path, content: String) { + val temporary = target.resolveSibling("${target.fileName}.tmp") + Files.writeString( + temporary, + content, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE, + ) + + if (Files.exists(target)) { + Files.move(target, backupFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING) + } + } + + private companion object { + const val DIRECTORY = "shops" + const val SHOPS_FILE = "shops.json" + const val KEY_VERSION = "schemaVersion" + const val KEY_SHOPS = "shops" + } +} 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 new file mode 100644 index 0000000..7614e95 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopSchema.kt @@ -0,0 +1,34 @@ +package net.trilleo.mc.plugins.tritown.shops.storage + +/** + * Versioning for the shop file's layout. + * + * 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. + */ +object ShopSchema { + + /** The version this build writes. */ + const val CURRENT: Int = 1 + + /** The oldest version this build can still read. */ + const val OLDEST_SUPPORTED: Int = 1 + + /** @throws ShopStorageException when data written at [version] cannot be read by this build */ + fun checkReadable(version: Int, source: String) { + if (version > CURRENT) { + throw ShopStorageException( + "$source was written by a newer version of TriTown (schema $version, this build reads up to " + + "$CURRENT). Update TriTown rather than letting an older build overwrite it." + ) + } + if (version < OLDEST_SUPPORTED) { + throw ShopStorageException( + "$source uses schema $version, which this build can no longer read (oldest supported is " + + "$OLDEST_SUPPORTED)." + ) + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopStorage.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopStorage.kt new file mode 100644 index 0000000..33c3c9e --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopStorage.kt @@ -0,0 +1,27 @@ +package net.trilleo.mc.plugins.tritown.shops.storage + +/** + * Where shop definitions are kept between restarts. + * + * Only JSON ships today, but the shops themselves never name a file, so a + * database-backed store can be dropped in without touching the feature. + */ +interface ShopStorage { + + /** The on-disk layout version this store writes. */ + val schemaVersion: Int + + /** + * Every stored shop. + * + * @throws ShopStorageException when the data cannot be read at all, which + * stops the feature rather than letting an empty list overwrite it + */ + fun loadAll(): List + + /** Replaces everything on disk with [shops]. */ + fun saveAll(shops: List) +} + +/** Raised when stored shops cannot be read, and must not be silently replaced. */ +class ShopStorageException(message: String, cause: Throwable? = null) : Exception(message, cause) 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 new file mode 100644 index 0000000..251fdee --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/StoredShop.kt @@ -0,0 +1,51 @@ +package net.trilleo.mc.plugins.tritown.shops.storage + +/** + * A shop as it sits on disk. + * + * Deliberately separate from the live model: items are Base64 here rather than + * `ItemStack`s, which keeps the whole storage layer free of Bukkit and therefore + * testable without a server. [net.trilleo.mc.plugins.tritown.shops.ShopManager] + * converts between the two. + * + * Gson builds these by reflection, so every field needs a default — an older + * file that predates a field must still load. + */ +data class StoredShop( + val id: String = "", + val displayName: String = "", + val permission: String? = null, + val towny: String = "NONE", + val hideWhenLocked: Boolean = false, + val npcIds: List = emptyList(), + val entries: List = emptyList(), +) + +/** One entry of a [StoredShop]. */ +data class StoredEntry( + val id: String = "", + val item: String = "", + val buy: StoredCost? = null, + val sell: StoredCost? = null, + val permission: String? = null, + val towny: String = "NONE", + val hideWhenLocked: Boolean = false, + val limitAmount: Int = 0, + val limitPeriod: String = "NONE", + val stockMax: Int = 0, + val stockRestockSeconds: Long = 0L, + val stockRemaining: Int = 0, + val stockLastRestock: Long = 0L, + val discountable: Boolean = true, + val matchMode: String = "EXACT", + val bought: Long = 0L, + val sold: Long = 0L, + val moneyIn: Double = 0.0, + val moneyOut: Double = 0.0, +) + +/** A price or a payout of a [StoredEntry]. */ +data class StoredCost( + val money: Double = 0.0, + val items: List = emptyList(), +) diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/shop/ShopSaveTask.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/shop/ShopSaveTask.kt new file mode 100644 index 0000000..f074f63 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/shop/ShopSaveTask.kt @@ -0,0 +1,34 @@ +package net.trilleo.mc.plugins.tritown.tasks.shop + +import net.trilleo.mc.plugins.tritown.config.ShopSettings +import net.trilleo.mc.plugins.tritown.registration.PluginTask +import net.trilleo.mc.plugins.tritown.shops.ShopManager + +/** + * Writes shop stock and statistics to disk on an interval, off the main thread. + * + * Definitions do not wait for this — an edit is written the moment it is made — + * so the interval only bounds how many purchase counters a hard crash can cost. + * Rewriting the file on every purchase would be the alternative, and a busy + * shop would then write it hundreds of times a minute for no gain. + */ +class ShopSaveTask : PluginTask( + delay = saveIntervalTicks(), + period = saveIntervalTicks(), + async = true, +) { + override fun run() { + ShopManager.flush() + } +} + +/** + * The configured save interval in ticks. + * + * Read at construction, which is safe because the shop settings are loaded in + * `onEnable` before the task registrar runs. + */ +private fun saveIntervalTicks(): Long { + val seconds = if (ShopSettings.isLoaded) ShopSettings.snapshot.saveIntervalSeconds else 60L + return seconds * 20L +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/ChatPrompt.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/ChatPrompt.kt new file mode 100644 index 0000000..6a58a1d --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/ChatPrompt.kt @@ -0,0 +1,77 @@ +package net.trilleo.mc.plugins.tritown.utils + +import net.trilleo.mc.plugins.tritown.Main +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * Asks a player to type something in chat and hands the answer back. + * + * A chest menu has nowhere to type, so anything free-form an editor needs — a + * name, an exact price, a permission node — is asked for in chat instead. The + * menu closes, the question is sent, and the answer reopens whatever comes next. + * + * ### Usage + * + * ```kotlin + * player.closeInventory() + * ChatPrompt.ask(player, player.tr("gui.shop-editor.prompt-price")) { input -> + * val price = input.toDoubleOrNull() ?: return@ask + * entry.buy = ShopCost(price) + * } + * ``` + * + * The answer arrives on the server thread, so a callback may touch Bukkit + * freely. Typing the cancel word, quitting, or being asked something else + * instead drops the pending question without running the callback. + */ +object ChatPrompt { + + private val pending = ConcurrentHashMap Unit>() + + /** + * Asks [player] the question [message], then runs [onInput] with what they type. + * + * Any question already waiting for them is dropped, so two menus cannot + * both be listening at once. + */ + fun ask(player: Player, message: String, onInput: (String) -> Unit) { + pending[player.uniqueId] = onInput + player.sendPrefixed(message) + player.sendPrefixed(player.tr("common.prompt-cancel")) + } + + /** Whether [player] is being asked something. */ + fun isWaiting(player: Player): Boolean = pending.containsKey(player.uniqueId) + + /** Drops any question waiting for [player] without running its callback. */ + fun cancel(player: Player) { + pending.remove(player.uniqueId) + } + + /** + * Feeds [message] to the question [player] was asked. + * + * Called from the chat listener, which runs off the main thread, so the + * callback is handed to the scheduler rather than run where it arrives. + * + * @return `true` when the message was an answer and should not reach chat + */ + fun consume(player: Player, message: String): Boolean { + val callback = pending.remove(player.uniqueId) ?: return false + + val answer = message.trim() + if (answer.equals(CANCEL_WORD, ignoreCase = true)) { + player.sendPrefixed(player.tr("common.prompt-cancelled")) + return true + } + + Bukkit.getScheduler().runTask(Main.instance, Runnable { callback(answer) }) + return true + } + + /** The word a player types to back out of a question. */ + const val CANCEL_WORD = "cancel" +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 7c5c538..1ab7efc 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -96,6 +96,37 @@ economy: roll-size-mb: 16 time-format: "yyyy-MM-dd HH:mm" +# ── Shops ─────────────────────────────────────────────────────────────────── +# Shops the server itself runs. Everything about a shop — what it sells, what it +# costs, who may use it — is set up in game with /tritown shop, not here; these +# are the rules that apply to all of them. +# +# The goods a shop sells are created and the money paid for them leaves the +# economy, so a shop is a sink, a faucet, or both, depending on how it is priced. +shops: + # Turn shops off entirely. Existing shops are left on disk untouched. + enabled: true + # Seconds between writing stock levels and sales figures to disk. A crash + # loses at most this long of them. Changes to a shop itself are always written + # immediately and are never at risk. + save-interval: 60 + # A purchase costing more than this asks the player to confirm first. + # Set to 0 to never ask. + confirm-above: 1000.0 + # What the editor suggests an entry should pay back, as a fraction of its buy + # 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 + + # 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 + # than one gets the largest. + discounts: + has-town: 0.0 + has-nation: 0.0 + is-mayor: 0.0 + is-king: 0.0 + # ── 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 ad8b2c2..90ff4b5 100644 --- a/src/main/resources/lang/en_US.yml +++ b/src/main/resources/lang/en_US.yml @@ -16,6 +16,7 @@ command: economy: "Economy" info: "Info" moderation: "Moderation" + shop: "Shops" help: header: "TriTown Commands" @@ -102,6 +103,35 @@ command: unpinned: "Sidebar back to matching where you stand." unknown-board: "No board named {board}." + shop: + description: "Administer the server's shops" + disabled: "Shops are switched off on this server." + unavailable: "Shops could not be loaded. Check the server log." + no-permission-action: "You don't have permission to use /tritown shop {action}." + players-only: "Only a player can open a menu." + unknown: "There is no shop with the id {id}." + 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." + deleted: "Deleted the shop {id}." + opened: "Opened {id} for {player}." + bound: "{npc} now opens the shop {id}." + unbound: "{npc} no longer opens the shop {id}." + npc-unknown: "There is no NPC named {npc}." + npc-not-bound: "{npc} does not open a shop." + npcs-unavailable: "{plugin} is not installed, so NPCs cannot be bound." + usage: + header: "Shop administration" + list: "/tritown shop list - every shop on the server" + create: "/tritown shop create [name] - start a new shop" + delete: "/tritown shop delete confirm - remove a shop for good" + edit: "/tritown shop edit - change what a shop offers" + open: "/tritown shop open [player] - open a shop for somebody" + bind: "/tritown shop bind - put an NPC behind the counter" + unbind: "/tritown shop unbind - take an NPC off its shop" + stats: "/tritown shop stats - what a shop has traded" + + common: unknown: "unknown" invalid-amount: "That is not a valid amount." @@ -116,6 +146,9 @@ common: # player who has none. none: "-" + prompt-cancel: "Type cancel to leave it as it is." + prompt-cancelled: "Left unchanged." + money: # Plain text only: these also travel to other plugins as Vault's error message, which they print verbatim. error: @@ -142,12 +175,15 @@ money: town-deleted: "{name} was deleted" admin-set: "Set by {admin}" admin-reset: "Reset by {admin}" + shop-buy: "Bought at {shop}" + shop-sell: "Sold at {shop}" # What asked for the transaction. A source TriTown does not know is shown as it was recorded. source: vault: "Another plugin" command: "TriTown command" towny: "Towny" + shop: "Shop" account-type: player: "player" @@ -185,6 +221,170 @@ gui: source: "Source: {source}" time: "{time}" + shop: + title: "{name}" + unknown: "Shop" + empty: "Nothing for sale" + empty-lore: "This shop has no entries yet." + buy: "Buy: {price}" + buy-discounted: "Buy: {price} {full}" + sell: "Sell: {price}" + cost-item: "- {amount}x {item}" + stock: "In stock: {amount}/{max}" + limit: "Left for you: {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" + click-sell-max: "Shift-right-click to sell all you carry" + locked-permission: "You cannot buy this." + locked-has-town: "Only residents of a town may buy this." + locked-no-town: "Only players without a town may buy this." + locked-has-nation: "Only residents of a nation may buy this." + locked-is-mayor: "Only a mayor may buy this." + locked-is-king: "Only a king may buy this." + requirement-none: "Anyone" + requirement-has-town: "In a town" + requirement-no-town: "Without a town" + requirement-has-nation: "In a nation" + requirement-is-mayor: "Mayor" + requirement-is-king: "King" + period-none: "ever" + period-daily: "per day" + period-weekly: "per week" + match-exact: "Exactly this item" + match-material: "Any item of this kind" + + shop-confirm: + title: "Confirm purchase" + amount: "You will receive {amount}" + price: "You will pay {price}" + accept: "Buy" + accept-lore: "Go ahead with the purchase." + cancel: "Cancel" + cancel-lore: "Back to the shop, nothing paid." + + shop-list: + title: "Shops" + empty: "No shops yet" + empty-lore: "Create one with /tritown shop create ." + id: "Id: {id}" + entries: "Entries: {amount}" + npcs: "NPCs: {amount}" + click-edit: "Click to edit" + click-preview: "Shift-click to see it as a player does" + + shop-editor: + title: "Shop editor" + buy: "Sold for:" + sell: "Bought back for:" + not-buyable: "Not for sale" + not-sellable: "Not bought back" + click-entry: "Click to edit this entry" + shift-click-entry: "Shift-click to remove it" + add: "Add an entry" + add-lore: "Click an item in your own inventory, or drag it here. Your item stays where it is." + settings: "Shop settings" + settings-lore: "Name, access and NPCs for {id}." + + shop-entry: + title: "Entry" + bundle: "One purchase gives {amount}" + state: "Now: {state}" + amount: "Now: {amount}" + buyable: "Players may buy this" + sellable: "The shop buys this back" + buy-price: "Price in money" + sell-price: "Payout in money" + buy-items: "Price in items" + sell-items: "Payout in items" + item-count: "Items: {amount}" + limit: "Limit per player" + limit-value: "{amount} {period}" + stock: "Stock" + stock-unlimited: "Unlimited" + stock-value: "{amount}/{max}, refills every {seconds}s" + permission: "Permission needed" + towny: "Standing needed" + hidden: "Hide when locked" + discountable: "Discounts apply" + match: "What counts as this item" + match-lore: "Used when buying the item back from a player." + click-toggle: "Click to switch it over" + click-cycle: "Click for the next, right-click for the previous" + click-set: "Click to type a new value" + click-open: "Click to open" + middle-click-period: "Middle-click to change how often it resets" + right-click-clear: "Right-click to clear it" + prompt-price: "Type the new price in chat." + prompt-permission: "Type the permission node in chat." + 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." + back: "Back" + back-lore: "Return to the shop editor." + + shop-cost: + title: "Items" + click-remove: "Click to remove this" + add: "Add an item" + add-lore: "Click an item in your own inventory, or drag it here. The stack size becomes the quantity." + back: "Back" + back-lore: "Return to the entry." + + shop-settings: + title: "Shop settings" + id: "Id: {id}" + value: "Now: {value}" + permission: "Permission needed" + towny: "Standing needed" + npcs: "Shopkeepers" + npc-count: "Bound NPCs: {amount}" + npc-lore: "Bind one with /tritown shop bind {id} ." + stats: "Sales figures" + stats-lore: "What this shop has traded." + click-rename: "Click to rename the shop" + click-set: "Click to type a new value" + click-cycle: "Click for the next, right-click for the previous" + right-click-clear: "Right-click to clear it" + prompt-name: "Type the shop's new name in chat. MiniMessage is allowed." + prompt-permission: "Type the permission node in chat." + back: "Back" + back-lore: "Return to the shop editor." + + shop-stats: + title: "Sales figures" + bought: "Bought by players: {amount}" + sold: "Sold to the shop: {amount}" + money-in: "Taken in: {amount}" + money-out: "Paid out: {amount}" + net: "Net: {amount}" + reset: "Shift-click to set every figure back to zero" + +# Messages a shop sends to a player, outside the menus themselves. +shop: + traded: "Traded {amount}x {item} for {price}." + traded-items: "Traded {amount}x {item}." + stats-reset: "Cleared the sales figures for {shop}." + + error: + 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." + 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}." + missing-items: "You do not have everything this costs." + missing-goods: "You are not carrying enough of that." + 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." + 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." + + editor: + entry-added: "Added {item} to the shop. Set a price next." + entry-removed: "Removed that entry." + + # 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 51163d6..f001e3d 100644 --- a/src/main/resources/lang/zh_CN.yml +++ b/src/main/resources/lang/zh_CN.yml @@ -16,6 +16,7 @@ command: economy: "经济" info: "信息" moderation: "管理" + shop: "商店" help: header: "TriTown 命令" @@ -102,6 +103,35 @@ command: unpinned: "侧边栏已恢复为根据所在位置自动切换。" unknown-board: "不存在名为 {board} 的板面。" + shop: + description: "管理服务器商店" + disabled: "本服务器已关闭商店功能。" + unavailable: "商店加载失败,请查看服务器日志。" + no-permission-action: "你没有权限使用 /tritown shop {action}。" + players-only: "只有玩家能打开菜单。" + unknown: "没有 id 为 {id} 的商店。" + created: "已创建商店 {id}。" + create-failed: "{id} 已被占用或不是有效的 id。请使用小写字母、数字、短横线和下划线。" + delete-confirm: "删除商店无法撤销。请执行 /tritown shop delete {id} confirm 继续。" + deleted: "已删除商店 {id}。" + opened: "已为 {player} 打开商店 {id}。" + bound: "{npc} 现在会打开商店 {id}。" + unbound: "{npc} 不再打开商店 {id}。" + npc-unknown: "没有名为 {npc} 的 NPC。" + npc-not-bound: "{npc} 没有绑定任何商店。" + npcs-unavailable: "未安装 {plugin},无法绑定 NPC。" + usage: + header: "商店管理" + list: "/tritown shop list - 服务器上的所有商店" + create: "/tritown shop create [name] - 新建一个商店" + delete: "/tritown shop delete confirm - 永久删除一个商店" + edit: "/tritown shop edit - 修改商店的商品" + open: "/tritown shop open [player] - 为某人打开商店" + bind: "/tritown shop bind - 让 NPC 看店" + unbind: "/tritown shop unbind - 解除 NPC 的商店绑定" + stats: "/tritown shop stats - 商店的交易统计" + + common: unknown: "未知" invalid-amount: "这不是有效的金额。" @@ -115,6 +145,9 @@ common: # 当侧边栏变量没有对应的值时显示,例如没有城镇的玩家的城镇名。 none: "-" + prompt-cancel: "输入 cancel 可保持不变。" + prompt-cancelled: "已取消,未做修改。" + money: # 仅限纯文本:这些信息会作为 Vault 的错误信息传给其他插件,并被原样输出。 error: @@ -141,12 +174,15 @@ money: town-deleted: "{name} 已被删除" admin-set: "由 {admin} 设置" admin-reset: "由 {admin} 重置" + shop-buy: "在 {shop} 购买" + shop-sell: "在 {shop} 出售" # 交易的发起方。TriTown 不认识的来源会按记录原样显示。 source: vault: "其他插件" command: "TriTown 命令" towny: "Towny" + shop: "商店" account-type: player: "玩家" @@ -186,6 +222,170 @@ gui: # 侧边栏。显示哪些行、按什么顺序显示由 config.yml 中的各个板面决定;措辞与颜色在此设置。 # 变量写作 %marker%,完整列表见 config.yml。 + shop: + title: "{name}" + unknown: "商店" + empty: "暂无商品" + empty-lore: "这家商店还没有任何商品。" + buy: "购买:{price}" + buy-discounted: "购买:{price} {full}" + sell: "出售:{price}" + cost-item: "- {amount}个 {item}" + stock: "库存:{amount}/{max}" + limit: "你还能买:{amount} ({period})" + click-buy: "左键 购买一份" + click-buy-max: "Shift + 左键 买下能买的全部" + click-sell: "右键 出售一份" + click-sell-max: "Shift + 右键 卖出身上全部" + locked-permission: "你无法购买此商品。" + locked-has-town: "只有城镇居民才能购买。" + locked-no-town: "只有没有城镇的玩家才能购买。" + locked-has-nation: "只有国家成员才能购买。" + locked-is-mayor: "只有镇长才能购买。" + locked-is-king: "只有国王才能购买。" + requirement-none: "所有人" + requirement-has-town: "有城镇" + requirement-no-town: "无城镇" + requirement-has-nation: "有国家" + requirement-is-mayor: "镇长" + requirement-is-king: "国王" + period-none: "总计" + period-daily: "每日" + period-weekly: "每周" + match-exact: "完全相同的物品" + match-material: "同类的任意物品" + + shop-confirm: + title: "确认购买" + amount: "你将获得 {amount}" + price: "你将支付 {price}" + accept: "购买" + accept-lore: "确认并完成这笔交易。" + cancel: "取消" + cancel-lore: "返回商店,不扣任何费用。" + + shop-list: + title: "商店列表" + empty: "还没有商店" + empty-lore: "使用 /tritown shop create 新建一个。" + id: "Id:{id}" + entries: "商品数:{amount}" + npcs: "NPC 数:{amount}" + click-edit: "单击 编辑" + click-preview: "Shift + 单击 以玩家视角查看" + + shop-editor: + title: "商店编辑器" + buy: "售价:" + sell: "回收价:" + not-buyable: "不出售" + not-sellable: "不回收" + click-entry: "单击 编辑这件商品" + shift-click-entry: "Shift + 单击 将其移除" + add: "添加商品" + add-lore: "单击你背包里的物品,或将其拖到这里。你的物品不会被拿走。" + settings: "商店设置" + settings-lore: "{id} 的名称、准入和 NPC。" + + shop-entry: + title: "商品设置" + bundle: "每次购买获得 {amount}" + state: "当前:{state}" + amount: "当前:{amount}" + buyable: "允许玩家购买" + sellable: "商店回收此物" + buy-price: "售价(货币)" + sell-price: "回收价(货币)" + buy-items: "售价(物品)" + sell-items: "回收价(物品)" + item-count: "物品种类:{amount}" + limit: "每人限购" + limit-value: "{amount}({period})" + stock: "库存" + stock-unlimited: "无限" + stock-value: "{amount}/{max},每 {seconds} 秒补货" + permission: "所需权限" + towny: "所需身份" + hidden: "无权限时隐藏" + discountable: "参与折扣" + match: "哪些物品算数" + match-lore: "从玩家手中回收时使用。" + click-toggle: "单击 切换" + click-cycle: "单击 下一个,右键 上一个" + click-set: "单击 输入新的值" + click-open: "单击 打开" + middle-click-period: "中键 切换重置周期" + right-click-clear: "右键 清除" + prompt-price: "请在聊天栏输入新的价格。" + prompt-permission: "请在聊天栏输入权限节点。" + prompt-limit: "请输入每个玩家最多能买多少。0 表示不限。" + prompt-stock: "请输入库存和补货秒数,例如 64 3600。0 表示不限库存。" + back: "返回" + back-lore: "回到商店编辑器。" + + shop-cost: + title: "物品" + click-remove: "单击 移除" + add: "添加物品" + add-lore: "单击你背包里的物品,或将其拖到这里。堆叠数量就是所需数量。" + back: "返回" + back-lore: "回到商品设置。" + + shop-settings: + title: "商店设置" + id: "Id:{id}" + value: "当前:{value}" + permission: "所需权限" + towny: "所需身份" + npcs: "店员" + npc-count: "已绑定 NPC:{amount}" + npc-lore: "使用 /tritown shop bind {id} 绑定。" + stats: "经营数据" + stats-lore: "这家商店的交易情况。" + click-rename: "单击 重命名商店" + click-set: "单击 输入新的值" + click-cycle: "单击 下一个,右键 上一个" + right-click-clear: "右键 清除" + prompt-name: "请在聊天栏输入商店的新名称,可使用 MiniMessage。" + prompt-permission: "请在聊天栏输入权限节点。" + back: "返回" + back-lore: "回到商店编辑器。" + + shop-stats: + title: "经营数据" + bought: "玩家购入:{amount}" + sold: "卖给商店:{amount}" + money-in: "收入:{amount}" + money-out: "支出:{amount}" + net: "净额:{amount}" + reset: "Shift + 单击 将所有数据清零" + +# 商店在菜单之外发给玩家的消息。 +shop: + traded: "以 {price} 成交 {amount}个 {item}。" + traded-items: "成交 {amount}个 {item}。" + stats-reset: "已清空 {shop} 的经营数据。" + + error: + unavailable: "商店暂时不可用。" + locked: "你无法进入这家商店。" + bad-amount: "这家商店无法交易这个数量。" + not-for-sale: "这件商品不出售。" + not-bought: "这家商店不回收此物。" + cannot-afford: "你的钱不够,需要 {price}。" + missing-items: "你缺少所需的物品。" + missing-goods: "你身上的数量不够。" + no-space: "你的背包放不下。" + out-of-stock: "库存只剩 {amount} 份。" + limit-reached: "你最多还能买 {amount} 份。" + economy-unavailable: "经济系统不可用,无法付款。" + payout-refused: "付款失败,交易未完成。" + + editor: + entry-added: "已将 {item} 加入商店,接下来设置价格。" + entry-removed: "已移除该商品。" + + scoreboard: # 服务器名称。各帧之间只有渐变相位不同,循环播放时高光会从名称上扫过。 title: diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimitTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimitTest.kt new file mode 100644 index 0000000..a7d78b6 --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimitTest.kt @@ -0,0 +1,69 @@ +package net.trilleo.mc.plugins.tritown.shops + +import net.trilleo.mc.plugins.tritown.enums.LimitPeriod +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +/** + * A per-player limit has to reset for everybody at once, and a lifetime limit + * must never reset at all. + */ +class ShopLimitTest { + + private val day = 24L * 60L * 60L * 1000L + + /** An arbitrary instant snapped to the start of a week, so both windows begin here. */ + private val start = 1_700_000_000_000L / (7L * day) * (7L * day) + + @Test + fun `a lifetime limit has a single window`() { + val limit = ShopLimit(5, LimitPeriod.NONE) + + assertEquals(limit.windowAt(0L), limit.windowAt(10_000L * day)) + } + + @Test + fun `a daily limit changes window at the day boundary`() { + val limit = ShopLimit(5, LimitPeriod.DAILY) + + assertEquals(limit.windowAt(start), limit.windowAt(start + day - 1)) + assertNotEquals(limit.windowAt(start), limit.windowAt(start + day)) + } + + @Test + fun `a weekly limit changes window at the week boundary`() { + val limit = ShopLimit(5, LimitPeriod.WEEKLY) + + assertEquals(limit.windowAt(start), limit.windowAt(start + 7 * day - 1)) + assertNotEquals(limit.windowAt(start), limit.windowAt(start + 7 * day)) + } + + @Test + fun `what is spent this window counts against the limit`() { + val limit = ShopLimit(5, LimitPeriod.DAILY) + + assertEquals(2, limit.remaining(used = 3, usedWindow = limit.windowAt(start), epochMillis = start)) + } + + @Test + fun `a count from an earlier window is spent and ignored`() { + val limit = ShopLimit(5, LimitPeriod.DAILY) + + assertEquals(5, limit.remaining(used = 5, usedWindow = limit.windowAt(start), epochMillis = start + day)) + } + + @Test + fun `a lifetime limit never gives the allowance back`() { + val limit = ShopLimit(5, LimitPeriod.NONE) + + assertEquals(0, limit.remaining(used = 5, usedWindow = 0L, epochMillis = start + 1000 * day)) + } + + @Test + fun `buying past the limit does not report a negative allowance`() { + val limit = ShopLimit(5, LimitPeriod.DAILY) + + assertEquals(0, limit.remaining(used = 9, usedWindow = limit.windowAt(start), epochMillis = start)) + } +} diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopPricingTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopPricingTest.kt new file mode 100644 index 0000000..b4f393e --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopPricingTest.kt @@ -0,0 +1,63 @@ +package net.trilleo.mc.plugins.tritown.shops + +import net.trilleo.mc.plugins.tritown.enums.TownyRequirement +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Discounts pick the best match rather than adding up, and never round a price up. */ +class ShopPricingTest { + + private val rates = mapOf( + TownyRequirement.HAS_TOWN to 0.05, + TownyRequirement.HAS_NATION to 0.10, + TownyRequirement.IS_MAYOR to 0.20, + ) + + @Test + fun `no standing earns no discount`() { + assertEquals(0.0, ShopPricing.discount(rates, setOf(TownyRequirement.NONE))) + } + + @Test + fun `a matching standing earns its discount`() { + assertEquals(0.05, ShopPricing.discount(rates, setOf(TownyRequirement.NONE, TownyRequirement.HAS_TOWN))) + } + + @Test + fun `several standings earn the best one, not the sum`() { + val standing = setOf(TownyRequirement.HAS_TOWN, TownyRequirement.HAS_NATION, TownyRequirement.IS_MAYOR) + + assertEquals(0.20, ShopPricing.discount(rates, standing)) + } + + @Test + fun `a standing with no rate set earns nothing`() { + assertEquals(0.0, ShopPricing.discount(rates, setOf(TownyRequirement.IS_KING))) + } + + @Test + fun `a discount comes off the price`() { + assertEquals(80.0, ShopPricing.apply(100.0, 0.20, 2)) + } + + @Test + fun `a discounted price rounds down, so the discount is never worth less than it says`() { + assertEquals(6.66, ShopPricing.apply(9.99, 1.0 / 3.0, 2)) + } + + @Test + fun `no discount leaves the price alone`() { + assertEquals(9.99, ShopPricing.apply(9.99, 0.0, 2)) + } + + @Test + fun `a free entry stays free`() { + assertEquals(0.0, ShopPricing.apply(0.0, 0.5, 2)) + } + + @Test + fun `a total is rounded to the currency's scale`() { + assertEquals(10.01, ShopPricing.round(10.005, 2)) + assertEquals(10.0, ShopPricing.round(10.004, 2)) + } +} diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStockTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStockTest.kt new file mode 100644 index 0000000..ac0be53 --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStockTest.kt @@ -0,0 +1,89 @@ +package net.trilleo.mc.plugins.tritown.shops + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** A limited supply has to refill on time, and never past what it started with. */ +class ShopStockTest { + + private val minute = 60_000L + + @Test + fun `first look sets the clock rather than restocking`() { + val stock = ShopStock(max = 10, restockSeconds = 60L, remaining = 4, lastRestock = 0L) + + stock.restock(START) + + assertEquals(4, stock.remaining) + assertEquals(START, stock.lastRestock) + } + + @Test + fun `nothing refills before the period is up`() { + val stock = ShopStock(max = 10, restockSeconds = 60L, remaining = 2, lastRestock = START) + + assertEquals(2, stock.available(START + minute - 1)) + } + + @Test + fun `the period turning over fills it back up`() { + val stock = ShopStock(max = 10, restockSeconds = 60L, remaining = 2, lastRestock = START) + + assertEquals(10, stock.available(START + minute)) + } + + @Test + fun `a long absence fills it once, not once per period`() { + val stock = ShopStock(max = 10, restockSeconds = 60L, remaining = 0, lastRestock = START) + + assertEquals(10, stock.available(START + 100 * minute)) + } + + @Test + fun `the refill time advances by whole periods so it does not drift`() { + val stock = ShopStock(max = 10, restockSeconds = 60L, remaining = 0, lastRestock = START) + + stock.restock(START + 3 * minute + 30_000L) + + assertEquals(START + 3 * minute, stock.lastRestock) + } + + @Test + fun `a supply that never restocks stays where it is`() { + val stock = ShopStock(max = 10, restockSeconds = 0L, remaining = 1, lastRestock = START) + + assertEquals(1, stock.available(START + 1000 * minute)) + } + + @Test + fun `taking more than is there takes nothing`() { + val stock = ShopStock(max = 10, restockSeconds = 0L, remaining = 3, lastRestock = START) + + assertFalse(stock.take(4, START)) + assertEquals(3, stock.remaining) + } + + @Test + fun `taking what is there succeeds`() { + val stock = ShopStock(max = 10, restockSeconds = 0L, remaining = 3, lastRestock = START) + + assertTrue(stock.take(3, START)) + assertEquals(0, stock.remaining) + } + + @Test + fun `putting stock back never goes above the maximum`() { + val stock = ShopStock(max = 10, restockSeconds = 0L, remaining = 9, lastRestock = START) + + stock.restore(5) + + assertEquals(10, stock.remaining) + } + + private companion object { + /** An arbitrary fixed instant, so no test depends on the wall clock. */ + const val START = 1_700_000_000_000L + } +} diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/JsonShopStorageTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/JsonShopStorageTest.kt new file mode 100644 index 0000000..da71c39 --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/JsonShopStorageTest.kt @@ -0,0 +1,130 @@ +package net.trilleo.mc.plugins.tritown.shops.storage + +import java.io.File +import java.nio.file.Files +import java.util.logging.Level +import java.util.logging.Logger +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The shop file is the only copy of work that took an administrator an + * afternoon, so what it must never do is lose it: not to a crash mid-write, not + * to one malformed entry, and not to an older build opening a newer file. + */ +class JsonShopStorageTest { + + private val directory: File = Files.createTempDirectory("tritown-shops").toFile() + private val logger = Logger.getAnonymousLogger().apply { level = Level.OFF } + private val storage = JsonShopStorage(directory, logger) + + private val shopsFile = File(File(directory, "shops"), "shops.json") + private val backupFile = File(File(directory, "shops"), "shops.json.bak") + + @AfterTest + fun cleanUp() { + directory.deleteRecursively() + } + + @Test + fun `a server with no shops starts empty`() { + assertEquals(emptyList(), storage.loadAll()) + } + + @Test + fun `a shop survives a round trip`() { + val shop = StoredShop( + id = "market", + displayName = "Market", + permission = "tritown.shop.market", + towny = "HAS_TOWN", + hideWhenLocked = true, + npcIds = listOf("npc-1", "npc-2"), + entries = listOf( + StoredEntry( + id = "entry-1", + item = "encoded-bread", + buy = StoredCost(12.5, listOf("encoded-iron")), + sell = StoredCost(6.25), + limitAmount = 3, + limitPeriod = "DAILY", + stockMax = 64, + stockRestockSeconds = 3600L, + stockRemaining = 12, + bought = 7L, + moneyIn = 87.5, + ) + ), + ) + + storage.saveAll(listOf(shop)) + + assertEquals(listOf(shop), storage.loadAll()) + } + + @Test + fun `saving replaces what was there rather than adding to it`() { + storage.saveAll(listOf(StoredShop(id = "one"), StoredShop(id = "two"))) + storage.saveAll(listOf(StoredShop(id = "one"))) + + assertEquals(listOf("one"), storage.loadAll().map { it.id }) + } + + @Test + fun `the previous copy is kept as a backup`() { + storage.saveAll(listOf(StoredShop(id = "one"))) + storage.saveAll(listOf(StoredShop(id = "two"))) + + assertTrue(backupFile.exists()) + assertTrue(backupFile.readText().contains("one")) + } + + @Test + fun `a corrupt file falls back to the backup instead of starting empty`() { + storage.saveAll(listOf(StoredShop(id = "one"))) + storage.saveAll(listOf(StoredShop(id = "two"))) + shopsFile.writeText("{ this is not json") + + assertEquals(listOf("one"), storage.loadAll().map { it.id }) + } + + @Test + fun `neither copy being readable is an error, not an empty server`() { + storage.saveAll(listOf(StoredShop(id = "one"))) + storage.saveAll(listOf(StoredShop(id = "two"))) + shopsFile.writeText("{ broken") + backupFile.writeText("also broken") + + assertFailsWith { storage.loadAll() } + } + + @Test + fun `one unreadable shop is skipped rather than taking the rest with it`() { + shopsFile.parentFile.mkdirs() + shopsFile.writeText( + """ + { + "schemaVersion": 1, + "shops": [ + { "id": "good", "displayName": "Good" }, + "not an object", + { "displayName": "No id at all" } + ] + } + """.trimIndent() + ) + + assertEquals(listOf("good"), storage.loadAll().map { it.id }) + } + + @Test + fun `a file from a newer build is refused rather than half-read`() { + shopsFile.parentFile.mkdirs() + shopsFile.writeText("""{ "schemaVersion": ${ShopSchema.CURRENT + 1}, "shops": [] }""") + + assertFailsWith { storage.loadAll() } + } +} From 5892323d149049890f13130126a8f86ab58d1358 Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sat, 19 Sep 2026 10:20:03 +0800 Subject: [PATCH 05/14] Internal: Document the admin shop system Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 33 ++++++-- docs/DEVELOPER_GUIDE.md | 176 ++++++++++++++++++++++++++++++++++++++++ docs/UTILITY_GUIDE.md | 61 ++++++++++++++ 3 files changed, 265 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ac655a4..80b3c19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ overview. | Platform | Paper API 26.2 (MC 26.2) | | Towny | 0.103.2.7 (`towny_version` in gradle.properties) | | Vault API | 1.7.1 (`vault_api_version` in gradle.properties) | +| FancyNpcs API | 2.9.2 (`fancynpcs_version`, optional at runtime) | | Java toolchain | JDK 25 | ## After Every Change: Keep the Changelog and Docs in Sync @@ -56,7 +57,7 @@ Before finishing any task that changes the plugin, do all of the following: ./gradlew startServer # Runs copyPlugin, then launches the paper-*.jar in run/ ``` -The local test server lives in `run/` (gitignored). `copyPlugin` puts the matching Towny jar in `run/plugins/`, but the +The local test server lives in `run/` (gitignored). `copyPlugin` puts the matching Towny and FancyNpcs jars in `run/plugins/`, but the Paper 26.2 jar (`run/paper-*.jar`) and Vault must be downloaded by hand, and `eula.txt` accepted, before `startServer` works. No economy plugin is needed — TriTown supplies the economy itself. The server console reads commands from the terminal running Gradle. @@ -78,9 +79,10 @@ src/main/kotlin/net/trilleo/mc/plugins/tritown/ ├── listeners/ # Event listeners, including Towny events (auto-registered) ├── recipes/ # Recipes (auto-registered, implement PluginRecipe) ├── 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, CountdownUtil, TeamUtil, - # TagUtil, PDCUtil, GameRuleUtil +└── utils/ # Lang, EconomyUtil, 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 @@ -90,8 +92,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/`: it has to be alive in `onLoad`, long before the -registrars run. +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. | Component | Base Class | Package | |:------------|:-------------------------------|:------------------------| @@ -190,6 +192,27 @@ complete stack and no separate economy plugin is needed. See double-count every town deposit. - **Costs and rewards are configurable** — put amounts in `config.yml`, not in Kotlin. +## Working with Shops + +**Shops are the server's own, not a player's.** See [Shops](docs/DEVELOPER_GUIDE.md#shops). + +- **Go through `ShopManager`** — it is the only thing that reads or writes a shop. `save()` for a change to a + definition, which must never be lost; `markDirty()` for stock and statistics, which `ShopSaveTask` flushes. +- **Trade only through `ShopTrade`** — its ordering is what keeps a trade safe: everything that can refuse is asked + before anything is taken, and anything taken is remembered so it can be put back. Never charge and hand over in two + places. +- **Serialize items with `ItemCodec`** — Paper's byte form is the only round-trip that keeps every data component, so a + custom item survives. Never describe an item field by field. +- **Read Towny through `ShopAccess`** — it is the one place shops touch Towny, and it re-reads on every check. +- **Keep FancyNpcs isolated** — only `listeners/shop/ShopNpcListener` may name a FancyNpcs type in a signature, and + only `shops/npc/FancyNpcsAdapter` may touch the API. `ShopNpcBridge` exposes plain types so a server without the + plugin still loads everything else. Adding a FancyNpcs type to its signatures would take the shop command with it. +- **Attribute money with the four-argument `EconomyUtil.withdraw`/`deposit`** so a trade is recorded as a shop movement + rather than an anonymous Vault call. +- **Shop and entry names are administrator-written MiniMessage stored in the shop file**, not translation keys. Escape + anything player-written before embedding it; a shop's own name is deliberately not escaped, because an administrator + wrote it. + ## Versioning & Releases - `plugin_version` in [gradle.properties](gradle.properties) is the single source of truth for the plugin version. It diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 884f52b..5dac762 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -329,8 +329,31 @@ a count, and translate the key yourself there. | `setup` | Yes | Populate the inventory with items before it opens | | `title` | No | Build the title yourself when `titleKey` alone is not enough | | `onClick` | No | Handle click events (clicks are cancelled by default) | +| `onDrag` | No | Handle drag events (drags are cancelled by default) | | `onClose` | No | Handle cleanup when the GUI is closed | +`onClick` and `onDrag` both cancel by default, so a GUI cannot be used to take items out of it. Override either one only +when the menu reads what was clicked or dragged, and cancel the event there too unless the slot genuinely accepts it. + +A GUI that wants a click in the player's *own* inventory — to copy an item out of it, say — has to take it before the +base class does, because `PagedPluginGUI.onClick` ignores anything outside its own inventory: + +```kotlin +override fun onClick(event: InventoryClickEvent) { + val player = event.whoClicked as? Player + if (player != null && event.clickedInventory === player.inventory) { + event.isCancelled = true + event.currentItem?.let { copyIntoMenu(it) } + return + } + super.onClick(event) +} +``` + +Never open another inventory from inside a click handler: the click is still being delivered, and the server and client +end up disagreeing about what is on screen. Schedule it for the next tick instead +(`Bukkit.getScheduler().runTask(Main.instance) { … }`). + ### Opening a GUI Use `GUIManager.open(player, id)` to open a registered GUI for a player: @@ -1764,10 +1787,14 @@ data.set("kills", kills + 1) | `getDouble` | `getDouble(key, default = 0.0)` | Returns a `Double` value | | `getBoolean` | `getBoolean(key, default = false)` | Returns a `Boolean` value | | `getJsonArray` | `getJsonArray(key)` | Returns a `JsonArray` value, or an empty `JsonArray` when absent | +| `getJsonObject`| `getJsonObject(key)` | Returns a `JsonObject` value, or an empty `JsonObject` when absent | | `set` | `set(key, value)` | Stores a `String`, `Int`, `Double`, `Boolean`, `JsonArray`, or `JsonObject` | | `remove` | `remove(key)` | Removes the entry at `key` | | `has` | `has(key)` | Returns `true` when `key` exists | +`getJsonObject` hands back the stored object rather than a copy, so writing to it writes through; call `set` afterwards +anyway, because a key that was absent comes back as a fresh object that nothing is holding. + ### Custom Subclass Extend `PlayerData` to add strongly-typed Kotlin properties: @@ -1860,10 +1887,14 @@ data.set("eventCount", events + 1) | `getDouble` | `getDouble(key, default = 0.0)` | Returns a `Double` value | | `getBoolean` | `getBoolean(key, default = false)` | Returns a `Boolean` value | | `getJsonArray` | `getJsonArray(key)` | Returns a `JsonArray` value, or an empty `JsonArray` when absent | +| `getJsonObject`| `getJsonObject(key)` | Returns a `JsonObject` value, or an empty `JsonObject` when absent | | `set` | `set(key, value)` | Stores a `String`, `Int`, `Double`, `Boolean`, `JsonArray`, or `JsonObject` | | `remove` | `remove(key)` | Removes the entry at `key` | | `has` | `has(key)` | Returns `true` when `key` exists | +`getJsonObject` hands back the stored object rather than a copy, so writing to it writes through; call `set` afterwards +anyway, because a key that was absent comes back as a fresh object that nothing is holding. + ### Custom Subclass Extend `ServerData` to add strongly-typed Kotlin properties: @@ -2294,6 +2325,151 @@ get it later. --- +## Shops + +Admin shops: shops the server itself runs, defined in game and opened by clicking an NPC. The goods are created and the +money paid for them leaves the economy, so there is no shop account behind a shop holding either. + +Everything lives under `shops/`, which is **not a scanned package** — for the same reason `economy/` is not. The +manager has to be alive before the registrars build the menus and commands that read it. + +### The model + +| 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 | +| `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 | +| `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 +MiniMessage and can be changed freely. Entry ids are UUIDs, so reordering or renaming never disturbs a counter. + +An entry's own stack size is the **bundle**: an entry holding 16 bread sells sixteen loaves per click. `bundleSize`, +`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. + +### Preserving an item + +`ItemCodec` wraps Paper's `ItemStack.serializeAsBytes()` / `deserializeBytes()` and Base64s the result. That is the only +round-trip that keeps every data component, so a renamed, enchanted, custom-model or plugin-invented item comes back +exactly as it went in, and Paper upgrades the embedded game version when Minecraft moves on. + +`ItemCodec.decode` returns `null` rather than throwing. One unreadable entry must not take a whole shop with it. + +### Storage + +`plugins/TriTown/shops/shops.json`, written by `JsonShopStorage` through a temporary file with the previous copy kept +as `.bak` — the same approach as `JsonEconomyStorage`. A file that will not parse falls back to the backup rather than +starting empty, because an empty start would be written back over the real data at the next save. + +The storage layer works on `StoredShop` / `StoredEntry` / `StoredCost`, which hold Base64 strings rather than +`ItemStack`s. That keeps it free of Bukkit and therefore testable without a server; `ShopManager` converts between +the stored and live shapes. + +Saving has two speeds, and the difference matters: + +| Call | When | Cost | +|:-----------------------|:-------------------------------------------------|:-----------------------------------------| +| `ShopManager.save()` | A definition changed — an edit that must not be lost | Writes the whole file now | +| `ShopManager.markDirty()` | Stock or statistics changed on a purchase | Nothing; `ShopSaveTask` flushes it later | + +### Trading + +`ShopTrade.buy` and `ShopTrade.sell` are the only places a trade happens, and both run on the server thread because +they touch an inventory. The ordering is what makes them safe: **everything that can refuse is asked before anything is +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. +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. + +A sell is the mirror image. 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. + +### Attribution + +A shop movement must not appear in `/eco history` as an anonymous Vault call, so it goes through the attributed +overloads of `EconomyUtil`: + +```kotlin +val reason = TransactionReason.of(TransactionReason.SHOP_BUY, "shop" to shop.displayName) +EconomyUtil.withdraw(player, quote.money, EconomyContext.SOURCE_SHOP, reason) +``` + +`EconomyContext` is a thread-local, so the attribution reaches the record through the Vault provider on the same thread +without feature code ever naming `EconomyService`. Another plugin's economy keeps no such record and ignores it. + +### Access + +`ShopAccess` is the only place Towny is read, and it is read fresh on every check — a player who joins a town sees the +town's prices without relogging. `standing(player)` is called once per menu render rather than once per entry, because +every entry asks the same questions. + +Gate permissions are written by whoever set the shop up, so they cannot be registered at startup the way a command's +nodes are. They are checked as they stand and defined in the server's permissions plugin. + +### 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 +to sweep counters at midnight. `PlayerDataManager` only serves online players, which is the only case a purchase needs. + +### FancyNpcs + +FancyNpcs is a soft dependency, and the isolation that makes that work is worth understanding before changing it: + +- **`listeners/shop/ShopNpcListener`** is the only class naming a FancyNpcs type in a signature. `PackageScanner` + catches `NoClassDefFoundError` and skips a class it cannot load, so without FancyNpcs this listener simply never + registers. +- **`shops/npc/FancyNpcsAdapter`** is the only other class touching the API. It is `internal` and is reached solely + through `ShopNpcBridge`, so the JVM never resolves it on a server without the plugin. +- **`shops/npc/ShopNpcBridge`** exposes `List` and `String?` and nothing else. Anything that would put a + FancyNpcs type in its signatures would take `ShopCommand` down with it. + +Bindings store `NpcData.getId()`, not the name, so renaming an NPC changes nothing. An NPC opens one shop: binding it +again moves it rather than leaving it ambiguous. + +### The menus + +Every shop menu is in `guis/shop/`, and they all follow the singleton rules the GUI section sets out: which shop is open +is held per viewer in a `ConcurrentHashMap` and cleared in `onClose`, and a paged menu builds its items once +rather than in `getItems`. + +| Menu | What it does | +|:-------------------|:------------------------------------------------------------------------| +| `ShopGUI` | The player's view; buys and sells, and redraws only the entry traded | +| `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 | +| `ShopCostGUI` | The item side of a price or a payout | +| `ShopSettingsGUI` | A shop's name, gate and bound NPCs | +| `ShopStatsGUI` | What a shop has traded | + +`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. + +**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, +and an administrator setting up a shop is usually holding the only copy of what they are adding. + +Anything free-form — a price, a permission node, a shop's name — is asked for in chat through `ChatPrompt`, because +a chest menu has nowhere to type and a price of 12500 is not somewhere to click. + + ## Sidebar The sidebar lives in `net.trilleo.mc.plugins.tritown.scoreboard`, which — like `economy` — is **not** one of the diff --git a/docs/UTILITY_GUIDE.md b/docs/UTILITY_GUIDE.md index 54d31e4..43f8081 100644 --- a/docs/UTILITY_GUIDE.md +++ b/docs/UTILITY_GUIDE.md @@ -11,6 +11,7 @@ reduce boilerplate and provide commonly needed functionality out of the box. | `TagUtil` | Per-player string tag management with player-data persistence | | `Lang` | Translations: per-player language files and the `tr()` helper | | `MessageUtil` | Prefix-decorated message sender for any command sender | +| `ChatPrompt` | Asks a player a question in chat and hands the answer back | | `EconomyUtil` | Economy access: balances, withdrawals, deposits, transfers, formatting | | `PDCUtil` | Persistent data container helpers for Entity, Chunk, and ItemStack | | `GameRuleUtil` | Convenient get, set, and toggle helpers for Minecraft game rules | @@ -504,6 +505,8 @@ EconomyUtil.deposit(player, 100.0) | `has(player, amount)` | Whether the player has at least `amount`. | | `withdraw(player, amount)` | Takes `amount`; returns `false` without charging if the player cannot afford it. | | `deposit(player, amount)` | Gives `amount`; returns `false` if the provider refuses. | +| `withdraw(…, source, reason)`| Takes `amount` and records where it went and why. | +| `deposit(…, source, reason)` | Gives `amount` and records where it came from and why. | | `transfer(from, to, amount)` | Moves `amount` between two players. | | `format(amount)` | Formats `amount` as plain text, the way other plugins print it. | | `formatRich(amount)` | Formats `amount` as a `Component`, using the configured MiniMessage pattern. | @@ -519,6 +522,64 @@ refunds the sender if the deposit fails. Use `format` for anything another plugin will print — it must never contain MiniMessage tags — and `formatRich` for TriTown's own messages. +The four-argument `withdraw` and `deposit` attach a source and a reason to the transaction, so it shows up in +`/eco history` as something other than an anonymous Vault call. A feature with a story to tell should use them rather +than reaching past `EconomyUtil` for the economy service: + +```kotlin +val reason = TransactionReason.of(TransactionReason.SHOP_BUY, "shop" to shop.displayName) +EconomyUtil.withdraw(player, price, EconomyContext.SOURCE_SHOP, reason) +``` + +The attribution travels on the calling thread, so it reaches the record without this having to know which provider won. +Another plugin's economy keeps no such record and ignores it. + +--- + +## ChatPrompt + +A chest menu has nowhere to type, so anything free-form an editor needs — a name, an exact price, a permission node +— is asked for in chat instead. `ChatPrompt` closes that loop: it sends the question, waits for the next thing the +player types, and hands it back. + +The answer arrives on the server thread, so a callback may touch Bukkit freely. Typing `cancel`, quitting, or being +asked something else instead drops the pending question without running the callback, and the answer never reaches the +chat channel. + +### Usage + +```kotlin +import net.trilleo.mc.plugins.tritown.utils.ChatPrompt +import net.trilleo.mc.plugins.tritown.utils.tr + +player.closeInventory() +ChatPrompt.ask(player, player.tr("gui.shop-entry.prompt-price")) { input -> + val price = input.toDoubleOrNull() + if (price == null) { + player.sendPrefixed(player.tr("common.error", "message" to player.tr("common.invalid-amount"))) + } else { + entry.buy = ShopCost(price) + } + ShopEntryGUI.show(player, shop, entry) +} +``` + +Reopen the menu on both paths, as above. A mistyped price should not leave the player standing in the world wondering +where the editor went. + +### Methods + +| Method | Description | +|:----------------------------------|:--------------------------------------------------------------------------| +| `ask(player, message, onInput)` | Sends `message` and runs `onInput` with what the player types next | +| `isWaiting(player)` | Whether the player is being asked something | +| `cancel(player)` | Drops any pending question without running its callback | +| `consume(player, message)` | Feeds a chat message in; used by `ChatPromptListener`, not by features | +| `CANCEL_WORD` | The word a player types to back out | + +Only one question can be waiting for a player at a time: asking a second drops the first, so two menus cannot both be +listening. + --- ## PDCUtil From eb5a5b59b4c9c1771d2455be33f346e7d8b604b7 Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sat, 19 Sep 2026 10:30:48 +0800 Subject: [PATCH 06/14] Backend: Stop copying the FancyNpcs API jar into the test server Only the API is published to Maven, so the copied jar carries no plugin descriptor and a server ignores it. Worse, the delete glob that went with it would have removed a real FancyNpcs installed by hand. It is now downloaded alongside Paper and Vault. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 10 +++++----- README.md | 8 +++++--- build.gradle.kts | 9 +++++---- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 80b3c19..8fb7ec0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ overview. | Platform | Paper API 26.2 (MC 26.2) | | Towny | 0.103.2.7 (`towny_version` in gradle.properties) | | Vault API | 1.7.1 (`vault_api_version` in gradle.properties) | -| FancyNpcs API | 2.9.2 (`fancynpcs_version`, optional at runtime) | +| FancyNpcs API | 2.9.2 (`fancynpcs_version`, API artifact only) | | Java toolchain | JDK 25 | ## After Every Change: Keep the Changelog and Docs in Sync @@ -57,10 +57,10 @@ Before finishing any task that changes the plugin, do all of the following: ./gradlew startServer # Runs copyPlugin, then launches the paper-*.jar in run/ ``` -The local test server lives in `run/` (gitignored). `copyPlugin` puts the matching Towny and FancyNpcs jars in `run/plugins/`, but the -Paper 26.2 jar (`run/paper-*.jar`) and Vault must be downloaded by hand, and `eula.txt` accepted, before `startServer` -works. No economy plugin is needed — TriTown supplies the economy itself. The server console reads commands from the -terminal running Gradle. +The local test server lives in `run/` (gitignored). `copyPlugin` puts the matching Towny jar in `run/plugins/`, but the +Paper 26.2 jar (`run/paper-*.jar`), Vault, and FancyNpcs (Maven carries its API only) must be downloaded by hand, and +`eula.txt` accepted, before `startServer` works. No economy plugin is needed — TriTown supplies the economy itself. +The server console reads commands from the terminal running Gradle. ## Repository Layout diff --git a/README.md b/README.md index 8f26655..1eea45b 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,14 @@ FancyNpcs is optional too: without it shops still work, they just cannot be open ./gradlew build ``` -The compiled JAR is placed in `build/libs/`. Run `./gradlew copyPlugin` to copy it, along with the matching Towny and -FancyNpcs jars, into `run/plugins/` for the local test server, or `./gradlew startServer` to copy them and start the -server. Before the first start: +The compiled JAR is placed in `build/libs/`. Run `./gradlew copyPlugin` to copy it, along with the matching Towny jar, +into `run/plugins/` for the local test server, or `./gradlew startServer` to copy them and start the server. Before the +first start: - download a Paper 26.2 jar from [papermc.io](https://papermc.io/downloads/paper) into `run/`; - put Vault into `run/plugins/`; +- put [FancyNpcs](https://modrinth.com/plugin/fancynpcs) into `run/plugins/` as well, to test shop NPCs — only its API + is published to Maven, so the plugin itself is not fetched by the build; - accept the EULA in `run/eula.txt` after the first launch. Prebuilt jars are attached to every [GitHub release](https://github.com/Trilleo/TriTown/releases). diff --git a/build.gradle.kts b/build.gradle.kts index dfb5cea..5fcde2f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,12 +38,12 @@ dependencies { compileOnly("com.github.MilkBowl:VaultAPI:${providers.gradleProperty("vault_api_version").get()}") { isTransitive = false } - // Optional at runtime: NPCs open shops when FancyNpcs is installed, and the feature stays off when it is not. + // API only, and optional at runtime: NPCs open shops when FancyNpcs is installed, and the feature stays off when + // it is not. The plugin jar itself is not on Maven and has to be installed by hand. compileOnly("de.oliver:FancyNpcs:${providers.gradleProperty("fancynpcs_version").get()}") { isTransitive = false } serverPlugins("com.palmergames.bukkit.towny:towny:${providers.gradleProperty("towny_version").get()}") - serverPlugins("de.oliver:FancyNpcs:${providers.gradleProperty("fancynpcs_version").get()}") testImplementation(kotlin("test")) // Aligned with what Paper 26.2 bundles, since the plugin uses these at runtime through paper-api. testImplementation("net.kyori:adventure-api:5.2.0") @@ -81,10 +81,11 @@ tasks.jar { } } -// Copies the depended-on plugins into the test server, replacing any older version left behind by a version bump. +// Copies Towny into the test server, replacing any other Towny version left behind by a version bump. +// FancyNpcs is not copied: only its API is published to Maven, and the plugin itself comes from Modrinth. tasks.register("copyServerPlugins") { val pluginsDir = layout.projectDirectory.dir("run/plugins") - doFirst { delete(fileTree(pluginsDir) { include("towny-*.jar", "FancyNpcs-*.jar") }) } + doFirst { delete(fileTree(pluginsDir) { include("towny-*.jar") }) } from(serverPlugins) into(pluginsDir) } From 04a67818b447ec65b03742e4eb1388ac7033ba83 Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sat, 19 Sep 2026 10:52:38 +0800 Subject: [PATCH 07/14] Improvement: Frame the shop menus and fix the editor's buttons in place Paged GUIs gain a framed layout and a way to put their own buttons in the navigation row. The shop menus use both: the editor's actions no longer shift along as entries are added, because they sit in a row that never moves. How many items a purchase hands over is now a setting of its own rather than the template's stack size, so it can exceed what one stack holds. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 5 + README.md | 2 +- docs/DEVELOPER_GUIDE.md | 59 +++++++- .../mc/plugins/tritown/enums/PagedLayout.kt | 22 +++ .../plugins/tritown/guis/shop/ShopCostGUI.kt | 38 +++-- .../tritown/guis/shop/ShopEditorGUI.kt | 66 +++++++-- .../plugins/tritown/guis/shop/ShopEntryGUI.kt | 35 +++++ .../mc/plugins/tritown/guis/shop/ShopGUI.kt | 7 +- .../plugins/tritown/guis/shop/ShopListGUI.kt | 7 +- .../plugins/tritown/guis/shop/ShopStatsGUI.kt | 6 +- .../plugins/tritown/registration/GUIFrame.kt | 56 +++++++ .../tritown/registration/PagedPluginGUI.kt | 138 +++++++++++++----- .../mc/plugins/tritown/shops/ShopEntry.kt | 25 +++- .../mc/plugins/tritown/shops/ShopManager.kt | 3 + .../tritown/shops/storage/StoredShop.kt | 1 + src/main/resources/lang/en_US.yml | 8 + src/main/resources/lang/zh_CN.yml | 8 + .../tritown/registration/GUIFrameTest.kt | 52 +++++++ 18 files changed, 451 insertions(+), 87 deletions(-) create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/PagedLayout.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIFrame.kt create mode 100644 src/test/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIFrameTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ceb40a..4e6cf7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and carry, right-click sells one, and shift-right-click sells everything you are carrying. + A price can be money, items, or both at once, and so can a payout — so a shop can sell for currency, barter, or ask for a fee alongside the materials. + + How many items one purchase hands over is set per entry, and is not capped at a stack: a bundle of 128 bread is + handed over as two stacks. + An entry can have a stock that refills on a timer, a per-player limit that resets daily, weekly or never, or neither. Both are shown on the item, counting down as players buy. + Entries and whole shops can be locked behind a permission node or a standing in Towny — being in a town or a @@ -50,6 +52,9 @@ + GUIs can now handle a drag through `onDrag`, which cancels the drag by default. The GUI manager also forgets a player who quits with a menu open. ++ Paged GUIs gained `PagedLayout.FRAMED`, which insets the content and draws a border around it, and `navButtons`, + which puts a menu's own actions in the fixed navigation row instead of after the last item where they move as the + list grows. `contentIndex` turns a clicked slot into a position in the item list, which a framed layout needs. + `PlayerData` and `ServerData` gained `getJsonObject`, so a nested object that was written can be read back. + Added `ChatPrompt`, which asks a player a question in chat and hands the answer back on the server thread. Menus use it for anything that has to be typed, such as a price or a permission node. diff --git a/README.md b/README.md index 1eea45b..148bc5a 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ A shop is created with `/tt shop create `, which opens its editor. Everythin - **Adding what it sells.** Click an item in your own inventory, or drag it over the menu. Nothing leaves your inventory — the item is copied, with every property it has, so a renamed and enchanted sword goes on the shelf as that exact sword. The stack size you click becomes the bundle: click a stack of 16 bread and one purchase is 16 - loaves. + loaves. The bundle can be changed afterwards, and is not limited to a stack — 128 bread is handed over as two. - **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. diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 5dac762..faeebd5 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -452,6 +452,32 @@ For example, a 6-row GUI provides 45 content slots per page (rows 1–5). | `rows` | `Int` | `6` | Number of rows (2–6, each row = 9 slots) | | `fillMode` | `FillMode` | `FillMode.NONE` | Controls background filler; re-applied on every page render, not just initial open | | `mode` | `PagedGUIMode` | `PagedGUIMode.LIST` | Controls how items are supplied — see [Modes](#modes) below | +| `layout` | `PagedLayout` | `PagedLayout.FULL` | Whether the content area fills the menu or sits inside a border | + +### Layouts + +| Layout | Content slots (6 rows) | Description | +|:---------------------|:-----------------------|:----------------------------------------------------------------------------| +| `PagedLayout.FULL` | 45 | Every slot above the navigation row is content | +| `PagedLayout.FRAMED` | 28 | Content is inset by one slot on every side, with a black glass border round it | + +A framed menu carries the border into its navigation row too, so the whole edge is one colour rather than changing +where the controls start. + +**Do not index `getItems` by the raw slot.** Under a framed layout a slot is not a position in that list, because the +border sits between them. Use `contentIndex(page, rawSlot)`, which returns the position or `null` when the slot holds +no content: + +```kotlin +override fun onContentClick(event: InventoryClickEvent, page: Int) { + event.isCancelled = true + val index = contentIndex(page, event.rawSlot) ?: return + val entry = entries.getOrNull(index) ?: return + … +} +``` + +`pageSize` is how many items one page holds, should a subclass need it. ### Modes @@ -460,15 +486,19 @@ For example, a 6-row GUI provides 45 content slots per page (rows 1–5). | Mode | Override | Description | |:--------------------|:--------------|:-------------------------------------------------------------------------------------------------| | `PagedGUIMode.LIST` | `getItems` | Items are provided as a flat list and distributed automatically across pages (one item per slot) | -| `PagedGUIMode.SET` | `getSetItems` | Items are placed manually by page and slot, giving full control over each item's exact position | +| `PagedGUIMode.SET` | `getSetItems` | Items are placed manually by page and position, giving full control over each item's placement | ### Methods to Override | Method | Mode | Required | Description | |:-----------------|:-------|:---------|:-----------------------------------------------------------------| | `getItems` | `LIST` | Yes | Return the full list of items to paginate for a player | -| `getSetItems` | `SET` | Yes | Return a map of `page → (slot → item)` for manual placement | +| `getSetItems` | `SET` | Yes | Return a map of `page → (position → item)` for manual placement | | `onContentClick` | Both | No | Handle clicks on content slots (clicks are cancelled by default) | +| `navButtons` | Both | No | Buttons to place in the navigation row, keyed by offset | +| `onNavClick` | Both | No | Handle clicks on those buttons | + +A `SET` position is an index into the content area, not an inventory slot, for the same reason `contentIndex` exists. You do **not** need to override `setup`, `onClick`, or `onClose` — `PagedPluginGUI` handles them internally for pagination. If you need custom close logic, override `onClose` and call `super.onClose(event)` to ensure page state is @@ -484,6 +514,22 @@ The last row of the inventory contains: | 4 | Paper | **Page indicator** — displays "Page X/Y" | | 8 | Arrow | **Next Page** — hidden on the last page | +Offsets 1, 2, 3, 5, 6 and 7 are free, and `navButtons` puts a GUI's own actions there: + +```kotlin +override fun navButtons(player: Player): Map = mapOf( + 2 to itemStack(Material.COMPARATOR) { name(player.tr("gui.rewards.settings")) }, +) + +override fun onNavClick(event: InventoryClickEvent, offset: Int) { + if (offset == 2) openSettings(event.whoClicked as? Player ?: return) +} +``` + +**Put an action here rather than at the end of the content.** A button appended to the item list moves every time the +list grows, so the thing an administrator clicks most sits somewhere new after every edit. The navigation row never +moves. Anything placed at a reserved offset is ignored. + ### Example (LIST mode) ```kotlin @@ -2348,9 +2394,10 @@ manager has to be alive before the registrars build the menus and commands that `id` is stable and is what NPC bindings and purchase counters are keyed by; `displayName` is administrator-written MiniMessage and can be changed freely. Entry ids are UUIDs, so reordering or renaming never disturbs a counter. -An entry's own stack size is the **bundle**: an entry holding 16 bread sells sixteen loaves per click. `bundleSize`, +`bundle` is how many items one purchase moves, and it is deliberately **not** the template's stack size: a bundle may +exceed what a stack holds, and an `ItemStack` is not a safe place to keep a count of 128. `bundleSize`, `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. +allows, which is what is both measured for room and handed over, while the first two are for drawing. ### Preserving an item @@ -2459,6 +2506,10 @@ rather than in `getItems`. | `ShopSettingsGUI` | A shop's name, gate and bound NPCs | | `ShopStatsGUI` | What a shop has traded | +Every one of them is framed: the paged menus through `PagedLayout.FRAMED`, and `ShopCostGUI`, which lays out its own +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. diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/PagedLayout.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/PagedLayout.kt new file mode 100644 index 0000000..e0e1e14 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/PagedLayout.kt @@ -0,0 +1,22 @@ +package net.trilleo.mc.plugins.tritown.enums + +/** + * How a paged GUI arranges its content area. + * + * The navigation row is reserved either way; this only decides what the rest of + * the inventory does with the space above it. + */ +enum class PagedLayout { + + /** Every slot above the navigation row holds content. The most room, and no border. */ + FULL, + + /** + * Content sits in an inset block with a one-slot border around it. + * + * Costs the outer ring of slots — a six-row menu holds 28 items a page + * rather than 45 — and buys a menu that reads as a thing rather than as a + * grid of loose items. + */ + FRAMED, +} 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 1ba35e2..f81523a 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 @@ -1,6 +1,7 @@ package net.trilleo.mc.plugins.tritown.guis.shop 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.shops.ShopCost @@ -32,7 +33,7 @@ import java.util.concurrent.ConcurrentHashMap class ShopCostGUI : PluginGUI( id = ID, titleKey = "gui.shop-cost.title", - rows = 6, + rows = ROWS, fillMode = FillMode.NONE, ) { @@ -50,8 +51,10 @@ class ShopCostGUI : PluginGUI( val (_, entry, buying) = resolve(player) ?: return inventory.clear() + GUIFrame.draw(inventory, CONTENT_SLOTS) + cost(entry, buying).items.forEachIndexed { index, item -> - if (index < CONTENT_SLOTS) inventory.setItem(index, describe(player, item)) + CONTENT_SLOTS.getOrNull(index)?.let { inventory.setItem(it, describe(player, item)) } } inventory.setItem( @@ -76,19 +79,20 @@ class ShopCostGUI : PluginGUI( return } - when (val slot = event.rawSlot) { - SLOT_BACK -> ShopRender.navigate { ShopEntryGUI.show(player, shop, entry) } + if (event.rawSlot == SLOT_BACK) { + ShopRender.navigate { ShopEntryGUI.show(player, shop, entry) } + return + } - in 0 until CONTENT_SLOTS -> { - val items = cost(entry, buying).items - if (slot >= items.size) return - apply(entry, buying, items.filterIndexed { index, _ -> index != slot }) - ShopManager.save() - setup(player, event.inventory) - } + val position = CONTENT_SLOTS.indexOf(event.rawSlot) + if (position < 0) return - else -> return - } + val items = cost(entry, buying).items + if (position >= items.size) return + + apply(entry, buying, items.filterIndexed { index, _ -> index != position }) + ShopManager.save() + setup(player, event.inventory) } override fun onDrag(event: InventoryDragEvent) { @@ -108,7 +112,7 @@ class ShopCostGUI : PluginGUI( if (stack.type.isAir) return val items = cost(entry, buying).items - if (items.size >= CONTENT_SLOTS) return + if (items.size >= CONTENT_SLOTS.size) return apply(entry, buying, items + stack.clone()) ShopManager.save() @@ -144,8 +148,10 @@ class ShopCostGUI : PluginGUI( companion object { const val ID = "shop-cost" - /** Everything but the last row, which carries the way back out. */ - private const val CONTENT_SLOTS = 45 + /** The slots inside the border; the last row carries the way back out. */ + private val CONTENT_SLOTS = GUIFrame.contentSlots(ROWS) + + private const val ROWS = 6 private const val SLOT_BACK = 45 private const val SLOT_HINT = 49 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 955bfb6..a90d1ae 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 @@ -1,6 +1,7 @@ package net.trilleo.mc.plugins.tritown.guis.shop import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.enums.PagedLayout import net.trilleo.mc.plugins.tritown.registration.GUIManager import net.trilleo.mc.plugins.tritown.registration.PagedPluginGUI import net.trilleo.mc.plugins.tritown.shops.ShopCost @@ -29,12 +30,16 @@ import java.util.concurrent.ConcurrentHashMap * all, and stays where it was. An editor that took the item would lose it to a * crash or a mistimed close, and an administrator setting up a shop is usually * holding the only copy of whatever they are adding. + * + * The actions live in the navigation row rather than after the last entry, so + * they stay under the same finger however many entries the shop grows. */ class ShopEditorGUI : PagedPluginGUI( id = ID, titleKey = "gui.shop-editor.title", rows = 6, fillMode = FillMode.NONE, + layout = PagedLayout.FRAMED, ) { private val editing = ConcurrentHashMap() @@ -47,7 +52,28 @@ class ShopEditorGUI : PagedPluginGUI( override fun getItems(player: Player): List { val shop = shopOf(player) ?: return emptyList() - return shop.entries.map { entry -> icon(player, entry) } + settingsButton(player, shop) + hint(player) + return shop.entries.map { entry -> icon(player, entry) } + } + + override fun navButtons(player: Player): Map { + val shop = shopOf(player) ?: return emptyMap() + return mapOf( + SLOT_ADD to button(player, Material.PAPER, "gui.shop-editor.add", "gui.shop-editor.add-lore"), + SLOT_SETTINGS to settingsButton(player, shop), + SLOT_STATS to button(player, Material.WRITABLE_BOOK, "gui.shop-editor.stats", "gui.shop-editor.stats-lore"), + SLOT_LIST to button(player, Material.ARROW, "gui.shop-editor.back", "gui.shop-editor.back-lore"), + ) + } + + override fun onNavClick(event: InventoryClickEvent, offset: Int) { + val player = event.whoClicked as? Player ?: return + val shop = shopOf(player) ?: return + + when (offset) { + SLOT_SETTINGS -> ShopRender.navigate { ShopSettingsGUI.show(player, shop) } + SLOT_STATS -> ShopRender.navigate { ShopStatsGUI.show(player, shop) } + SLOT_LIST -> ShopRender.navigate { ShopListGUI.show(player) } + } } /** @@ -73,12 +99,9 @@ class ShopEditorGUI : PagedPluginGUI( val player = event.whoClicked as? Player ?: return val shop = shopOf(player) ?: return - val index = page * CONTENT_SLOTS + event.rawSlot - when (index) { - shop.entries.size -> ShopRender.navigate { ShopSettingsGUI.show(player, shop) } - in shop.entries.indices -> click(event.click, player, shop, shop.entries[index]) - else -> return - } + val index = contentIndex(page, event.rawSlot) ?: return + val entry = shop.entries.getOrNull(index) ?: return + click(event.click, player, shop, entry) } override fun onDrag(event: InventoryDragEvent) { @@ -106,11 +129,20 @@ class ShopEditorGUI : PagedPluginGUI( ShopRender.navigate { ShopEntryGUI.show(player, shop, entry) } } - /** Adds [stack] as a new entry, priced at nothing until the administrator sets a price. */ + /** + * Adds [stack] as a new entry, priced at nothing until the administrator sets a price. + * + * The stack size clicked becomes the bundle, so putting a stack of 16 bread + * on the shelf sells sixteen loaves at a time without any further setting up. + */ private fun add(player: Player, shop: ShopDefinition, stack: ItemStack) { if (stack.type.isAir) return - val entry = ShopEntry(item = stack.clone(), buy = ShopCost.FREE) + val entry = ShopEntry( + item = stack.clone().apply { amount = 1 }, + bundle = stack.amount.coerceAtLeast(1), + buy = ShopCost.FREE, + ) shop.entries += entry ShopManager.save() @@ -120,6 +152,7 @@ class ShopEditorGUI : PagedPluginGUI( private fun icon(player: Player, entry: ShopEntry): ItemStack { val lore = buildList { + add(player.tr("gui.shop-editor.bundle", "amount" to entry.bundleSize)) if (entry.isBuyable) addAll(buyLines(player, entry)) else add(player.tr("gui.shop-editor.not-buyable")) if (entry.isSellable) addAll(sellLines(player, entry)) else add(player.tr("gui.shop-editor.not-sellable")) add(player.tr("gui.shop-editor.click-entry")) @@ -140,17 +173,22 @@ class ShopEditorGUI : PagedPluginGUI( meta { lore(LoreUtil.wrapLore(player.tr("gui.shop-editor.settings-lore", "id" to shop.id))) } } - private fun hint(player: Player): ItemStack = itemStack(Material.PAPER) { - name(player.tr("gui.shop-editor.add")) - meta { lore(LoreUtil.wrapLore(player.tr("gui.shop-editor.add-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 shopOf(player: Player): ShopDefinition? = editing[player.uniqueId]?.let(ShopManager::get) companion object { const val ID = "shop-editor" - private const val CONTENT_SLOTS = 45 + // Offsets in the navigation row; 0, 4 and 8 belong to the page controls. + private const val SLOT_ADD = 1 + private const val SLOT_SETTINGS = 2 + private const val SLOT_STATS = 3 + private const val SLOT_LIST = 5 /** Opens the editor for [shop] through the registered instance. */ fun show(player: Player, shop: ShopDefinition): Boolean { 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 98d6405..e7fb321 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 @@ -58,6 +58,7 @@ class ShopEntryGUI : PluginGUI( val (_, entry) = resolve(player) ?: return inventory.setItem(SLOT_GOODS, goods(player, entry)) + inventory.setItem(SLOT_BUNDLE, bundle(player, entry)) inventory.setItem(SLOT_BUY_TOGGLE, toggle(player, entry.isBuyable, "gui.shop-entry.buyable")) inventory.setItem(SLOT_BUY_MONEY, money(player, entry.buy, "gui.shop-entry.buy-price")) @@ -92,6 +93,7 @@ class ShopEntryGUI : PluginGUI( SLOT_SELL_MONEY -> return askMoney(player, shop, entry, buying = false, clear = clear) 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_STOCK -> return editStock(player, shop, entry, clear) SLOT_PERMISSION -> return askPermission(player, shop, entry, clear) @@ -155,6 +157,25 @@ class ShopEntryGUI : PluginGUI( } } + /** + * Asks how many items one purchase should hand over. + * + * Not capped at a stack: a bundle of 128 bread is handed over as two stacks, + * and refusing it would only make an administrator create two entries for + * what is one thing being sold. + */ + private fun editBundle(player: Player, shop: ShopDefinition, entry: ShopEntry) { + prompt(player, shop, entry, player.tr("gui.shop-entry.prompt-bundle")) { input -> + val amount = input.toIntOrNull() + if (amount == null || amount < 1) { + player.sendPrefixed(player.tr("common.error", "message" to player.tr("common.invalid-amount"))) + } else { + entry.bundle = amount + ShopManager.save() + } + } + } + /** 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) { when (click) { @@ -247,6 +268,19 @@ class ShopEntryGUI : PluginGUI( listOf(player.tr("gui.shop-entry.bundle", "amount" to entry.bundleSize)), ) + private fun bundle(player: Player, entry: ShopEntry): ItemStack = itemStack(Material.PAPER) { + name(player.tr("gui.shop-entry.bundle-size")) + meta { + lore( + LoreUtil.wrapLore( + player.tr("gui.shop-entry.amount", "amount" to entry.bundleSize) + + "" + player.tr("gui.shop-entry.bundle-lore") + + "" + player.tr("gui.shop-entry.click-set") + ) + ) + } + } + private fun toggle(player: Player, on: Boolean, nameKey: String): ItemStack = itemStack(if (on) Material.LIME_DYE else Material.GRAY_DYE) { name(player.tr(nameKey)) @@ -398,6 +432,7 @@ class ShopEntryGUI : PluginGUI( private const val MONEY_SCALE = 2 private const val SLOT_GOODS = 4 + private const val SLOT_BUNDLE = 13 private const val SLOT_BUY_TOGGLE = 19 private const val SLOT_BUY_MONEY = 20 private const val SLOT_BUY_ITEMS = 21 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 a9415a4..a7fa88c 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 @@ -5,6 +5,7 @@ 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 import net.trilleo.mc.plugins.tritown.registration.GUIManager import net.trilleo.mc.plugins.tritown.registration.PagedPluginGUI @@ -45,6 +46,7 @@ class ShopGUI : PagedPluginGUI( titleKey = "gui.shop.title", rows = 6, fillMode = FillMode.NONE, + layout = PagedLayout.FRAMED, ) { /** @@ -79,7 +81,7 @@ class ShopGUI : PagedPluginGUI( val view = views[player.uniqueId] ?: return val shop = ShopManager.get(view.shopId) ?: return - val index = page * CONTENT_SLOTS + event.rawSlot + val index = contentIndex(page, event.rawSlot) ?: return val entry = view.entryIds.getOrNull(index)?.let(shop::entry) ?: return val result = when (event.click) { @@ -262,9 +264,6 @@ class ShopGUI : PagedPluginGUI( companion object { const val ID = "shop" - /** Content slots on one page: everything but the navigation row. */ - private const val CONTENT_SLOTS = 45 - /** Opens [shop] for [viewer] through the registered instance. */ fun show(viewer: Player, shop: ShopDefinition): Boolean { val gui = GUIManager.getGUI(ID) as? ShopGUI ?: return false 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 cdf1cdc..0df430b 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 @@ -1,6 +1,7 @@ package net.trilleo.mc.plugins.tritown.guis.shop import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.enums.PagedLayout import net.trilleo.mc.plugins.tritown.registration.GUIManager import net.trilleo.mc.plugins.tritown.registration.PagedPluginGUI import net.trilleo.mc.plugins.tritown.shops.ShopDefinition @@ -25,6 +26,7 @@ class ShopListGUI : PagedPluginGUI( titleKey = "gui.shop-list.title", rows = 6, fillMode = FillMode.NONE, + layout = PagedLayout.FRAMED, ) { override fun getItems(player: Player): List { @@ -45,7 +47,8 @@ class ShopListGUI : PagedPluginGUI( event.isCancelled = true val player = event.whoClicked as? Player ?: return - val shop = ShopManager.all().getOrNull(page * CONTENT_SLOTS + event.rawSlot) ?: return + val index = contentIndex(page, event.rawSlot) ?: return + val shop = ShopManager.all().getOrNull(index) ?: return ShopRender.navigate { if (event.click == ClickType.SHIFT_LEFT) ShopGUI.show(player, shop) else ShopEditorGUI.show(player, shop) @@ -70,8 +73,6 @@ class ShopListGUI : PagedPluginGUI( companion object { const val ID = "shop-list" - private const val CONTENT_SLOTS = 45 - /** Opens the list for [player] through the registered instance. */ fun show(player: Player): Boolean = GUIManager.open(player, ID) } 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 e66c71a..e3678bb 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 @@ -1,6 +1,7 @@ package net.trilleo.mc.plugins.tritown.guis.shop import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.enums.PagedLayout import net.trilleo.mc.plugins.tritown.registration.GUIManager import net.trilleo.mc.plugins.tritown.registration.PagedPluginGUI import net.trilleo.mc.plugins.tritown.shops.ShopDefinition @@ -31,6 +32,7 @@ class ShopStatsGUI : PagedPluginGUI( titleKey = "gui.shop-stats.title", rows = 6, fillMode = FillMode.NONE, + layout = PagedLayout.FRAMED, ) { private val viewing = ConcurrentHashMap() @@ -52,7 +54,7 @@ class ShopStatsGUI : PagedPluginGUI( val player = event.whoClicked as? Player ?: return val shop = shopOf(player) ?: return - val index = page * CONTENT_SLOTS + event.rawSlot + val index = contentIndex(page, event.rawSlot) ?: return if (index != 0 || event.click != ClickType.SHIFT_LEFT) return shop.entries.forEach { it.stats.reset() } @@ -103,8 +105,6 @@ class ShopStatsGUI : PagedPluginGUI( companion object { const val ID = "shop-stats" - private const val CONTENT_SLOTS = 45 - /** Opens a shop's figures through the registered instance. */ fun show(player: Player, shop: ShopDefinition): Boolean { val gui = GUIManager.getGUI(ID) as? ShopStatsGUI ?: return false diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIFrame.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIFrame.kt new file mode 100644 index 0000000..dad67c1 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIFrame.kt @@ -0,0 +1,56 @@ +package net.trilleo.mc.plugins.tritown.registration + +import net.trilleo.mc.plugins.tritown.utils.itemStack +import org.bukkit.Material +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack + +/** + * The border drawn around a menu's content, and the slots it leaves free. + * + * Shared rather than living in [PagedPluginGUI], because a plain [PluginGUI] + * that lays out a grid of its own wants exactly the same border and the same + * idea of which slots are inside it. + */ +object GUIFrame { + + private const val ROW_SIZE = 9 + + /** A single border slot: black glass with no name and no tooltip. */ + fun pane(): ItemStack = itemStack(Material.BLACK_STAINED_GLASS_PANE) { + name(" ") + hideTooltip(true) + } + + /** + * The slots inside the border, in reading order. + * + * The top and bottom rows and the first and last column are the border, so + * content is everything in between. A menu that wants the bottom row for + * navigation or buttons of its own simply draws over it: that row is the + * bottom edge either way, and is never content. + */ + fun contentSlots(rows: Int): List { + if (rows < 3) return emptyList() + + return buildList { + for (row in 1..rows - 2) { + for (column in 1 until ROW_SIZE - 1) add(row * ROW_SIZE + column) + } + } + } + + /** + * Fills every slot of [inventory] that is not in [contentSlots] with the border. + * + * Callers draw their own buttons over it afterwards, so a reserved row can be + * bordered first and then written into. + */ + fun draw(inventory: Inventory, contentSlots: Collection) { + val inside = contentSlots.toSet() + val pane = pane() + for (slot in 0 until inventory.size) { + if (slot !in inside) inventory.setItem(slot, pane.clone()) + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PagedPluginGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PagedPluginGUI.kt index 322110d..45d8f73 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PagedPluginGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PagedPluginGUI.kt @@ -4,6 +4,7 @@ import net.kyori.adventure.key.Key import net.kyori.adventure.sound.Sound import net.trilleo.mc.plugins.tritown.enums.FillMode import net.trilleo.mc.plugins.tritown.enums.PagedGUIMode +import net.trilleo.mc.plugins.tritown.enums.PagedLayout import net.trilleo.mc.plugins.tritown.utils.itemStack import net.trilleo.mc.plugins.tritown.utils.tr import org.bukkit.Material @@ -22,9 +23,11 @@ import java.util.* * `net.trilleo.mc.plugins.tritown.guis` package (or any subpackage) to * have it automatically discovered and registered at startup. * - * The bottom row of the inventory is reserved for navigation controls. - * Content slots are every slot **except** the last row. For example, a - * 6-row GUI provides 45 content slots per page (rows 1–5). + * The bottom row of the inventory is reserved for navigation controls, and + * [layout] decides what the rest of it does. [PagedLayout.FULL] fills every slot + * above that row with content — a 6-row GUI gives 45 content slots per page. + * [PagedLayout.FRAMED] insets the content by one slot on every side, giving 28 + * instead and a border around them. * * The class must have either: * - A no-arg constructor, **or** @@ -89,7 +92,8 @@ abstract class PagedPluginGUI( titleKey: String, rows: Int = 6, fillMode: FillMode = FillMode.NONE, - val mode: PagedGUIMode = PagedGUIMode.LIST + val mode: PagedGUIMode = PagedGUIMode.LIST, + val layout: PagedLayout = PagedLayout.FULL, ) : PluginGUI(id, titleKey, rows, fillMode) { /** Tracks the current page for each player viewing this GUI. */ @@ -98,7 +102,7 @@ abstract class PagedPluginGUI( /** * Returns all items that should be distributed across pages for the * given player. The list may be of any size; items are automatically - * split into pages of [contentSlots] each. + * split into pages of [pageSize] each. * * Used when [mode] is [PagedGUIMode.LIST]. Override this method to supply * the items to paginate. @@ -112,21 +116,27 @@ abstract class PagedPluginGUI( * Returns a map of items to place at specific pages and slots. * * The outer map key is the **zero-based page index**; the inner map key is - * the **zero-based slot index** within the content area of that page (slots - * 0 to [contentSlots]`- 1`). Pages that are missing from the map are - * rendered empty. + * the **zero-based position** within the content area of that page (0 to + * [pageSize]`- 1`), not an inventory slot — a framed layout maps those + * positions onto the slots inside its border. Pages that are missing from + * the map are rendered empty. * * Used when [mode] is [PagedGUIMode.SET]. Override this method to supply * manually positioned items. * * @param player the player the GUI is being opened for - * @return a map of `page → (slot → item)` describing the full contents + * @return a map of `page → (position → item)` describing the full contents */ open fun getSetItems(player: Player): Map> = emptyMap() /** * Called when a player clicks a **content slot** (not a navigation - * button). Override to add custom click handling. + * button or a button in the navigation row). Override to add custom click + * handling. + * + * Use [contentIndex] to turn the clicked slot into a position in the list + * returned by [getItems]; the raw slot is an inventory slot and does not + * match that list once a border is in the way. * * Clicks are cancelled by default to prevent item theft. * @@ -135,13 +145,53 @@ abstract class PagedPluginGUI( */ open fun onContentClick(event: InventoryClickEvent, page: Int) {} - /** The number of usable content slots per page (all rows except the last). */ - private val contentSlots: Int - get() = (rows - 1) * ROW_SIZE + /** + * Buttons to place in the navigation row, keyed by their offset in that row + * (0 to 8). + * + * Offsets 0, 4 and 8 belong to Previous, the page indicator and Next, and + * anything placed there is ignored. Because these sit in a row that never + * moves, a button here stays where it is however the content grows — which + * is the point of putting an action here rather than among the items. + * + * @param player the player the GUI is being drawn for + */ + open fun navButtons(player: Player): Map = emptyMap() + + /** + * Called when a player clicks one of this GUI's own [navButtons]. + * + * @param event the inventory click event + * @param offset the offset in the navigation row that was clicked (0 to 8) + */ + open fun onNavClick(event: InventoryClickEvent, offset: Int) {} + + /** The inventory slots that hold content, in reading order. */ + private val contentSlots: List by lazy { + when (layout) { + PagedLayout.FULL -> (0 until (rows - 1) * ROW_SIZE).toList() + PagedLayout.FRAMED -> GUIFrame.contentSlots(rows) + } + } + + private val contentPositions: Map by lazy { + contentSlots.withIndex().associate { (position, slot) -> slot to position } + } + + /** How many items fit on one page. */ + protected val pageSize: Int + get() = contentSlots.size.coerceAtLeast(1) /** The first slot index of the navigation row (the last row). */ private val navRowStart: Int - get() = contentSlots + get() = (rows - 1) * ROW_SIZE + + /** + * The position in [getItems]'s list that [rawSlot] shows on [page], or + * `null` when that slot holds no content. + */ + protected fun contentIndex(page: Int, rawSlot: Int): Int? = + contentPositions[rawSlot]?.let { position -> page * pageSize + position } // ----- PluginGUI overrides ------------------------------------------------ @@ -159,15 +209,16 @@ abstract class PagedPluginGUI( // Ignore clicks outside the GUI inventory if (slot < 0 || slot >= rows * ROW_SIZE) return - when (slot) { - navRowStart + PREVIOUS_OFFSET -> { + val navOffset = slot - navRowStart + when { + navOffset == PREVIOUS_OFFSET -> { if (page > 0) { openPage(player, event.inventory, page - 1) player.playSound(Sound.sound(Key.key("minecraft:ui.button.click"), Sound.Source.UI, 1f, 1f)) } } - navRowStart + NEXT_OFFSET -> { + navOffset == NEXT_OFFSET -> { val totalPages = totalPages(player) if (page < totalPages - 1) { openPage(player, event.inventory, page + 1) @@ -175,9 +226,9 @@ abstract class PagedPluginGUI( } } - else -> { - if (slot < contentSlots) onContentClick(event, page) - } + navOffset == PAGE_INDICATOR_OFFSET -> return + navOffset in 0 until ROW_SIZE -> onNavClick(event, navOffset) + slot in contentPositions -> onContentClick(event, page) } } @@ -195,7 +246,7 @@ abstract class PagedPluginGUI( private fun totalPages(player: Player): Int = when (mode) { PagedGUIMode.LIST -> { val itemCount = getItems(player).size - if (itemCount == 0) 1 else (itemCount + contentSlots - 1) / contentSlots + if (itemCount == 0) 1 else (itemCount + pageSize - 1) / pageSize } PagedGUIMode.SET -> { @@ -215,38 +266,54 @@ abstract class PagedPluginGUI( inventory.clear() fillInventory(this, inventory) + if (layout == PagedLayout.FRAMED) GUIFrame.draw(inventory, contentSlots) val totalPages = totalPages(player) when (mode) { PagedGUIMode.LIST -> { val items = getItems(player) - val start = page * contentSlots - val end = minOf(start + contentSlots, items.size) - for (i in start until end) { - inventory.setItem(i - start, items[i]) + val start = page * pageSize + val end = minOf(start + pageSize, items.size) + for (index in start until end) { + inventory.setItem(contentSlots[index - start], items[index]) } } PagedGUIMode.SET -> { val pageItems = getSetItems(player)[page] ?: emptyMap() - for ((slot, item) in pageItems) { - if (slot in 0 until contentSlots) { - inventory.setItem(slot, item) - } + for ((position, item) in pageItems) { + contentSlots.getOrNull(position)?.let { inventory.setItem(it, item) } } } } - // Navigation row – fill all slots with gray stained glass panes first - for (offset in 0 until ROW_SIZE) { - inventory.setItem(navRowStart + offset, itemStack(Material.GRAY_STAINED_GLASS_PANE) { + renderNavRow(player, inventory, page, totalPages) + } + + /** Draws the navigation row: filler, then this GUI's own buttons, then the page controls. */ + private fun renderNavRow(player: Player, inventory: Inventory, page: Int, totalPages: Int) { + // A framed menu carries its border all the way round, so the row below the + // content matches the rest of it rather than changing colour. + val filler = if (layout == PagedLayout.FRAMED) { + GUIFrame.pane() + } else { + itemStack(Material.GRAY_STAINED_GLASS_PANE) { name(" ") hideTooltip(true) - }) + } + } + + for (offset in 0 until ROW_SIZE) { + inventory.setItem(navRowStart + offset, filler.clone()) + } + + for ((offset, button) in navButtons(player)) { + if (offset in 0 until ROW_SIZE && offset !in RESERVED_OFFSETS) { + inventory.setItem(navRowStart + offset, button) + } } - // Place navigation items on top of the filler if (page > 0) { inventory.setItem( navRowStart + PREVIOUS_OFFSET, createNavItem( @@ -286,6 +353,9 @@ abstract class PagedPluginGUI( private const val PREVIOUS_OFFSET = 0 private const val PAGE_INDICATOR_OFFSET = 4 private const val NEXT_OFFSET = 8 + + /** Navigation-row offsets the page controls own, which [navButtons] may not use. */ + private val RESERVED_OFFSETS = setOf(PREVIOUS_OFFSET, PAGE_INDICATOR_OFFSET, NEXT_OFFSET) } /** 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 8d86dd9..8190834 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 @@ -7,16 +7,20 @@ import java.util.UUID /** * One line of goods in a shop. * - * The [item] is stored verbatim, custom data and all, and its own stack size is - * the bundle: an entry holding 16 bread sells sixteen loaves per click. Buying - * and selling are independent, so an entry can do either, both, or — with both - * left null — act as a display piece. + * The [item] is stored verbatim, custom data and all, and [bundle] is how many + * of it one purchase moves: an entry of bread with a bundle of 16 sells sixteen + * loaves per click. The two are kept apart so a bundle can exceed what a stack + * holds — 128 bread is a valid bundle, handed over as two stacks. Buying and + * 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 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 */ data class ShopEntry( val id: String = UUID.randomUUID().toString(), var item: ItemStack, + var bundle: Int = 1, var buy: ShopCost? = null, var sell: ShopCost? = null, var gate: ShopGate = ShopGate.OPEN, @@ -27,8 +31,8 @@ data class ShopEntry( var stats: ShopStats = ShopStats(), ) { - /** How many items one bundle is. */ - val bundleSize: Int get() = item.amount.coerceAtLeast(1) + /** How many items one bundle is, never less than one. */ + val bundleSize: Int get() = bundle.coerceAtLeast(1) /** Whether a player can buy this. */ val isBuyable: Boolean get() = buy != null @@ -36,7 +40,12 @@ data class ShopEntry( /** Whether the shop buys this back. */ val isSellable: Boolean get() = sell != null - /** One bundle as a single stack, for a menu slot rather than for handing over. */ + /** + * One bundle as a single stack, for a menu slot rather than for handing over. + * + * Capped at what a slot can show, because a bundle larger than a stack still + * has to be drawn in one square; the real count is written into the lore. + */ fun displayStack(): ItemStack = item.clone().apply { amount = bundleSize.coerceAtMost(item.maxStackSize) } /** 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 1fb9a82..2957a69 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 @@ -174,6 +174,8 @@ object ShopManager { return ShopEntry( id = entry.id.ifBlank { UUID.randomUUID().toString() }, item = item, + // A shop written before the bundle was its own field kept it as the item's stack size. + bundle = if (entry.bundle > 0) entry.bundle else item.amount, buy = toCost(entry.buy), sell = toCost(entry.sell), gate = ShopGate(entry.permission, requirement(entry.towny), entry.hideWhenLocked), @@ -196,6 +198,7 @@ object ShopManager { StoredEntry( id = entry.id, item = ItemCodec.encode(entry.item), + bundle = entry.bundleSize, buy = toStoredCost(entry.buy), sell = toStoredCost(entry.sell), permission = entry.gate.permission, 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 251fdee..c5badb1 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 @@ -25,6 +25,7 @@ data class StoredShop( data class StoredEntry( val id: String = "", val item: String = "", + val bundle: Int = 0, val buy: StoredCost? = null, val sell: StoredCost? = null, val permission: String? = null, diff --git a/src/main/resources/lang/en_US.yml b/src/main/resources/lang/en_US.yml index 90ff4b5..8e3b1c7 100644 --- a/src/main/resources/lang/en_US.yml +++ b/src/main/resources/lang/en_US.yml @@ -275,6 +275,7 @@ gui: shop-editor: title: "Shop editor" + bundle: "Sells {amount} at a time" buy: "Sold for:" sell: "Bought back for:" not-buyable: "Not for sale" @@ -285,10 +286,16 @@ gui: add-lore: "Click an item in your own inventory, or drag it here. Your item stays where it is." settings: "Shop settings" settings-lore: "Name, access and NPCs for {id}." + stats: "Sales figures" + stats-lore: "What this shop has traded." + back: "All shops" + back-lore: "Back to the list of shops." shop-entry: title: "Entry" bundle: "One purchase gives {amount}" + bundle-size: "Amount per purchase" + bundle-lore: "May be more than one stack; it is handed over as several." state: "Now: {state}" amount: "Now: {amount}" buyable: "Players may buy this" @@ -317,6 +324,7 @@ gui: right-click-clear: "Right-click to clear it" 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." back: "Back" diff --git a/src/main/resources/lang/zh_CN.yml b/src/main/resources/lang/zh_CN.yml index f001e3d..4987515 100644 --- a/src/main/resources/lang/zh_CN.yml +++ b/src/main/resources/lang/zh_CN.yml @@ -276,6 +276,7 @@ gui: shop-editor: title: "商店编辑器" + bundle: "每次出售 {amount} 个" buy: "售价:" sell: "回收价:" not-buyable: "不出售" @@ -286,10 +287,16 @@ gui: add-lore: "单击你背包里的物品,或将其拖到这里。你的物品不会被拿走。" settings: "商店设置" settings-lore: "{id} 的名称、准入和 NPC。" + stats: "经营数据" + stats-lore: "这家商店的交易情况。" + back: "商店列表" + back-lore: "返回商店列表。" shop-entry: title: "商品设置" bundle: "每次购买获得 {amount}" + bundle-size: "每次购买数量" + bundle-lore: "可以超过一组,将分成多组交付。" state: "当前:{state}" amount: "当前:{amount}" buyable: "允许玩家购买" @@ -318,6 +325,7 @@ gui: right-click-clear: "右键 清除" prompt-price: "请在聊天栏输入新的价格。" prompt-permission: "请在聊天栏输入权限节点。" + prompt-bundle: "请输入每次购买应获得的数量。" prompt-limit: "请输入每个玩家最多能买多少。0 表示不限。" prompt-stock: "请输入库存和补货秒数,例如 64 3600。0 表示不限库存。" back: "返回" diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIFrameTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIFrameTest.kt new file mode 100644 index 0000000..2ddfee5 --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIFrameTest.kt @@ -0,0 +1,52 @@ +package net.trilleo.mc.plugins.tritown.registration + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The slot arithmetic behind every framed menu. + * + * Worth pinning down here rather than in game: an off-by-one puts an item under + * the border where nothing can click it, and that looks like a menu that + * ignores you rather than like a bug. + */ +class GUIFrameTest { + + @Test + fun `a six-row menu keeps the four inner rows`() { + val slots = GUIFrame.contentSlots(6) + + assertEquals(28, slots.size) + assertEquals(10, slots.first()) + assertEquals(43, slots.last()) + } + + @Test + fun `content is in reading order, so a list fills left to right`() { + assertEquals(listOf(10, 11, 12, 13, 14, 15, 16, 19), GUIFrame.contentSlots(6).take(8)) + } + + @Test + fun `the border rows and columns are never content`() { + val slots = GUIFrame.contentSlots(6).toSet() + + val topRow = 0..8 + val bottomRow = 45..53 + val leftColumn = (0 until 6).map { it * 9 } + val rightColumn = (0 until 6).map { it * 9 + 8 } + + assertTrue((topRow + bottomRow + leftColumn + rightColumn).none { it in slots }) + } + + @Test + fun `a three-row menu keeps its single inner row`() { + assertEquals((10..16).toList(), GUIFrame.contentSlots(3)) + } + + @Test + fun `a menu too short to have an inside holds nothing`() { + assertEquals(emptyList(), GUIFrame.contentSlots(2)) + assertEquals(emptyList(), GUIFrame.contentSlots(1)) + } +} From 540e6918beac56d9e6c894cc48f527cee93f5b70 Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sat, 19 Sep 2026 19:25:15 +0800 Subject: [PATCH 08/14] Feature: Record what creates and destroys the server's currency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The economy now keeps figures of its own, hour by hour: what was created, what was destroyed, what for and who held it, alongside a measurement of the ledger taken on every flush. The supply is measured off the accounts rather than accumulated from movements, because adding movements up would drift the first time anything moved money without TriTown recording it. Recording is fed from EconomyService.record, so a new way of moving money is counted without touching it, and costs no disk and no lock — Towny moves money from its own threads. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 11 + CLAUDE.md | 9 +- README.md | 2 + docs/DEVELOPER_GUIDE.md | 75 +++ .../net/trilleo/mc/plugins/tritown/Main.kt | 12 + .../plugins/tritown/config/EconomySettings.kt | 15 + .../plugins/tritown/economy/EconomyPulse.kt | 526 ++++++++++++++++++ .../plugins/tritown/economy/EconomyService.kt | 42 +- .../economy/storage/JsonPulseStorage.kt | 74 +++ .../tritown/economy/storage/StoredPulse.kt | 68 +++ .../mc/plugins/tritown/enums/FlowCategory.kt | 78 +++ .../tritown/tasks/economy/EconomyFlushTask.kt | 14 +- src/main/resources/config.yml | 12 + src/main/resources/lang/en_US.yml | 10 + src/main/resources/lang/zh_CN.yml | 10 + .../tritown/economy/EconomyPulseTest.kt | 176 ++++++ 16 files changed, 1120 insertions(+), 14 deletions(-) create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyPulse.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/JsonPulseStorage.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/StoredPulse.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/FlowCategory.kt create mode 100644 src/test/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyPulseTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e6cf7c..8a72335 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,17 @@ ### Technical Details +#### Economy + ++ The economy now keeps figures of its own, hour by hour: what was created and destroyed, what for, who held it, and a + measurement of the ledger taken on every flush. They live in `plugins/TriTown/economy/statistics.json` and are + written on the same interval as balances, so a crash costs at most one interval of them and never a balance. ++ `economy.stats.enabled` turns the figures off entirely, and `economy.stats.retention-days` says how far back they + reach — 30 days by default, or 0 to keep them forever. ++ Recording a movement costs no disk and no lock: the counters are plain adders, which matters because Towny moves + money from its own threads. ++ Transaction statistics are kept even when `economy.history.enabled` is off, since they cost nothing per transaction. + #### Shops + Added [FancyNpcs](https://modrinth.com/plugin/fancynpcs) as an optional dependency. TriTown builds and runs without diff --git a/CLAUDE.md b/CLAUDE.md index 8fb7ec0..acb434a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,8 +72,9 @@ src/main/kotlin/net/trilleo/mc/plugins/tritown/ │ └── moderation/ ├── config/ # PluginConfig (typed config.yml wrapper), EconomySettings ├── data/ # JSON-persisted PlayerData / ServerData and their managers -├── economy/ # The economy: ledger, accounts, currencies, Vault provider, storage (not scanned) -├── enums/ # AccountType, DisplayLocation, FillMode, PagedGUIMode, ProviderMode, TransactionType +├── economy/ # The economy: ledger, accounts, currencies, Vault provider, statistics, +│ # storage (not scanned) +├── enums/ # AccountType, FlowCategory, TransactionType, FillMode, PagedGUIMode, … ├── guis/ # GUIs (auto-registered, extend PluginGUI / PagedPluginGUI) ├── items/ # Custom items (auto-registered, extend PluginItem) ├── listeners/ # Event listeners, including Towny events (auto-registered) @@ -191,6 +192,10 @@ complete stack and no separate economy plugin is needed. See reach TriTown as ordinary deposits and withdrawals. A `BankTransactionEvent` handler that wrote a record would double-count every town deposit. - **Costs and rewards are configurable** — put amounts in `config.yml`, not in Kotlin. +- **Server-wide figures go through `EconomyPulse`** — it is fed from `EconomyService.record`, so a new way of moving + money is counted without touching it. Group a movement with `FlowCategory`, never by totalling raw reason strings, + and remember the supply is measured off the ledger rather than accumulated. See + [Economy Statistics](docs/DEVELOPER_GUIDE.md#economy-statistics). ## Working with Shops diff --git a/README.md b/README.md index 148bc5a..4cf5df0 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,8 @@ version stays available as `/tritown:balance` and so on. | `economy.history.retention-days` | `30` | How long rolled log files are kept; `0` keeps them forever | | `economy.history.roll-size-mb` | `16` | Size at which the transaction log is rolled aside | | `economy.history.time-format` | `yyyy-MM-dd HH:mm` | How timestamps are shown in the history view | +| `economy.stats.enabled` | `true` | Keep the hourly figures the admin panel reads | +| `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.confirm-above` | `1000.0` | Purchase total that asks for confirmation first; `0` never asks | diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index faeebd5..5481099 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -2257,6 +2257,81 @@ at most one flush interval of changes. An SQL backend writing through on each tr --- +## Economy Statistics + +`EconomyPulse` is what the [admin panel](#admin-panel) reads. It answers two questions that need two different +mechanisms, which is why it keeps two things rather than one: + +| Kept | How | Answers | +|:-------------|:--------------------------------------------------------|:-------------------------------------------------| +| **Buckets** | Accumulated per hour as transactions happen | What created and destroyed currency, and what for | +| **Samples** | The whole ledger measured on the flush task | How much exists and who holds it, exactly | + +The supply is **measured, never accumulated**. Adding movements up would drift the first time anything moved money +without TriTown recording it; walking the accounts cannot. + +### What counts as what + +* A **deposit** is money entering the economy — it is created. +* A **withdrawal** is money leaving it — it is destroyed. +* A **transfer** moves money between two accounts and changes nothing, so it is counted separately as *circulation*, + and only the `TRANSFER_OUT` side is counted or every payment would show up at twice its size. +* A **set** carries how far a balance moved but not which way, so it is kept apart as an *adjustment* rather than + guessed at. + +Towny moves a bank deposit through Vault as a withdrawal from the player and a deposit into the town, not as a +transfer, so it lands on **both** gross sides and cancels in the net. The net — overall and per category — is the +number worth reading, and the panel says so on the card. + +### Categories + +`FlowCategory` groups a movement by what it was for. It exists because a transaction's `reason` carries arguments +(`money.reason.admin-set?admin=Bob`), so totalling by reason would produce a row per administrator. A category is +bounded, stable and translatable through `money.flow.*`, and `FlowCategory.of(source, reason)` is the only place the +mapping lives. + +### Recording + +`EconomyService.record` files every transaction, and hands it to `EconomyPulse` before the history log — so the +figures are kept even when `economy.history.enabled` is off. Recording must stay cheap and lock-free: it runs on +whichever thread moved the money, including Towny's. The counters are `LongAdder`s in concurrent maps, and nothing +here touches the disk. + +Only the primary currency is counted. A movement in any other currency is ignored rather than added to a total whose +minor units mean something else. + +### Sampling and storage + +`EconomyFlushTask` measures the ledger before each flush, next to the leaderboard rebuild, because both walk every +account and sorting belongs off the server thread. `EconomyService.start` and `shutdown` take one measurement each, so +the panel has something to show before the first flush and the supply after a restart is the one the server stopped +with. + +Figures live in `plugins/TriTown/economy/statistics.json`, written by `JsonPulseStorage` through a temporary file in +the same way balances are. There is deliberately **no backup copy**: statistics are worth keeping but nobody's money +depends on them, and a file that cannot be read simply starts the history again. A file written in another currency is +dropped rather than adopted. + +`economy.stats.enabled` turns the whole thing off; `economy.stats.retention-days` prunes buckets and samples older +than it. + +### Reading + +```kotlin +val flow = EconomyPulse.window(hours = 24, slices = 7) // 0 hours means everything still kept +flow.created // minor units that entered the economy +flow.netOf(FlowCategory.SHOP) // what the shops did to the supply +flow.slices // one column per slice, for a chart + +val supply = EconomyPulse.latest() // the last measurement, or null before the first one +val before = EconomyPulse.sampleAt(flow.from) // the measurement the window opened on +``` + +Amounts are **minor units** throughout, exactly as the ledger holds them; they become text only at the menu, through +`PanelRender`. + +--- + ## Economy (Vault) Vault is a hard dependency (`depend` in `plugin.yml`); the Vault API is `compileOnly` (`vault_api_version` in 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 6090d59..3a6c6d1 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt @@ -10,9 +10,11 @@ import net.trilleo.mc.plugins.tritown.data.PlayerDataManager import net.trilleo.mc.plugins.tritown.data.ServerDataManager import net.trilleo.mc.plugins.tritown.economy.CurrencyRegistry import net.trilleo.mc.plugins.tritown.economy.EconomyFormat +import net.trilleo.mc.plugins.tritown.economy.EconomyPulse import net.trilleo.mc.plugins.tritown.economy.EconomyService import net.trilleo.mc.plugins.tritown.economy.TownyAccountNaming import net.trilleo.mc.plugins.tritown.economy.storage.JsonEconomyStorage +import net.trilleo.mc.plugins.tritown.economy.storage.JsonPulseStorage import net.trilleo.mc.plugins.tritown.economy.vault.TriTownVaultEconomy import net.trilleo.mc.plugins.tritown.economy.vault.VaultRegistration import net.trilleo.mc.plugins.tritown.enums.ProviderMode @@ -60,6 +62,16 @@ class Main : JavaPlugin() { CurrencyRegistry.load(settings.currencies, settings.primaryCurrencyId) EconomyFormat.invalidate() EconomyService.initialize(logger, createStorage(settings), settings) + // Joins the economy here rather than in onEnable because Towny can + // already be moving money through the Vault provider by then, and + // those movements belong in the figures like any other. + if (settings.stats.enabled) { + EconomyPulse.start( + store = JsonPulseStorage(dataFolder, logger), + currency = CurrencyRegistry.primary, + retentionDays = settings.stats.retentionDays, + ) + } } catch (e: Exception) { logger.log(Level.SEVERE, "The economy could not be loaded", e) bootFailure = e.message ?: e.javaClass.simpleName diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/EconomySettings.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/EconomySettings.kt index 064cbcc..c63b21a 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/EconomySettings.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/EconomySettings.kt @@ -31,6 +31,7 @@ data class EconomySettings( val allowRescale: Boolean, val deleteAccountsOnDelete: Boolean, val history: HistorySettings, + val stats: StatsSettings, ) { /** @@ -48,6 +49,16 @@ data class EconomySettings( val timeFormat: String, ) + /** + * How much of the economy's own history the admin panel keeps. + * + * @param retentionDays how far back the hourly figures reach; 0 keeps them forever + */ + data class StatsSettings( + val enabled: Boolean, + val retentionDays: Int, + ) + /** The bounds the ledger should enforce, with the balance cap converted per currency. */ fun ledgerLimits(): LedgerLimits = LedgerLimits( capByCurrency = if (balanceCap <= 0.0) { @@ -123,6 +134,10 @@ data class EconomySettings( .coerceAtLeast(0L) * 1024L * 1024L, timeFormat = config.getString("economy.history.time-format", "yyyy-MM-dd HH:mm"), ), + stats = StatsSettings( + enabled = config.getBoolean("economy.stats.enabled", true), + retentionDays = config.getInt("economy.stats.retention-days", 30).coerceIn(0, 365), + ), ) } } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyPulse.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyPulse.kt new file mode 100644 index 0000000..6da27b7 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyPulse.kt @@ -0,0 +1,526 @@ +package net.trilleo.mc.plugins.tritown.economy + +import net.trilleo.mc.plugins.tritown.economy.storage.JsonPulseStorage +import net.trilleo.mc.plugins.tritown.economy.storage.StoredPulse +import net.trilleo.mc.plugins.tritown.economy.storage.StoredPulseBucket +import net.trilleo.mc.plugins.tritown.economy.storage.StoredPulseSample +import net.trilleo.mc.plugins.tritown.economy.storage.StorageSchema +import net.trilleo.mc.plugins.tritown.enums.AccountType +import net.trilleo.mc.plugins.tritown.enums.FlowCategory +import net.trilleo.mc.plugins.tritown.enums.TransactionType +import java.util.EnumMap +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.LongAdder + +/** + * What the economy as a whole is doing: where money comes from, where it goes, + * and what is left holding it. + * + * Two different things are kept, because they answer different questions. + * *Buckets* accumulate movements hour by hour as they happen, which is the only + * way to know what created and destroyed currency. *Samples* measure the ledger + * outright on a timer, which is the only way to know the supply exactly — + * adding movements up would drift the moment anything moved money without + * TriTown seeing it. + * + * Money is created when it is deposited from outside the economy and destroyed + * when it is withdrawn out of it; a transfer moves it between two accounts and + * changes nothing, so it is counted separately as circulation. Towny moves bank + * deposits through Vault as a withdrawal and a deposit rather than as a + * transfer, so those land on both sides of the gross figures and cancel in the + * net — which is why the net, and the per-category net, are the numbers worth + * reading. + * + * Recording happens on whichever thread moved the money, including Towny's, so + * everything here is lock-free and never touches the disk. Sampling and writing + * happen on the flush task. + */ +object EconomyPulse { + + // ── Read models ───────────────────────────────────────────────────── + + /** One column of the flow chart: what happened between [from] and [to]. */ + data class Slice(val from: Long, val to: Long, val created: Long, val destroyed: Long) { + val net: Long get() = created - destroyed + } + + /** + * Everything that moved over one window, in minor units. + * + * @param createdBy money that entered the economy, by what it was for + * @param destroyedBy money that left it, by what it was for + * @param createdTo money that entered, by the kind of account that received it + * @param destroyedFrom money that left, by the kind of account it came from + * @param circulated money moved between accounts, counted once per transfer + * @param adjusted how far balances were moved by being set outright + */ + data class Flow( + val from: Long, + val to: Long, + val createdBy: Map, + val destroyedBy: Map, + val createdTo: Map, + val destroyedFrom: Map, + val circulated: Long, + val adjusted: Long, + val movements: Long, + val transfers: Long, + val slices: List, + ) { + val created: Long get() = createdBy.values.sum() + val destroyed: Long get() = destroyedBy.values.sum() + val net: Long get() = created - destroyed + + /** Whether anything at all happened in this window. */ + val isEmpty: Boolean get() = movements == 0L && transfers == 0L + + /** What [category] did to the supply: positive when it feeds the economy, negative when it drains it. */ + fun netOf(category: FlowCategory): Long = (createdBy[category] ?: 0L) - (destroyedBy[category] ?: 0L) + + /** Every category that saw any money at all, largest gross movement first. */ + fun categories(): List = (createdBy.keys + destroyedBy.keys) + .sortedByDescending { (createdBy[it] ?: 0L) + (destroyedBy[it] ?: 0L) } + } + + /** + * The ledger as it stood when it was measured. + * + * @param supply total held, by account type + * @param accounts how many accounts of each type exist + * @param activeWallets player wallets whose balance changed in the last week + * @param topShare the share of player wealth held by the richest tenth, 0 to 1 + * @param gini inequality across player wallets, 0 (identical) to 1 (one player holds everything) + */ + data class Supply( + val at: Long, + val supply: Map, + val accounts: Map, + val activeWallets: Int, + val median: Long, + val mean: Long, + val richest: Long, + val topShare: Double, + val gini: Double, + ) { + /** Every unit of currency in existence. */ + val total: Long get() = supply.values.sum() + + /** How much is held in player wallets rather than by a town, a nation or the server. */ + val held: Long get() = (supply[AccountType.PLAYER] ?: 0L) + (supply[AccountType.UNKNOWN] ?: 0L) + + /** How much sits in town and nation banks. */ + val banked: Long get() = (supply[AccountType.TOWN] ?: 0L) + (supply[AccountType.NATION] ?: 0L) + + /** How many accounts there are of every kind. */ + val accountCount: Int get() = accounts.values.sum() + + /** How many player wallets there are. */ + val wallets: Int get() = (accounts[AccountType.PLAYER] ?: 0) + (accounts[AccountType.UNKNOWN] ?: 0) + } + + // ── State ─────────────────────────────────────────────────────────── + + private val buckets = ConcurrentHashMap() + private val samples = ConcurrentHashMap() + private val dirty = AtomicBoolean(false) + + private var storage: JsonPulseStorage? = null + private var retentionHours: Long = 0L + + /** The currency the figures are in. A movement in any other currency is ignored rather than added to it. */ + @Volatile + private var currencyId: String = "" + + /** + * Whether statistics are switched on and loaded. + * + * Written last by [start] and read first by [record], so a recorder that + * sees this set is guaranteed to see everything [start] published before it. + */ + @Volatile + var isEnabled: Boolean = false + private set + + // ── Lifecycle ─────────────────────────────────────────────────────── + + /** + * Loads whatever was stored and starts recording. + * + * Called from `onLoad` alongside the rest of the economy, because Towny can + * move money through the Vault provider before TriTown has enabled and + * those movements should be counted like any other. + * + * @param retentionDays how far back the history reaches; 0 keeps everything + */ + fun start(store: JsonPulseStorage, currency: Currency, retentionDays: Int) { + buckets.clear() + samples.clear() + + storage = store + currencyId = currency.id + retentionHours = retentionDays.coerceAtLeast(0).toLong() * 24L + isEnabled = true + + store.load()?.let(::restore) + prune() + dirty.set(false) + } + + /** Writes anything outstanding and stops recording. */ + fun shutdown() { + if (!isEnabled) return + isEnabled = false + flush() + storage = null + buckets.clear() + samples.clear() + } + + // ── Recording ─────────────────────────────────────────────────────── + + /** + * Files one movement of money. + * + * Called from [EconomyService] for every transaction it records, on + * whichever thread made it. + * + * @param holder what kind of account the movement is filed against + */ + fun record( + type: TransactionType, + currency: Currency, + amount: Money, + holder: AccountType, + source: String, + reason: String, + ) { + if (!isEnabled || currency.id != currencyId) return + + val minor = amount.abs().minor + if (minor == 0L) return + + val bucket = buckets.computeIfAbsent(hourOf(System.currentTimeMillis())) { Bucket() } + bucket.movements.increment() + + when (type) { + TransactionType.DEPOSIT -> { + bucket.createdBy.adder(FlowCategory.of(source, reason)).add(minor) + bucket.createdTo.adder(holder).add(minor) + } + + TransactionType.WITHDRAW -> { + bucket.destroyedBy.adder(FlowCategory.of(source, reason)).add(minor) + bucket.destroyedFrom.adder(holder).add(minor) + } + + // A transfer is recorded against both accounts, so only one side is + // counted or every payment would show up as twice its own size. + TransactionType.TRANSFER_OUT -> { + bucket.circulated.add(minor) + bucket.transfers.increment() + } + + TransactionType.TRANSFER_IN -> Unit + + // The record carries how far the balance moved but not which way, so + // a set is kept apart from the money it did or did not create. + TransactionType.SET -> bucket.adjusted.add(minor) + + TransactionType.CLOSED -> Unit + } + + dirty.set(true) + } + + /** + * Measures the ledger and keeps the result as this hour's sample. + * + * Walks every account and sorts the player wallets, so this belongs on the + * flush task next to the leaderboard rebuild and nowhere near the server + * thread. + */ + fun sample(ledger: EconomyLedger, currency: Currency) { + if (!isEnabled || currency.id != currencyId) return + + val supply = EnumMap(AccountType::class.java) + val counts = EnumMap(AccountType::class.java) + val wallets = ArrayList(ledger.size) + val activeSince = System.currentTimeMillis() - ACTIVE_WINDOW_MS + var active = 0 + + for (account in ledger.accounts()) { + val balance = account.balance(currency.id).minor + supply.merge(account.type, balance, Long::plus) + counts.merge(account.type, 1, Int::plus) + + if (account.type == AccountType.PLAYER || account.type == AccountType.UNKNOWN) { + wallets.add(balance) + if (account.updatedAt >= activeSince) active++ + } + } + + wallets.sort() + val now = System.currentTimeMillis() + val measurement = Supply( + at = now, + supply = supply, + accounts = counts, + activeWallets = active, + median = median(wallets), + mean = if (wallets.isEmpty()) 0L else wallets.sum() / wallets.size, + richest = wallets.lastOrNull() ?: 0L, + topShare = topShare(wallets), + gini = gini(wallets), + ) + + // The sample is always replaced, so the panel shows when the ledger was + // last looked at, but an idle server does not rewrite the whole file + // every flush just because that time moved on. + val previous = samples.put(hourOf(now), measurement) + if (previous == null || previous.copy(at = now) != measurement) dirty.set(true) + + prune() + } + + /** Writes the statistics out, if anything has changed since the last write. */ + fun flush() { + val store = storage ?: return + if (!dirty.getAndSet(false)) return + store.save(snapshot()) + } + + // ── Reading ───────────────────────────────────────────────────────── + + /** + * Everything recorded over the last [hours], split into [slices] equal + * columns. + * + * @param hours how far back to reach; 0 or less means everything still kept + */ + fun window(hours: Int, slices: Int = 7): Flow { + val now = System.currentTimeMillis() + val from = if (hours > 0) now - hours * HOUR_MS else earliest() ?: now + return window(from, now, slices) + } + + /** The newest sample taken at or before [millis], or `null` when the ledger had not been measured by then. */ + fun sampleAt(millis: Long): Supply? = samples.values.filter { it.at <= millis }.maxByOrNull { it.at } + + /** The most recent measurement of the ledger, or `null` when none has been taken yet. */ + fun latest(): Supply? = samples.values.maxByOrNull { it.at } + + /** When the oldest record still kept starts, or `null` when nothing is recorded. */ + fun earliest(): Long? { + val bucket = buckets.keys.minOrNull() + val sample = samples.keys.minOrNull() + val hour = listOfNotNull(bucket, sample).minOrNull() ?: return null + return hour * HOUR_MS + } + + // ── Internals ─────────────────────────────────────────────────────── + + private fun window(from: Long, to: Long, slices: Int): Flow { + val createdBy = EnumMap(FlowCategory::class.java) + val destroyedBy = EnumMap(FlowCategory::class.java) + val createdTo = EnumMap(AccountType::class.java) + val destroyedFrom = EnumMap(AccountType::class.java) + val columns = LongArray(slices.coerceAtLeast(1) * 2) + + var circulated = 0L + var adjusted = 0L + var movements = 0L + var transfers = 0L + + val span = (to - from).coerceAtLeast(1L) + for ((hour, bucket) in buckets) { + val start = hour * HOUR_MS + // The hour the window starts in is half inside it; counting all of it + // is closer to the truth than dropping it, since a bucket cannot be split. + if (start + HOUR_MS <= from || start > to) continue + + for ((category, adder) in bucket.createdBy) createdBy.merge(category, adder.sum(), Long::plus) + for ((category, adder) in bucket.destroyedBy) destroyedBy.merge(category, adder.sum(), Long::plus) + for ((type, adder) in bucket.createdTo) createdTo.merge(type, adder.sum(), Long::plus) + for ((type, adder) in bucket.destroyedFrom) destroyedFrom.merge(type, adder.sum(), Long::plus) + + circulated += bucket.circulated.sum() + adjusted += bucket.adjusted.sum() + movements += bucket.movements.sum() + transfers += bucket.transfers.sum() + + val column = (((start - from).coerceAtLeast(0L) * slices) / span).toInt().coerceIn(0, slices - 1) + columns[column * 2] += bucket.createdBy.values.sumOf { it.sum() } + columns[column * 2 + 1] += bucket.destroyedBy.values.sumOf { it.sum() } + } + + val width = span / slices + return Flow( + from = from, + to = to, + createdBy = createdBy, + destroyedBy = destroyedBy, + createdTo = createdTo, + destroyedFrom = destroyedFrom, + circulated = circulated, + adjusted = adjusted, + movements = movements, + transfers = transfers, + slices = List(slices) { index -> + Slice( + from = from + width * index, + to = if (index == slices - 1) to else from + width * (index + 1), + created = columns[index * 2], + destroyed = columns[index * 2 + 1], + ) + }, + ) + } + + private fun prune() { + if (retentionHours <= 0L) return + val oldest = hourOf(System.currentTimeMillis()) - retentionHours + buckets.keys.removeIf { it < oldest } + samples.keys.removeIf { it < oldest } + } + + private fun snapshot(): StoredPulse = StoredPulse( + v = StorageSchema.CURRENT, + currency = currencyId, + buckets = buckets.entries.sortedBy { it.key }.map { (hour, bucket) -> + StoredPulseBucket( + hour = hour, + created = bucket.createdBy.sums(), + destroyed = bucket.destroyedBy.sums(), + createdTo = bucket.createdTo.sums(), + destroyedFrom = bucket.destroyedFrom.sums(), + circulated = bucket.circulated.sum(), + adjusted = bucket.adjusted.sum(), + movements = bucket.movements.sum(), + transfers = bucket.transfers.sum(), + ) + }, + samples = samples.entries.sortedBy { it.key }.map { (hour, sample) -> + StoredPulseSample( + hour = hour, + at = sample.at, + supply = sample.supply.mapKeys { it.key.name }, + accounts = sample.accounts.mapKeys { it.key.name }, + activeWallets = sample.activeWallets, + median = sample.median, + mean = sample.mean, + richest = sample.richest, + topShare = sample.topShare, + gini = sample.gini, + ) + }, + ) + + /** + * Seeds the statistics from [stored]. + * + * Figures written in another currency are dropped rather than adopted: the + * numbers are minor units, so reading cents as thousandths would multiply + * every past hour by ten. + */ + private fun restore(stored: StoredPulse) { + if (stored.currency.isNotEmpty() && stored.currency != currencyId) return + + for (entry in stored.buckets) { + buckets[entry.hour] = Bucket().apply { + for ((name, value) in entry.created) category(name)?.let { createdBy.adder(it).add(value) } + for ((name, value) in entry.destroyed) category(name)?.let { destroyedBy.adder(it).add(value) } + for ((name, value) in entry.createdTo) accountType(name)?.let { createdTo.adder(it).add(value) } + for ((name, value) in entry.destroyedFrom) accountType(name)?.let { destroyedFrom.adder(it).add(value) } + circulated.add(entry.circulated) + adjusted.add(entry.adjusted) + movements.add(entry.movements) + transfers.add(entry.transfers) + } + } + + for (entry in stored.samples) { + samples[entry.hour] = Supply( + at = entry.at, + supply = entry.supply.mapNotNull { (name, value) -> accountType(name)?.let { it to value } }.toMap(), + accounts = entry.accounts.mapNotNull { (name, value) -> accountType(name)?.let { it to value } } + .toMap(), + activeWallets = entry.activeWallets, + median = entry.median, + mean = entry.mean, + richest = entry.richest, + topShare = entry.topShare, + gini = entry.gini, + ) + } + } + + private fun category(name: String): FlowCategory? = + runCatching { FlowCategory.valueOf(name) }.getOrNull() + + private fun accountType(name: String): AccountType? = + runCatching { AccountType.valueOf(name) }.getOrNull() + + private fun median(sorted: List): Long = when { + sorted.isEmpty() -> 0L + sorted.size % 2 == 1 -> sorted[sorted.size / 2] + else -> (sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2 + } + + /** The share of all player wealth held by the richest tenth, which is the figure a server owner feels. */ + private fun topShare(sorted: List): Double { + if (sorted.isEmpty()) return 0.0 + val total = sorted.sumOf { it.coerceAtLeast(0L) } + if (total <= 0L) return 0.0 + val tenth = ((sorted.size + 9) / 10).coerceAtLeast(1) + val top = sorted.takeLast(tenth).sumOf { it.coerceAtLeast(0L) } + return top.toDouble() / total.toDouble() + } + + /** + * The Gini coefficient of the player wallets, from 0 (everyone holds the + * same) to 1 (one player holds everything). + * + * Debts are read as zero, because the measure is defined over non-negative + * shares and a server that allows overdrafts should not be able to push it + * past 1. + */ + private fun gini(sorted: List): Double { + if (sorted.size < 2) return 0.0 + + var total = 0.0 + var weighted = 0.0 + for ((index, value) in sorted.withIndex()) { + val amount = value.coerceAtLeast(0L).toDouble() + total += amount + weighted += amount * (index + 1) + } + if (total <= 0.0) return 0.0 + + val n = sorted.size + return ((2.0 * weighted) / (n * total) - (n + 1.0) / n).coerceIn(0.0, 1.0) + } + + private fun hourOf(millis: Long): Long = millis / HOUR_MS + + private fun ConcurrentHashMap.adder(key: K): LongAdder = + computeIfAbsent(key) { LongAdder() } + + private fun > ConcurrentHashMap.sums(): Map = + entries.associate { (key, adder) -> key.name to adder.sum() } + + /** One hour of movements. Every counter is an adder, so recording never blocks another thread. */ + private class Bucket { + val createdBy = ConcurrentHashMap() + val destroyedBy = ConcurrentHashMap() + val createdTo = ConcurrentHashMap() + val destroyedFrom = ConcurrentHashMap() + val circulated = LongAdder() + val adjusted = LongAdder() + val movements = LongAdder() + val transfers = LongAdder() + } + + private const val HOUR_MS = 60L * 60L * 1000L + private const val ACTIVE_WINDOW_MS = 7L * 24L * HOUR_MS +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyService.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyService.kt index 8cd6a5d..7c11cdd 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyService.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyService.kt @@ -99,8 +99,10 @@ object EconomyService { if (storage == null) return isReady = true Bukkit.getOnlinePlayers().forEach { ensurePlayerAccount(it) } - // Built once here so the leaderboard works before the first flush. + // Both built once here so the leaderboard and the admin panel have + // something to show before the first flush. BaltopCache.rebuild(ledger, CurrencyRegistry.primary, includeGovernmentsInBaltop) + EconomyPulse.sample(ledger, CurrencyRegistry.primary) } /** Re-applies the settings that can change without a restart. */ @@ -113,7 +115,11 @@ object EconomyService { fun shutdown() { if (storage == null) return isReady = false + // One last measurement, so the supply the panel shows after a restart is + // the supply the server stopped with rather than an hour-old one. + if (CurrencyRegistry.isLoaded) EconomyPulse.sample(ledger, CurrencyRegistry.primary) flush() + EconomyPulse.shutdown() runCatching { storage?.close() } storage = null transactions?.clear() @@ -305,9 +311,12 @@ object EconomyService { fun history(uuid: UUID): List = transactions?.recent(uuid) ?: emptyList() /** - * Files a transaction against [account], if history is switched on. + * Files a transaction against [account], for the history view and for the + * economy-wide statistics. * - * Recording never blocks: the record goes on a queue the flush task drains. + * Recording never blocks: the history goes on a queue the flush task drains, + * and the statistics are plain adders. The statistics are kept even when the + * history is switched off, since they cost no disk per transaction. */ fun record( account: UUID, @@ -318,21 +327,37 @@ object EconomyService { balanceAfter: Money, meta: Map = emptyMap(), ) { - val log = transactions ?: return + val holder = ledger.get(account)?.type ?: AccountType.UNKNOWN + // Money reaching a town, nation or NPC account through Vault has come // from Towny; saying so is more useful than the generic default. Towny // does not expose its own reason for the movement, so that is all that // can honestly be claimed here. - if (EconomyContext.current() == EconomyContext.DEFAULT && isTownyOwned(account)) { + if (EconomyContext.current() == EconomyContext.DEFAULT && isTownyOwned(holder)) { EconomyContext.with(EconomyContext.SOURCE_TOWNY, TransactionReason.TOWNY) { - log.record(account, counterparty, currency, type, amount, balanceAfter, meta) + file(account, counterparty, currency, type, amount, balanceAfter, holder, meta) } return } - log.record(account, counterparty, currency, type, amount, balanceAfter, meta) + file(account, counterparty, currency, type, amount, balanceAfter, holder, meta) + } + + private fun file( + account: UUID, + counterparty: UUID?, + currency: Currency, + type: TransactionType, + amount: Money, + balanceAfter: Money, + holder: AccountType, + meta: Map, + ) { + val context = EconomyContext.current() + EconomyPulse.record(type, currency, amount, holder, context.source, context.reason) + transactions?.record(account, counterparty, currency, type, amount, balanceAfter, meta) } - private fun isTownyOwned(uuid: UUID): Boolean = when (ledger.get(uuid)?.type) { + private fun isTownyOwned(type: AccountType): Boolean = when (type) { AccountType.TOWN, AccountType.NATION, AccountType.NPC, AccountType.SERVER -> true else -> false } @@ -371,6 +396,7 @@ object EconomyService { .onFailure { logger.log(Level.WARNING, "Failed to write the economy transaction log", it) } } + EconomyPulse.flush() pruneHistory(store) } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/JsonPulseStorage.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/JsonPulseStorage.kt new file mode 100644 index 0000000..a871968 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/JsonPulseStorage.kt @@ -0,0 +1,74 @@ +package net.trilleo.mc.plugins.tritown.economy.storage + +import com.google.gson.GsonBuilder +import java.io.File +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.util.logging.Logger + +/** + * Keeps the economy statistics in one JSON file under `/economy/`. + * + * Written the same way balances are — to a temporary file that is then moved + * into place — so a crash mid-write leaves the previous file rather than a + * truncated one. Unlike balances there is no backup copy: statistics are worth + * keeping but nobody's money depends on them, and a file that cannot be read + * simply starts the history again. + */ +class JsonPulseStorage(directory: File, private val logger: Logger) { + + private val file = File(File(directory, DIRECTORY), FILE) + private val gson = GsonBuilder().setPrettyPrinting().create() + + /** Reads what was stored, or `null` when there is nothing readable yet. */ + fun load(): StoredPulse? { + if (!file.exists()) return null + return try { + val stored = gson.fromJson(file.readText(), StoredPulse::class.java) ?: return null + StorageSchema.checkReadable(stored.v, file.name) + stored + } catch (e: EconomyStorageException) { + // Statistics are not worth refusing to start over, unlike balances. + logger.warning(e.message) + null + } catch (e: Exception) { + logger.warning("Could not read ${file.name}: [${e.javaClass.simpleName}] ${e.message}") + null + } + } + + /** Writes [snapshot], replacing whatever was there. */ + fun save(snapshot: StoredPulse) { + try { + file.parentFile?.mkdirs() + writeAtomically(file.toPath(), gson.toJson(snapshot)) + } catch (e: Exception) { + logger.warning("Failed to write ${file.name}: [${e.javaClass.simpleName}] ${e.message}") + } + } + + private fun writeAtomically(target: Path, content: String) { + val temporary = target.resolveSibling("${target.fileName}.tmp") + Files.writeString( + temporary, + content, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE, + ) + + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING) + } + } + + private companion object { + const val DIRECTORY = "economy" + const val FILE = "statistics.json" + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/StoredPulse.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/StoredPulse.kt new file mode 100644 index 0000000..8459a60 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/StoredPulse.kt @@ -0,0 +1,68 @@ +package net.trilleo.mc.plugins.tritown.economy.storage + +/** + * The economy statistics as they are written to storage. + * + * Amounts are minor units of the primary currency throughout, and the maps are + * keyed by enum name rather than by ordinal so that adding a category later + * cannot silently re-label the history already on disk. + * + * @param buckets one entry per hour that saw any movement + * @param samples one entry per hour the ledger was measured in + */ +data class StoredPulse( + val v: Int, + val currency: String, + val buckets: List, + val samples: List, +) + +/** + * What one hour of the economy did. + * + * @param hour hours since the epoch, which is what the bucket is keyed by + * @param created money that entered the economy, by [net.trilleo.mc.plugins.tritown.enums.FlowCategory] + * @param destroyed money that left it, by category + * @param createdTo money that entered, by the kind of account that received it + * @param destroyedFrom money that left, by the kind of account it came from + * @param circulated money moved between two accounts, counted once per transfer + * @param adjusted how far balances were moved by being set outright + */ +data class StoredPulseBucket( + val hour: Long, + val created: Map, + val destroyed: Map, + val createdTo: Map, + val destroyedFrom: Map, + val circulated: Long, + val adjusted: Long, + val movements: Long, + val transfers: Long, +) + +/** + * The state of the ledger at one moment, measured rather than accumulated. + * + * The supply is read straight off the accounts, so it is exact however the + * movements that produced it were attributed. + * + * @param hour hours since the epoch; one sample is kept per hour + * @param at when the sample was actually taken + * @param supply total held, by account type + * @param accounts how many accounts of each type exist + * @param activeWallets player wallets whose balance changed in the last week + * @param topShare the share of player wealth held by the richest tenth, 0 to 1 + * @param gini inequality across player wallets, 0 (identical) to 1 (one player holds everything) + */ +data class StoredPulseSample( + val hour: Long, + val at: Long, + val supply: Map, + val accounts: Map, + val activeWallets: Int, + val median: Long, + val mean: Long, + val richest: Long, + val topShare: Double, + val gini: Double, +) 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 new file mode 100644 index 0000000..88c4bdc --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/FlowCategory.kt @@ -0,0 +1,78 @@ +package net.trilleo.mc.plugins.tritown.enums + +import net.trilleo.mc.plugins.tritown.economy.EconomyContext +import net.trilleo.mc.plugins.tritown.economy.TransactionReason + +/** + * What a movement of money was for, in the few buckets an economy is actually + * read in. + * + * A transaction is recorded with a reason key and a source, which together are + * precise enough to read one line of a history but far too fine to total: the + * reason carries arguments (`money.reason.admin-set?admin=Bob`), so summing by + * reason would produce a row per administrator. A category is the stable, + * translatable grouping the statistics are kept in, and it never grows with the + * number of players. + */ +enum class FlowCategory { + + /** The balance a player is given the first time they join. Always a faucet. */ + STARTING_BALANCE, + + /** Bought from, or sold to, one of the server's own shops. */ + SHOP, + + /** Anything Towny moved: bank deposits and withdrawals, plot sales, upkeep, taxes. */ + TOWNY, + + /** An administrator giving, taking, setting or resetting a balance. */ + ADMIN, + + /** A payment from one account to another. */ + PAYMENT, + + /** Another plugin, through Vault, with nothing to identify it further. */ + EXTERNAL, + + /** Recorded with a reason TriTown has no category for. */ + OTHER; + + /** The translation key naming this category, spelled out so the language test can see it. */ + val key: String + get() = when (this) { + STARTING_BALANCE -> "money.flow.starting-balance" + SHOP -> "money.flow.shop" + TOWNY -> "money.flow.towny" + ADMIN -> "money.flow.admin" + PAYMENT -> "money.flow.payment" + EXTERNAL -> "money.flow.external" + OTHER -> "money.flow.other" + } + + companion object { + + /** + * The category a transaction recorded with [source] and [reason] belongs + * to. + * + * The reason is matched on its key alone, since anything after `?` is an + * argument. The source is only consulted when the reason says nothing + * useful, which is what happens when another plugin moves money through + * Vault inside an operation TriTown did attribute. + */ + fun of(source: String, reason: String): FlowCategory { + val key = reason.substringBefore('?') + return when { + key == TransactionReason.STARTING_BALANCE -> STARTING_BALANCE + 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 + source == EconomyContext.SOURCE_SHOP -> SHOP + source == EconomyContext.SOURCE_TOWNY -> TOWNY + key == TransactionReason.EXTERNAL -> EXTERNAL + else -> OTHER + } + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/economy/EconomyFlushTask.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/economy/EconomyFlushTask.kt index b4faa2b..5b8f4a9 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/economy/EconomyFlushTask.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/economy/EconomyFlushTask.kt @@ -3,19 +3,21 @@ package net.trilleo.mc.plugins.tritown.tasks.economy import net.trilleo.mc.plugins.tritown.config.EconomySettings import net.trilleo.mc.plugins.tritown.economy.BaltopCache import net.trilleo.mc.plugins.tritown.economy.CurrencyRegistry +import net.trilleo.mc.plugins.tritown.economy.EconomyPulse import net.trilleo.mc.plugins.tritown.economy.EconomyService import net.trilleo.mc.plugins.tritown.registration.PluginTask /** - * Writes changed accounts to disk on an interval and rebuilds the balance - * leaderboard, both off the main thread. + * Writes changed accounts to disk on an interval, measures the economy and + * rebuilds the balance leaderboard, all off the main thread. * * The interval bounds how much a hard crash can cost, since `onDisable` does * not run when a server is killed. Balances are still written immediately for * anything that should not wait, such as an administrator changing one. * - * The leaderboard is rebuilt here because this task is already walking every - * account, which keeps sorting off the main thread entirely. + * The measurement and the leaderboard are done here because this task is + * already walking every account, which keeps both the sorting and the + * statistics off the main thread entirely. */ class EconomyFlushTask : PluginTask( delay = flushIntervalTicks(), @@ -25,6 +27,10 @@ class EconomyFlushTask : PluginTask( override fun run() { if (!EconomyService.isReady) return + // Measured before the flush, so the sample this round takes is written + // in the same pass rather than waiting for the next one. + if (CurrencyRegistry.isLoaded) EconomyPulse.sample(EconomyService.ledger, CurrencyRegistry.primary) + EconomyService.flush() if (CurrencyRegistry.isLoaded) { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 1ab7efc..4f54eba 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -96,6 +96,18 @@ economy: roll-size-mb: 16 time-format: "yyyy-MM-dd HH:mm" + stats: + # Keep the figures the admin panel reads: how much currency is created and + # destroyed each hour, what for, and how the supply is spread across players. + # They live in /economy/statistics.json and are written on the + # same interval as balances. Turning this off empties /tritown admin. + # Both settings here are read at startup, so changing them needs a restart. + enabled: true + # How many days of hourly figures to keep. 0 keeps them forever; a year of + # them is well under a megabyte, so the cost is time to read at startup + # rather than space. + retention-days: 30 + # ── Shops ─────────────────────────────────────────────────────────────────── # Shops the server itself runs. Everything about a shop — what it sells, what it # costs, who may use it — is set up in game with /tritown shop, not here; these diff --git a/src/main/resources/lang/en_US.yml b/src/main/resources/lang/en_US.yml index 8e3b1c7..607fec3 100644 --- a/src/main/resources/lang/en_US.yml +++ b/src/main/resources/lang/en_US.yml @@ -178,6 +178,16 @@ money: shop-buy: "Bought at {shop}" shop-sell: "Sold at {shop}" + # Where money comes from and where it goes, as the admin panel groups it. + flow: + starting-balance: "New players" + shop: "Shops" + towny: "Towny" + admin: "Administration" + payment: "Player payments" + external: "Other plugins" + other: "Uncategorised" + # What asked for the transaction. A source TriTown does not know is shown as it was recorded. source: vault: "Another plugin" diff --git a/src/main/resources/lang/zh_CN.yml b/src/main/resources/lang/zh_CN.yml index 4987515..68a930c 100644 --- a/src/main/resources/lang/zh_CN.yml +++ b/src/main/resources/lang/zh_CN.yml @@ -177,6 +177,16 @@ money: shop-buy: "在 {shop} 购买" shop-sell: "在 {shop} 出售" + # 资金的来源与去向,管理面板按此归类统计。 + flow: + starting-balance: "新玩家" + shop: "商店" + towny: "Towny" + admin: "管理操作" + payment: "玩家转账" + external: "其他插件" + other: "未归类" + # 交易的发起方。TriTown 不认识的来源会按记录原样显示。 source: vault: "其他插件" diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyPulseTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyPulseTest.kt new file mode 100644 index 0000000..4fefd70 --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyPulseTest.kt @@ -0,0 +1,176 @@ +package net.trilleo.mc.plugins.tritown.economy + +import net.trilleo.mc.plugins.tritown.economy.storage.JsonPulseStorage +import net.trilleo.mc.plugins.tritown.enums.AccountType +import net.trilleo.mc.plugins.tritown.enums.FlowCategory +import net.trilleo.mc.plugins.tritown.enums.TransactionType +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.util.logging.Level +import java.util.logging.Logger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class EconomyPulseTest { + + @TempDir + lateinit var directory: File + + private val logger = Logger.getLogger("EconomyPulseTest").apply { level = Level.OFF } + private val currency = Currency("dollar", "Dollar", "Dollars", "$", 2, "%symbol%%amount%", "%symbol%%amount%") + + @BeforeEach + fun setUp() { + EconomyPulse.start(JsonPulseStorage(directory, logger), currency, retentionDays = 30) + } + + @AfterEach + fun tearDown() { + EconomyPulse.shutdown() + } + + private fun deposit(minor: Long, reason: String, holder: AccountType = AccountType.PLAYER) = + EconomyPulse.record( + TransactionType.DEPOSIT, currency, Money(minor), holder, EconomyContext.SOURCE_COMMAND, reason + ) + + private fun withdraw(minor: Long, reason: String, holder: AccountType = AccountType.PLAYER) = + EconomyPulse.record( + TransactionType.WITHDRAW, currency, Money(minor), holder, EconomyContext.SOURCE_COMMAND, reason + ) + + @Test + fun `totals deposits as created and withdrawals as destroyed`() { + deposit(10_000L, TransactionReason.STARTING_BALANCE) + withdraw(2_500L, TransactionReason.SHOP_BUY) + + val flow = EconomyPulse.window(hours = 24) + + assertEquals(10_000L, flow.created) + assertEquals(2_500L, flow.destroyed) + assertEquals(7_500L, flow.net) + assertEquals(2, flow.movements.toInt()) + } + + @Test + fun `files movements under the category their reason belongs to`() { + deposit(10_000L, TransactionReason.STARTING_BALANCE) + deposit(500L, TransactionReason.SHOP_SELL) + withdraw(300L, TransactionReason.SHOP_BUY) + withdraw(1_000L, TransactionReason.TOWNY, AccountType.TOWN) + + val flow = EconomyPulse.window(hours = 24) + + assertEquals(10_000L, flow.createdBy[FlowCategory.STARTING_BALANCE]) + assertEquals(500L, flow.createdBy[FlowCategory.SHOP]) + assertEquals(300L, flow.destroyedBy[FlowCategory.SHOP]) + assertEquals(200L, flow.netOf(FlowCategory.SHOP)) + assertEquals(-1_000L, flow.netOf(FlowCategory.TOWNY)) + } + + @Test + fun `counts a transfer once and leaves the supply alone`() { + EconomyPulse.record( + TransactionType.TRANSFER_OUT, currency, Money(4_000L), AccountType.PLAYER, + EconomyContext.SOURCE_COMMAND, TransactionReason.PAYMENT, + ) + EconomyPulse.record( + TransactionType.TRANSFER_IN, currency, Money(4_000L), AccountType.PLAYER, + EconomyContext.SOURCE_COMMAND, TransactionReason.PAYMENT, + ) + + val flow = EconomyPulse.window(hours = 24) + + assertEquals(4_000L, flow.circulated) + assertEquals(1L, flow.transfers) + assertEquals(0L, flow.created) + assertEquals(0L, flow.destroyed) + } + + @Test + fun `keeps balances that were set apart from money that was created`() { + EconomyPulse.record( + TransactionType.SET, currency, Money(9_000L), AccountType.PLAYER, + EconomyContext.SOURCE_COMMAND, TransactionReason.ADMIN_SET, + ) + + val flow = EconomyPulse.window(hours = 24) + + assertEquals(9_000L, flow.adjusted) + assertEquals(0L, flow.created) + assertEquals(0L, flow.destroyed) + } + + @Test + fun `ignores a currency it is not keeping figures in`() { + val other = currency.copy(id = "credit") + EconomyPulse.record( + TransactionType.DEPOSIT, other, Money(5_000L), AccountType.PLAYER, + EconomyContext.SOURCE_COMMAND, TransactionReason.EXTERNAL, + ) + + assertTrue(EconomyPulse.window(hours = 24).isEmpty) + } + + @Test + fun `splits the window into columns that add up to the whole`() { + deposit(1_000L, TransactionReason.EXTERNAL) + withdraw(400L, TransactionReason.EXTERNAL) + + val flow = EconomyPulse.window(hours = 24, slices = 7) + + assertEquals(7, flow.slices.size) + assertEquals(1_000L, flow.slices.sumOf { it.created }) + assertEquals(400L, flow.slices.sumOf { it.destroyed }) + // Everything just recorded belongs to the last column, which ends now. + assertEquals(1_000L, flow.slices.last().created) + } + + @Test + fun `measures the supply and how unevenly it is held`() { + val ledger = EconomyLedger() + ledger.deposit(ledger.getOrCreate(uuid(1), "Alex", AccountType.PLAYER), currency, Money(1_000L)) + ledger.deposit(ledger.getOrCreate(uuid(2), "Blair", AccountType.PLAYER), currency, Money(3_000L)) + ledger.deposit(ledger.getOrCreate(uuid(3), "Casey", AccountType.PLAYER), currency, Money(6_000L)) + ledger.deposit(ledger.getOrCreate(uuid(4), "town-Riverbend", AccountType.TOWN), currency, Money(5_000L)) + + EconomyPulse.sample(ledger, currency) + val supply = assertNotNull(EconomyPulse.latest()) + + assertEquals(15_000L, supply.total) + assertEquals(10_000L, supply.held) + assertEquals(5_000L, supply.banked) + assertEquals(3, supply.wallets) + assertEquals(3_000L, supply.median) + assertEquals(6_000L, supply.richest) + assertTrue(supply.gini > 0.0, "an uneven ledger should not read as perfectly equal") + } + + @Test + fun `reads back what it wrote`() { + deposit(7_000L, TransactionReason.STARTING_BALANCE) + EconomyPulse.flush() + + EconomyPulse.start(JsonPulseStorage(directory, logger), currency, retentionDays = 30) + val flow = EconomyPulse.window(hours = 24) + + assertEquals(7_000L, flow.created) + assertEquals(7_000L, flow.createdBy[FlowCategory.STARTING_BALANCE]) + } + + @Test + fun `drops figures written in another currency rather than reading them as its own`() { + deposit(7_000L, TransactionReason.STARTING_BALANCE) + EconomyPulse.flush() + + EconomyPulse.start(JsonPulseStorage(directory, logger), currency.copy(id = "credit"), retentionDays = 30) + + assertTrue(EconomyPulse.window(hours = 24).isEmpty) + } + + private fun uuid(seed: Long) = java.util.UUID(0L, seed) +} From 8d8148e7ae8a9675f22c4aec10f9dc5ee4deda06 Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sat, 19 Sep 2026 19:25:47 +0800 Subject: [PATCH 09/14] Feature: Add the admin panel, opening on the economy /tritown admin opens a menu that reads the server back to an owner. The economy section puts the supply, who holds it, what created and removed it, how unevenly wealth is spread and how fast money circulates on one screen, over the last day, week, month or everything on record, with the window drawn as a column chart. A full breakdown opens every category and every kind of account behind those totals. The shop sales figures move in beside them: one screen lists every shop with what it has taken in and paid out, and clicking one opens the figures that shop already had. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 30 + CLAUDE.md | 22 +- README.md | 10 + docs/DEVELOPER_GUIDE.md | 40 +- .../tritown/commands/admin/AdminCommand.kt | 79 +++ .../mc/plugins/tritown/enums/StatsWindow.kt | 33 + .../tritown/guis/admin/AdminPanelGUI.kt | 146 +++++ .../tritown/guis/admin/AdminShopsGUI.kt | 153 +++++ .../tritown/guis/admin/EconomyFlowGUI.kt | 209 ++++++ .../tritown/guis/admin/EconomyPanelGUI.kt | 618 ++++++++++++++++++ .../plugins/tritown/guis/admin/PanelRender.kt | 94 +++ .../plugins/tritown/guis/admin/PanelState.kt | 37 ++ .../listeners/admin/PanelStateListener.kt | 21 + .../tritown/registration/GUIManager.kt | 18 + src/main/resources/lang/en_US.yml | 207 ++++++ src/main/resources/lang/zh_CN.yml | 207 ++++++ 16 files changed, 1917 insertions(+), 7 deletions(-) create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/admin/AdminCommand.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/StatsWindow.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/AdminPanelGUI.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/AdminShopsGUI.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyFlowGUI.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyPanelGUI.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/PanelRender.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/PanelState.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/admin/PanelStateListener.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a72335..b38d6a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,34 @@ renaming it in FancyNpcs does not break anything. FancyNpcs is optional — without it everything else still works, and `/tritown shop open [player]` still opens a shop from the console or for testing. +#### Admin Panel + ++ Added `/tritown admin`, an administration panel that opens as a menu. It is the way in to what an owner needs to read + about the server, starting with the economy; more sections will follow. ++ The economy panel puts the whole economy on one screen: + + **Money supply** — how much currency exists, how it splits between player wallets, town and nation banks and the + server's own accounts, how far it has moved over the window, and how much that is per wallet. The supply is + measured off the accounts themselves rather than added up from movements, so it is exact. + + **Faucets and sinks** — how much currency was created and how much was removed, each broken down by what caused + it: new players, shops, Towny, administrators or another plugin. The net says which way the economy is drifting, + per day and as a share of the supply, with how long it would take at that rate to double or run dry. + + **Wealth distribution** — the median, mean and largest wallet, the share held by the richest tenth, and an + inequality figure with a word for what it means. + + **Circulation** — how much money players moved between themselves, over how many payments, and how quickly the + supply turns over. + + **A chart** — the window drawn as seven columns, each as tall as its net change, so a payday, a sink nobody uses + or a runaway faucet shows up as a shape rather than a number. + + **Accounts, the richest accounts, the shops and the ledger's own settings**, so nothing needs a command to check. ++ Every figure can be read over the last day, the last week, the last month or everything on record. Click the clock to + change the window and the whole screen follows it. ++ A full breakdown lists every source of money and every kind of account with what it created, removed and netted, for + the same window. ++ The shop sales figures now have a home in the panel: one screen lists every shop with what it has taken in and paid + out, and clicking one opens the figures that shop already had. `/tritown shop stats ` still opens a single shop + directly, and shift-clicking a shop here opens its editor. ++ Each section has its own permission — `tritown.admin` to open the panel, `tritown.admin.economy` and + `tritown.admin.shops` for the sections — so a moderator can be given the reading without the editing. + ### Fixes #### Misc @@ -69,6 +97,8 @@ + `PlayerData` and `ServerData` gained `getJsonObject`, so a nested object that was written can be read back. + Added `ChatPrompt`, which asks a player a question in chat and hands the answer back on the server thread. Menus use it for anything that has to be typed, such as a price or a permission node. ++ `GUIManager.openLater` opens a menu on the following tick, which is what a menu reached by clicking inside another + one needs so the server and the client do not disagree about what is on screen. #### Economy diff --git a/CLAUDE.md b/CLAUDE.md index acb434a..d03dd0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,14 +68,14 @@ The server console reads commands from the terminal running Gradle. src/main/kotlin/net/trilleo/mc/plugins/tritown/ ├── Main.kt # Plugin entry point (Main.instance, Main.reload()) ├── commands/ # Sub-commands (auto-registered) -│ ├── info/ -│ └── moderation/ +│ ├── admin/ economy/ info/ +│ └── moderation/ scoreboard/ shop/ ├── config/ # PluginConfig (typed config.yml wrapper), EconomySettings ├── data/ # JSON-persisted PlayerData / ServerData and their managers ├── economy/ # The economy: ledger, accounts, currencies, Vault provider, statistics, │ # storage (not scanned) -├── enums/ # AccountType, FlowCategory, TransactionType, FillMode, PagedGUIMode, … -├── guis/ # GUIs (auto-registered, extend PluginGUI / PagedPluginGUI) +├── enums/ # AccountType, FlowCategory, StatsWindow, TransactionType, FillMode, … +├── guis/ # GUIs (auto-registered, extend PluginGUI / PagedPluginGUI); admin/ is the panel ├── items/ # Custom items (auto-registered, extend PluginItem) ├── listeners/ # Event listeners, including Towny events (auto-registered) ├── recipes/ # Recipes (auto-registered, implement PluginRecipe) @@ -197,6 +197,20 @@ complete stack and no separate economy plugin is needed. See and remember the supply is measured off the ledger rather than accumulated. See [Economy Statistics](docs/DEVELOPER_GUIDE.md#economy-statistics). +## Working with the Admin Panel + +The panel in `guis/admin` is where an owner reads the server; `/tritown admin` opens it. See +[Admin Panel](docs/DEVELOPER_GUIDE.md#admin-panel). + +- **A section is a card and a menu.** Adding one means adding a card to `AdminPanelGUI` and a menu of its own; nothing + else in the panel changes. Give it its own permission under `tritown.admin.*` and do not draw a card the viewer + cannot open. +- **The panel reads, it does not write.** Anything that changes the server belongs in the command or menu that owns it, + not here. +- **Format through `PanelRender`** — money, percentages, rates, timestamps and the cards themselves, so the same figure + reads the same wherever it appears. Amounts stay in minor units until they reach it. +- **The window belongs to the viewer**, in `PanelState`, so every menu of the panel agrees on what is being looked at. + ## Working with Shops **Shops are the server's own, not a player's.** See [Shops](docs/DEVELOPER_GUIDE.md#shops). diff --git a/README.md b/README.md index 4cf5df0..774b81e 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,12 @@ 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. +**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 +the window drawn as columns. Read any of it over the last day, week or month, or over everything on record. Every +shop's takings are in there too, next to the economy they act on. + **English and Simplified Chinese.** Every message, menu and item TriTown shows is translated. By default each player sees whichever of the two their Minecraft client is set to, and everyone else sees English. Set `language` in `config.yml` to `en_US` or `zh_CN` to pick one for the whole server, or edit the files in `plugins/TriTown/lang/` to @@ -86,6 +92,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 | | `/tt shop …` | Set up the server's shops (OP only) | +| `/tt admin [section]` | Open the admin panel (OP only) | `/eco` takes `give`, `take` and `set` (` [currency]`), `reset ` back to the starting balance, `info ` for an account's details, `history [player]` to browse recorded transactions in a menu, and `flush` to @@ -97,6 +104,9 @@ 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. +`/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. + Commands are sub-commands of `/tritown` (alias `/tt`) unless noted. The economy commands are registered as top-level commands as well, which `economy.commands.top-level-aliases` turns off — they are then only reachable as `/tt balance`, `/tt pay` and `/tt baltop`. If another plugin already owns one of those names it keeps it, and TriTown's diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 5481099..0e99b3c 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**, and the **sidebar**. Commands, listeners, GUIs, tasks, custom items, and recipes all +**Towny**, the **Vault economy**, the **admin panel** 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. @@ -351,8 +351,8 @@ override fun onClick(event: InventoryClickEvent) { ``` Never open another inventory from inside a click handler: the click is still being delivered, and the server and client -end up disagreeing about what is on screen. Schedule it for the next tick instead -(`Bukkit.getScheduler().runTask(Main.instance) { … }`). +end up disagreeing about what is on screen. Use `GUIManager.openLater(player, id)`, which opens it on the following +tick. ### Opening a GUI @@ -363,6 +363,9 @@ import net.trilleo.mc.plugins.tritown.registration.GUIManager // Returns true if the GUI was found and opened, false otherwise GUIManager.open(player, "settings") + +// From inside a click handler, so the client is not left disagreeing about what is on screen +GUIManager.openLater(player, "settings") ``` ### Example @@ -2332,6 +2335,37 @@ Amounts are **minor units** throughout, exactly as the ledger holds them; they b --- +## Admin Panel + +The panel lives in `guis/admin` and is opened by `/tritown admin` (`commands/admin/AdminCommand`). It is a reading +surface: apart from writing the economy to disk on request, nothing in it changes the server. + +| Menu | Id | Shows | +|:-------------------|:-----------------|:-------------------------------------------------------------------| +| `AdminPanelGUI` | `admin-panel` | The sections, each with enough of itself to say whether to open it | +| `EconomyPanelGUI` | `admin-economy` | Supply, accounts, distribution, faucets, sinks, net, and the chart | +| `EconomyFlowGUI` | `admin-flow` | Every category and every kind of account, in full | +| `AdminShopsGUI` | `admin-shops` | Every shop's takings, opening into that shop's own figures | + +Adding a section means adding a card to `AdminPanelGUI` and a menu of its own — nothing else in the panel changes. + +**The window belongs to the viewer, not to a menu.** `PanelState` holds which of `StatsWindow.DAY`, `WEEK`, `MONTH` +or `ALL` each administrator is looking at, so switching it in the overview and then opening the breakdown does not +quietly go back to the last day. It is dropped when they quit (`listeners/admin/PanelStateListener`) rather than when +a menu closes, because opening the next menu closes the last one. + +**`PanelRender` is the only place a figure becomes text** — money, percentages, rates, timestamps, category and +account-type names, and the cards themselves — so the same number reads the same wherever it appears. + +**The chart is stack sizes.** Each of the seven columns is a stained-glass pane whose stack size is its net change +next to the largest one, green where the supply grew and red where it shrank. It reads as a chart at a glance without +a single custom texture, and the exact figures are in the lore. + +Permissions: `tritown.admin` opens the panel, `tritown.admin.economy` and `tritown.admin.shops` open the sections. A +card the viewer may not open is not drawn at all. + +--- + ## Economy (Vault) Vault is a hard dependency (`depend` in `plugin.yml`); the Vault API is `compileOnly` (`vault_api_version` in diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/admin/AdminCommand.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/admin/AdminCommand.kt new file mode 100644 index 0000000..aec7df7 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/admin/AdminCommand.kt @@ -0,0 +1,79 @@ +package net.trilleo.mc.plugins.tritown.commands.admin + +import net.trilleo.mc.plugins.tritown.guis.admin.AdminPanelGUI +import net.trilleo.mc.plugins.tritown.guis.admin.AdminShopsGUI +import net.trilleo.mc.plugins.tritown.guis.admin.EconomyPanelGUI +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PluginCommand +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 + +/** + * Opens the administration panel. + * + * The panel is where the server is read rather than changed, so the command + * takes nothing but the section to land on — everything it shows is a menu, and + * a section named here only saves a click. + */ +class AdminCommand : PluginCommand( + name = "admin", + description = "Open the admin panel", + usage = "/tritown admin [economy|shops]", + permission = PERMISSION, +) { + + override val extraPermissions = listOf(AdminPanelGUI.ECONOMY_PERMISSION, AdminPanelGUI.SHOPS_PERMISSION) + + override fun execute(sender: CommandSender, args: Array): Boolean { + val player = sender as? Player ?: run { + sender.sendPrefixed(sender.tr("command.admin.players-only")) + return true + } + + val section = args.firstOrNull()?.lowercase() + if (section != null && section !in SECTIONS) { + player.sendPrefixed(player.tr("command.admin.unknown-section", "section" to section)) + return true + } + + val permission = permissionFor(section) + if (permission != null && !player.hasPermission(permission)) { + player.sendPrefixed(player.tr("command.admin.no-permission-section", "section" to section.orEmpty())) + return true + } + + if (!GUIManager.open(player, guiFor(section))) { + player.sendPrefixed(player.tr("command.admin.unavailable")) + } + return true + } + + override fun tabComplete(sender: CommandSender, args: Array): List = + if (args.size == 1) { + SECTIONS.filter { + permissionFor(it)?.let(sender::hasPermission) != false && it.startsWith(args[0], ignoreCase = true) + } + } else { + emptyList() + } + + private fun guiFor(section: String?): String = when (section) { + "economy" -> EconomyPanelGUI.ID + "shops" -> AdminShopsGUI.ID + else -> AdminPanelGUI.ID + } + + private fun permissionFor(section: String?): String? = when (section) { + "economy" -> AdminPanelGUI.ECONOMY_PERMISSION + "shops" -> AdminPanelGUI.SHOPS_PERMISSION + else -> null + } + + private companion object { + const val PERMISSION = "tritown.admin" + + val SECTIONS = listOf("economy", "shops") + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/StatsWindow.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/StatsWindow.kt new file mode 100644 index 0000000..121e465 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/StatsWindow.kt @@ -0,0 +1,33 @@ +package net.trilleo.mc.plugins.tritown.enums + +/** + * How far back the admin panel's figures reach. + * + * The window is a property of who is looking rather than of the data: the + * statistics are kept hour by hour, and a window is simply how many of those + * hours are added up. + */ +enum class StatsWindow(val hours: Int) { + + DAY(24), + WEEK(24 * 7), + MONTH(24 * 30), + + /** Everything still on record, however far back the retention reaches. */ + ALL(0); + + /** The translation key naming this window, spelled out so the language test can see it. */ + val key: String + get() = when (this) { + DAY -> "gui.admin-economy.window-day" + WEEK -> "gui.admin-economy.window-week" + MONTH -> "gui.admin-economy.window-month" + ALL -> "gui.admin-economy.window-all" + } + + /** The next window in the cycle, so a click can step through them. */ + fun next(): StatsWindow = entries[(ordinal + 1) % entries.size] + + /** The previous window in the cycle. */ + fun previous(): StatsWindow = entries[(ordinal + entries.size - 1) % entries.size] +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/AdminPanelGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/AdminPanelGUI.kt new file mode 100644 index 0000000..e7777b3 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/AdminPanelGUI.kt @@ -0,0 +1,146 @@ +package net.trilleo.mc.plugins.tritown.guis.admin + +import com.palmergames.bukkit.towny.TownyAPI +import net.trilleo.mc.plugins.tritown.Main +import net.trilleo.mc.plugins.tritown.config.ShopSettings +import net.trilleo.mc.plugins.tritown.economy.EconomyPulse +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.enums.StatsWindow +import net.trilleo.mc.plugins.tritown.guis.shop.ShopRender +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.shops.ShopManager +import net.trilleo.mc.plugins.tritown.utils.EconomyUtil +import net.trilleo.mc.plugins.tritown.utils.TownyUtil +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Bukkit +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack + +/** + * The way in to everything an administrator runs the server from. + * + * Deliberately thin: it names the sections and shows just enough of each to say + * whether it is worth opening. Everything a section knows lives in that + * section's own menu, so adding one here is adding a card, not rewriting this. + */ +class AdminPanelGUI : PluginGUI( + id = ID, + titleKey = "gui.admin.title", + rows = 3, + fillMode = FillMode.NONE, +) { + + override fun setup(player: Player, inventory: Inventory) { + GUIFrame.draw(inventory, listOf(ECONOMY_SLOT, SHOPS_SLOT, SERVER_SLOT)) + + if (player.hasPermission(ECONOMY_PERMISSION)) inventory.setItem(ECONOMY_SLOT, economy(player)) + if (player.hasPermission(SHOPS_PERMISSION)) inventory.setItem(SHOPS_SLOT, shops(player)) + inventory.setItem(SERVER_SLOT, server(player)) + } + + override fun onClick(event: InventoryClickEvent) { + event.isCancelled = true + val player = event.whoClicked as? Player ?: return + + when (event.rawSlot) { + ECONOMY_SLOT -> if (player.hasPermission(ECONOMY_PERMISSION)) { + GUIManager.openLater(player, EconomyPanelGUI.ID) + } + + SHOPS_SLOT -> if (player.hasPermission(SHOPS_PERMISSION)) { + GUIManager.openLater(player, AdminShopsGUI.ID) + } + } + } + + // ── Cards ─────────────────────────────────────────────────────────── + + private fun economy(player: Player): ItemStack { + val lines = mutableListOf(player.tr("gui.admin.economy-lore")) + + if (!EconomyPulse.isEnabled) { + lines += player.tr("gui.admin.economy-stats-off") + } else { + val supply = EconomyPulse.latest() + val flow = EconomyPulse.window(StatsWindow.DAY.hours) + lines += "" + lines += player.tr( + "gui.admin.economy-supply", + "amount" to PanelRender.money(supply?.total ?: 0L), + ) + lines += player.tr( + "gui.admin.economy-net", + "amount" to PanelRender.delta(player, flow.net), + "window" to player.tr(StatsWindow.DAY.key), + ) + lines += player.tr("gui.admin.economy-accounts", "amount" to (supply?.accountCount ?: 0)) + } + + lines += "" + lines += player.tr("gui.admin.click-open") + return PanelRender.card(Material.GOLD_INGOT, player.tr("gui.admin.economy"), lines) + } + + private fun shops(player: Player): ItemStack { + val lines = mutableListOf(player.tr("gui.admin.shops-lore")) + + if (!ShopSettings.isLoaded || !ShopSettings.snapshot.enabled || !ShopManager.isReady) { + lines += player.tr("gui.admin.shops-off") + } else { + val shops = ShopManager.all() + val moneyIn = shops.sumOf { shop -> shop.entries.sumOf { it.stats.moneyIn } } + val moneyOut = shops.sumOf { shop -> shop.entries.sumOf { it.stats.moneyOut } } + lines += "" + lines += player.tr("gui.admin.shops-count", "amount" to shops.size) + lines += player.tr("gui.admin.shops-taken", "amount" to ShopRender.money(moneyIn)) + lines += player.tr("gui.admin.shops-paid", "amount" to ShopRender.money(moneyOut)) + } + + lines += "" + lines += player.tr("gui.admin.click-open") + return PanelRender.card(Material.EMERALD, player.tr("gui.admin.shops"), lines) + } + + /** + * What the server is running, in the two or three numbers that say whether + * anything is wrong before the sections are opened. + */ + private fun server(player: Player): ItemStack { + val towny = TownyAPI.getInstance() + val provider = runCatching { EconomyUtil.economy.name }.getOrNull() + + val lines = listOf( + player.tr("gui.admin.server-version", "version" to Main.instance.pluginMeta.version), + player.tr( + "gui.admin.server-provider", + "provider" to (provider ?: player.tr("gui.admin.server-no-provider")), + ), + "", + player.tr("gui.admin.server-towns", "amount" to towny.towns.size), + player.tr("gui.admin.server-nations", "amount" to towny.nations.size), + player.tr("gui.admin.server-online", "amount" to Bukkit.getOnlinePlayers().size), + player.tr( + "gui.admin.server-newday", + "time" to TownyUtil.duration(player, TownyUtil.secondsUntilNewDay()), + ), + ) + + return PanelRender.card(Material.BEACON, player.tr("gui.admin.server"), lines) + } + + companion object { + const val ID = "admin-panel" + + const val ECONOMY_PERMISSION = "tritown.admin.economy" + const val SHOPS_PERMISSION = "tritown.admin.shops" + + private const val ECONOMY_SLOT = 11 + private const val SHOPS_SLOT = 13 + private const val SERVER_SLOT = 15 + } +} 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 new file mode 100644 index 0000000..ce7d7f6 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/AdminShopsGUI.kt @@ -0,0 +1,153 @@ +package net.trilleo.mc.plugins.tritown.guis.admin + +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.guis.shop.ShopEditorGUI +import net.trilleo.mc.plugins.tritown.guis.shop.ShopRender +import net.trilleo.mc.plugins.tritown.guis.shop.ShopStatsGUI +import net.trilleo.mc.plugins.tritown.registration.GUIManager +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.tr +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.inventory.ClickType +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.inventory.ItemStack + +/** + * What every shop on the server has traded, side by side. + * + * A single shop's figures already have a menu of their own, reached from its + * editor; this is the row above that, where a shop that is quietly draining the + * economy stands out next to the ones that are not. Clicking a shop opens its + * own figures, so the two are one view at two depths rather than two views of + * the same thing. + */ +class AdminShopsGUI : PagedPluginGUI( + id = ID, + titleKey = "gui.admin-shops.title", + rows = 6, + fillMode = FillMode.NONE, + layout = PagedLayout.FRAMED, +) { + + override fun getItems(player: Player): List { + if (!ShopSettings.isLoaded || !ShopSettings.snapshot.enabled || !ShopManager.isReady) { + return listOf( + PanelRender.card( + Material.BARRIER, + player.tr("gui.admin-shops.disabled"), + listOf(player.tr("gui.admin-shops.disabled-lore")), + ) + ) + } + + val shops = ShopManager.all() + if (shops.isEmpty()) { + return listOf( + PanelRender.card( + Material.BARRIER, + player.tr("gui.admin-shops.empty"), + listOf(player.tr("gui.admin-shops.empty-lore")), + ) + ) + } + + return listOf(header(player, shops)) + shops.map { row(player, it) } + } + + override fun navButtons(player: Player): Map = mapOf( + BACK_OFFSET to PanelRender.card( + Material.ARROW, + player.tr("gui.admin-shops.back"), + listOf(player.tr("gui.admin-shops.back-lore")), + ), + ) + + override fun onNavClick(event: InventoryClickEvent, offset: Int) { + val player = event.whoClicked as? Player ?: return + if (offset == BACK_OFFSET) GUIManager.openLater(player, AdminPanelGUI.ID) + } + + override fun onContentClick(event: InventoryClickEvent, page: Int) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + if (!ShopManager.isReady) return + + // The header takes the first position, so the shops start one behind it. + val index = (contentIndex(page, event.rawSlot) ?: return) - 1 + val shop = ShopManager.all().getOrNull(index) ?: return + + if (event.click == ClickType.SHIFT_LEFT) { + if (player.hasPermission(EDIT_PERMISSION)) ShopRender.navigate { ShopEditorGUI.show(player, shop) } + return + } + ShopRender.navigate { ShopStatsGUI.show(player, shop) } + } + + // ── Rendering ─────────────────────────────────────────────────────── + + private fun header(player: Player, shops: List): ItemStack { + val moneyIn = shops.sumOf { shop -> shop.entries.sumOf { it.stats.moneyIn } } + val moneyOut = shops.sumOf { shop -> shop.entries.sumOf { it.stats.moneyOut } } + val bought = shops.sumOf { shop -> shop.entries.sumOf { it.stats.bought } } + val sold = shops.sumOf { shop -> shop.entries.sumOf { it.stats.sold } } + + return PanelRender.card( + Material.WRITABLE_BOOK, + player.tr("gui.admin-shops.header"), + listOf( + player.tr("gui.admin-shops.count", "amount" to shops.size), + player.tr("gui.admin-shops.entries", "amount" to shops.sumOf { it.entries.size }), + "", + player.tr("gui.shop-stats.bought", "amount" to bought), + player.tr("gui.shop-stats.sold", "amount" to sold), + player.tr("gui.shop-stats.money-in", "amount" to ShopRender.money(moneyIn)), + player.tr("gui.shop-stats.money-out", "amount" to ShopRender.money(moneyOut)), + player.tr("gui.shop-stats.net", "amount" to ShopRender.money(moneyIn - moneyOut)), + "", + player.tr("gui.admin-shops.header-lore"), + ), + ) + } + + private fun row(player: Player, shop: ShopDefinition): ItemStack { + val moneyIn = shop.entries.sumOf { it.stats.moneyIn } + val moneyOut = shop.entries.sumOf { it.stats.moneyOut } + + val lines = listOf( + player.tr("gui.admin-shops.id", "id" to shop.id), + player.tr("gui.admin-shops.entries", "amount" to shop.entries.size), + "", + player.tr("gui.shop-stats.money-in", "amount" to ShopRender.money(moneyIn)), + player.tr("gui.shop-stats.money-out", "amount" to ShopRender.money(moneyOut)), + player.tr("gui.shop-stats.net", "amount" to ShopRender.money(moneyIn - moneyOut)), + "", + player.tr("gui.admin-shops.click-stats"), + player.tr("gui.admin-shops.click-edit"), + ) + + // A shop wears its own goods, so a list of them is recognisable before a + // single line of it has been read. The shop's name is written by an + // administrator as MiniMessage, which is why it is not escaped. + val icon = (shop.entries.firstOrNull()?.displayStack() ?: ItemStack(Material.CHEST)).clone() + icon.editMeta { meta -> + meta.displayName(ComponentUtil.parse(shop.displayName)) + meta.lore(null) + } + + return ShopRender.withLore(icon, lines) + } + + companion object { + const val ID = "admin-shops" + + private const val EDIT_PERMISSION = "tritown.shop.admin.edit" + private const val BACK_OFFSET = 2 + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyFlowGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyFlowGUI.kt new file mode 100644 index 0000000..2c7d853 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyFlowGUI.kt @@ -0,0 +1,209 @@ +package net.trilleo.mc.plugins.tritown.guis.admin + +import net.trilleo.mc.plugins.tritown.economy.EconomyPulse +import net.trilleo.mc.plugins.tritown.enums.AccountType +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.enums.FlowCategory +import net.trilleo.mc.plugins.tritown.enums.PagedLayout +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PagedPluginGUI +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Material +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 +import org.bukkit.inventory.ItemStack +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * Every source of money and every holder of it, in full. + * + * The overview shows the three largest of each because that is what fits on a + * card; this is the same window opened out, so a category that only ever moves a + * little is still visible, and each one carries its own net — which is the line + * that says whether it feeds the economy or drains it. + * + * The figures are built once when the menu opens, because a paged menu asks for + * its items again on every page turn and re-totalling the window each time would + * do the same work several times per click. + */ +class EconomyFlowGUI : PagedPluginGUI( + id = ID, + titleKey = "gui.admin-flow.title", + rows = 6, + fillMode = FillMode.NONE, + layout = PagedLayout.FRAMED, +) { + + private val snapshots = ConcurrentHashMap>() + + override fun setup(player: Player, inventory: Inventory) { + snapshots[player.uniqueId] = render(player) + super.setup(player, inventory) + } + + override fun getItems(player: Player): List = snapshots[player.uniqueId] ?: emptyList() + + override fun navButtons(player: Player): Map = mapOf( + BACK_OFFSET to PanelRender.card( + Material.ARROW, + player.tr("gui.admin-flow.back"), + listOf(player.tr("gui.admin-flow.back-lore")), + ), + WINDOW_OFFSET to PanelRender.card( + Material.CLOCK, + player.tr("gui.admin-economy.window", "window" to player.tr(PanelState.window(player).key)), + listOf(player.tr("gui.admin-economy.window-lore")), + ), + ) + + override fun onNavClick(event: InventoryClickEvent, offset: Int) { + val player = event.whoClicked as? Player ?: return + + when (offset) { + BACK_OFFSET -> GUIManager.openLater(player, EconomyPanelGUI.ID) + WINDOW_OFFSET -> { + PanelState.cycle(player, event.click != ClickType.RIGHT) + setup(player, event.inventory) + } + } + } + + override fun onClose(event: InventoryCloseEvent) { + super.onClose(event) + snapshots.remove((event.player as? Player)?.uniqueId ?: return) + } + + // ── Rendering ─────────────────────────────────────────────────────── + + private fun render(player: Player): List { + if (!EconomyPulse.isEnabled) { + return listOf( + PanelRender.card( + Material.BARRIER, + player.tr("gui.admin-economy.disabled"), + listOf(player.tr("gui.admin-economy.disabled-lore")), + ) + ) + } + + val window = PanelState.window(player) + val flow = EconomyPulse.window(window.hours) + + val items = mutableListOf(header(player, flow, window.key)) + flow.categories().forEach { items += category(player, flow, it) } + + items += PanelRender.card( + Material.PAPER, + player.tr("gui.admin-flow.holders"), + listOf(player.tr("gui.admin-flow.holders-lore")), + ) + holders(flow).forEach { items += holder(player, flow, it) } + + return items + } + + private fun header(player: Player, flow: EconomyPulse.Flow, windowKey: String): ItemStack = PanelRender.card( + Material.BOOK, + player.tr("gui.admin-flow.header", "window" to player.tr(windowKey)), + listOf( + player.tr( + "gui.admin-economy.window-range", + "from" to PanelRender.time(flow.from), + "to" to PanelRender.time(flow.to), + ), + "", + player.tr("gui.admin-economy.generation-total", "amount" to PanelRender.money(flow.created)), + player.tr("gui.admin-economy.sinks-total", "amount" to PanelRender.money(flow.destroyed)), + player.tr("gui.admin-economy.net-total", "amount" to PanelRender.delta(player, flow.net)), + "", + player.tr("gui.admin-flow.movements", "amount" to flow.movements), + player.tr("gui.admin-economy.circulation-volume", "amount" to PanelRender.money(flow.circulated)), + ), + ) + + private fun category(player: Player, flow: EconomyPulse.Flow, category: FlowCategory): ItemStack { + val created = flow.createdBy[category] ?: 0L + val destroyed = flow.destroyedBy[category] ?: 0L + + return PanelRender.card( + material(category), + player.tr("gui.admin-flow.category", "name" to PanelRender.categoryName(player, category)), + listOf( + player.tr( + "gui.admin-flow.created", + "amount" to PanelRender.money(created), + "percent" to PanelRender.percent(PanelRender.share(created, flow.created)), + ), + player.tr( + "gui.admin-flow.destroyed", + "amount" to PanelRender.money(destroyed), + "percent" to PanelRender.percent(PanelRender.share(destroyed, flow.destroyed)), + ), + player.tr("gui.admin-flow.net", "amount" to PanelRender.delta(player, created - destroyed)), + "", + player.tr(descriptionOf(category)), + ), + ) + } + + private fun holder(player: Player, flow: EconomyPulse.Flow, type: AccountType): ItemStack { + val received = flow.createdTo[type] ?: 0L + val paid = flow.destroyedFrom[type] ?: 0L + + return PanelRender.card( + material(type), + player.tr("gui.admin-flow.holder", "name" to PanelRender.holderName(player, type)), + listOf( + player.tr("gui.admin-flow.received", "amount" to PanelRender.money(received)), + player.tr("gui.admin-flow.paid", "amount" to PanelRender.money(paid)), + player.tr("gui.admin-flow.net", "amount" to PanelRender.delta(player, received - paid)), + ), + ) + } + + /** Every kind of account that saw any money, most movement first. */ + private fun holders(flow: EconomyPulse.Flow): List = + (flow.createdTo.keys + flow.destroyedFrom.keys) + .sortedByDescending { (flow.createdTo[it] ?: 0L) + (flow.destroyedFrom[it] ?: 0L) } + + private fun material(category: FlowCategory): Material = when (category) { + FlowCategory.STARTING_BALANCE -> Material.EGG + FlowCategory.SHOP -> Material.EMERALD + FlowCategory.TOWNY -> Material.BELL + FlowCategory.ADMIN -> Material.COMMAND_BLOCK + FlowCategory.PAYMENT -> Material.ENDER_PEARL + FlowCategory.EXTERNAL -> Material.REDSTONE + FlowCategory.OTHER -> Material.PAPER + } + + private fun material(type: AccountType): Material = when (type) { + AccountType.PLAYER -> Material.PLAYER_HEAD + AccountType.TOWN -> Material.BRICKS + AccountType.NATION -> Material.GOLDEN_HELMET + AccountType.NPC -> Material.VILLAGER_SPAWN_EGG + AccountType.SERVER -> Material.COMMAND_BLOCK + AccountType.UNKNOWN -> Material.BARRIER + } + + /** Spelled out rather than built from the enum, so every key is a literal the language test can see. */ + private fun descriptionOf(category: FlowCategory): String = when (category) { + FlowCategory.STARTING_BALANCE -> "gui.admin-flow.about-starting-balance" + FlowCategory.SHOP -> "gui.admin-flow.about-shop" + FlowCategory.TOWNY -> "gui.admin-flow.about-towny" + FlowCategory.ADMIN -> "gui.admin-flow.about-admin" + FlowCategory.PAYMENT -> "gui.admin-flow.about-payment" + FlowCategory.EXTERNAL -> "gui.admin-flow.about-external" + FlowCategory.OTHER -> "gui.admin-flow.about-other" + } + + companion object { + const val ID = "admin-flow" + + private const val BACK_OFFSET = 2 + private const val WINDOW_OFFSET = 6 + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyPanelGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyPanelGUI.kt new file mode 100644 index 0000000..5a81fa6 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyPanelGUI.kt @@ -0,0 +1,618 @@ +package net.trilleo.mc.plugins.tritown.guis.admin + +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.EconomySettings +import net.trilleo.mc.plugins.tritown.config.ShopSettings +import net.trilleo.mc.plugins.tritown.economy.BaltopCache +import net.trilleo.mc.plugins.tritown.economy.CurrencyRegistry +import net.trilleo.mc.plugins.tritown.economy.EconomyPulse +import net.trilleo.mc.plugins.tritown.economy.EconomyService +import net.trilleo.mc.plugins.tritown.economy.TownyAccountNaming +import net.trilleo.mc.plugins.tritown.enums.AccountType +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.enums.FlowCategory +import net.trilleo.mc.plugins.tritown.guis.shop.ShopRender +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.shops.ShopManager +import net.trilleo.mc.plugins.tritown.utils.ComponentUtil +import net.trilleo.mc.plugins.tritown.utils.EconomyUtil +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.event.inventory.ClickType +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack +import kotlin.math.abs +import kotlin.math.roundToLong + +/** + * What the server's economy is doing, on one screen. + * + * The top row is the economy as it stands — how much currency exists, who holds + * it and how unevenly. The middle row is what has moved over the window the + * viewer has chosen, which is where money is created and where it drains away. + * The bottom row is the same window drawn as a column per slice, so a payday, a + * duplication bug or a shop nobody can afford shows up as a shape rather than a + * number. + * + * Nothing here is cached: the figures are read from [EconomyPulse] when the menu + * is drawn, which costs a walk over the hours still kept and no disk at all. The + * ledger itself is only ever measured on the flush task, so a menu that opens + * shows the last measurement rather than taking a new one. + */ +class EconomyPanelGUI : PluginGUI( + id = ID, + titleKey = "gui.admin-economy.title", + rows = ROWS, + fillMode = FillMode.NONE, +) { + + override fun setup(player: Player, inventory: Inventory) { + GUIFrame.draw(inventory, GUIFrame.contentSlots(ROWS)) + render(player, inventory) + } + + override fun onClick(event: InventoryClickEvent) { + event.isCancelled = true + val player = event.whoClicked as? Player ?: return + + when (event.rawSlot) { + SHOPS -> if (player.hasPermission(AdminPanelGUI.SHOPS_PERMISSION)) { + GUIManager.openLater(player, AdminShopsGUI.ID) + } + + BREAKDOWN -> GUIManager.openLater(player, EconomyFlowGUI.ID) + + BACK -> GUIManager.openLater(player, AdminPanelGUI.ID) + + WINDOW -> { + PanelState.cycle(player, event.click != ClickType.RIGHT) + click(player) + render(player, event.inventory) + } + + REFRESH -> { + click(player) + render(player, event.inventory) + } + + HEALTH -> { + val plugin = Main.instance + plugin.server.scheduler.runTaskAsynchronously(plugin, Runnable { EconomyService.flush() }) + player.sendPrefixed(player.tr("command.eco.flushing")) + } + } + } + + // ── Rendering ─────────────────────────────────────────────────────── + + private fun render(player: Player, inventory: Inventory) { + inventory.setItem( + BACK, + button(Material.ARROW, player.tr("gui.admin-economy.back"), player.tr("gui.admin-economy.back-lore")), + ) + + if (!EconomyPulse.isEnabled) { + renderDisabled(player, inventory) + return + } + + val window = PanelState.window(player) + val flow = EconomyPulse.window(window.hours, CHART.size) + val supply = EconomyPulse.latest() + val previous = EconomyPulse.sampleAt(flow.from) + val days = ((flow.to - flow.from).toDouble() / MILLIS_PER_DAY).coerceAtLeast(MINIMUM_DAYS) + + inventory.setItem(SUPPLY, supplyCard(player, supply, previous, window.key)) + inventory.setItem(ACCOUNTS, accountsCard(player, supply)) + inventory.setItem(DISTRIBUTION, distributionCard(player, supply)) + inventory.setItem(LEADERS, leadersCard(player)) + inventory.setItem(CIRCULATION, circulationCard(player, flow, supply, days)) + inventory.setItem(SHOPS, shopsCard(player, flow)) + inventory.setItem(HEALTH, healthCard(player)) + + inventory.setItem(GENERATION, generationCard(player, flow, days)) + inventory.setItem(SINKS, sinksCard(player, flow, days)) + inventory.setItem(NET, netCard(player, flow, supply, days)) + inventory.setItem(TOWNY, townyCard(player, flow)) + inventory.setItem(ADMIN, adminCard(player, flow)) + inventory.setItem(NEWCOMERS, newcomersCard(player, flow)) + inventory.setItem(BREAKDOWN, breakdownCard(player)) + + renderChart(player, inventory, flow) + + inventory.setItem( + WINDOW, + PanelRender.card( + Material.CLOCK, + player.tr("gui.admin-economy.window", "window" to player.tr(window.key)), + listOf( + player.tr( + "gui.admin-economy.window-range", + "from" to PanelRender.time(flow.from), + "to" to PanelRender.time(flow.to), + ), + "", + player.tr("gui.admin-economy.window-lore"), + ), + ), + ) + inventory.setItem( + REFRESH, + button( + Material.SPYGLASS, + player.tr("gui.admin-economy.refresh"), + player.tr("gui.admin-economy.refresh-lore"), + ), + ) + } + + /** Everything but the way out, for a server that has switched the figures off. */ + private fun renderDisabled(player: Player, inventory: Inventory) { + val slots = GUIFrame.contentSlots(ROWS) + val pane = GUIFrame.pane() + for (slot in slots) inventory.setItem(slot, pane.clone()) + + inventory.setItem( + slots[slots.size / 2], + PanelRender.card( + Material.BARRIER, + player.tr("gui.admin-economy.disabled"), + listOf(player.tr("gui.admin-economy.disabled-lore")), + ), + ) + } + + private fun supplyCard( + player: Player, + supply: EconomyPulse.Supply?, + previous: EconomyPulse.Supply?, + windowKey: String, + ): ItemStack { + if (supply == null) return measuring(player, Material.GOLD_BLOCK, "gui.admin-economy.supply") + + val elsewhere = supply.total - supply.held - supply.banked + val lines = listOf( + player.tr("gui.admin-economy.supply-total", "amount" to PanelRender.money(supply.total)), + "", + player.tr( + "gui.admin-economy.supply-wallets", + "amount" to PanelRender.money(supply.held), + "percent" to PanelRender.percent(PanelRender.share(supply.held, supply.total)), + ), + player.tr( + "gui.admin-economy.supply-banks", + "amount" to PanelRender.money(supply.banked), + "percent" to PanelRender.percent(PanelRender.share(supply.banked, supply.total)), + ), + player.tr( + "gui.admin-economy.supply-elsewhere", + "amount" to PanelRender.money(elsewhere), + "percent" to PanelRender.percent(PanelRender.share(elsewhere, supply.total)), + ), + "", + player.tr( + "gui.admin-economy.supply-change", + "window" to player.tr(windowKey), + "amount" to PanelRender.delta(player, supply.total - (previous?.total ?: supply.total)), + ), + player.tr( + "gui.admin-economy.supply-average", + "amount" to PanelRender.money(if (supply.wallets == 0) 0L else supply.held / supply.wallets), + ), + player.tr("gui.admin-economy.measured", "time" to PanelRender.time(supply.at)), + ) + + return PanelRender.card(Material.GOLD_BLOCK, player.tr("gui.admin-economy.supply"), lines) + } + + private fun accountsCard(player: Player, supply: EconomyPulse.Supply?): ItemStack { + if (supply == null) return measuring(player, Material.PLAYER_HEAD, "gui.admin-economy.accounts") + + val other = (supply.accounts[AccountType.NPC] ?: 0) + (supply.accounts[AccountType.SERVER] ?: 0) + val lines = listOf( + player.tr("gui.admin-economy.accounts-total", "amount" to supply.accountCount), + "", + player.tr("gui.admin-economy.accounts-wallets", "amount" to supply.wallets), + player.tr("gui.admin-economy.accounts-towns", "amount" to (supply.accounts[AccountType.TOWN] ?: 0)), + player.tr("gui.admin-economy.accounts-nations", "amount" to (supply.accounts[AccountType.NATION] ?: 0)), + player.tr("gui.admin-economy.accounts-other", "amount" to other), + "", + player.tr( + "gui.admin-economy.accounts-active", + "amount" to supply.activeWallets, + "percent" to PanelRender.percent( + if (supply.wallets == 0) 0.0 else supply.activeWallets.toDouble() / supply.wallets + ), + ), + ) + + return PanelRender.card(Material.PLAYER_HEAD, player.tr("gui.admin-economy.accounts"), lines) + } + + private fun distributionCard(player: Player, supply: EconomyPulse.Supply?): ItemStack { + if (supply == null) return measuring(player, Material.COMPARATOR, "gui.admin-economy.distribution") + + val lines = listOf( + player.tr("gui.admin-economy.distribution-median", "amount" to PanelRender.money(supply.median)), + player.tr("gui.admin-economy.distribution-mean", "amount" to PanelRender.money(supply.mean)), + player.tr("gui.admin-economy.distribution-richest", "amount" to PanelRender.money(supply.richest)), + "", + player.tr("gui.admin-economy.distribution-top", "percent" to PanelRender.percent(supply.topShare)), + player.tr( + "gui.admin-economy.distribution-gini", + "value" to PanelRender.percent(supply.gini), + "label" to player.tr(giniLabel(supply.gini)), + ), + "", + player.tr("gui.admin-economy.distribution-lore"), + ) + + return PanelRender.card(Material.COMPARATOR, player.tr("gui.admin-economy.distribution"), lines) + } + + private fun leadersCard(player: Player): ItemStack { + val snapshot = BaltopCache.current + val lines = mutableListOf() + + if (snapshot.entries.isEmpty()) { + lines += player.tr("gui.admin-economy.leaders-empty") + } else { + snapshot.entries.take(LEADER_LINES).forEachIndexed { index, entry -> + lines += player.tr( + "gui.admin-economy.leaders-line", + "rank" to index + 1, + "name" to name(entry), + "amount" to PanelRender.money(entry.balance.minor), + ) + } + lines += "" + lines += player.tr("gui.admin-economy.leaders-updated", "time" to PanelRender.time(snapshot.refreshedAt)) + } + + return PanelRender.card(Material.DIAMOND, player.tr("gui.admin-economy.leaders"), lines) + } + + private fun circulationCard( + player: Player, + flow: EconomyPulse.Flow, + supply: EconomyPulse.Supply?, + days: Double, + ): ItemStack { + val average = if (flow.transfers == 0L) 0L else flow.circulated / flow.transfers + val velocity = if (supply == null || supply.total <= 0L) { + 0.0 + } else { + (flow.circulated / days) / supply.total + } + + val lines = listOf( + player.tr("gui.admin-economy.circulation-volume", "amount" to PanelRender.money(flow.circulated)), + player.tr("gui.admin-economy.circulation-count", "amount" to flow.transfers), + player.tr("gui.admin-economy.circulation-average", "amount" to PanelRender.money(average)), + "", + player.tr("gui.admin-economy.circulation-velocity", "percent" to PanelRender.rate(velocity)), + "", + player.tr("gui.admin-economy.circulation-lore"), + ) + + return PanelRender.card(Material.ENDER_PEARL, player.tr("gui.admin-economy.circulation"), lines) + } + + private fun shopsCard(player: Player, flow: EconomyPulse.Flow): ItemStack { + val lines = mutableListOf() + + if (!ShopSettings.isLoaded || !ShopSettings.snapshot.enabled || !ShopManager.isReady) { + lines += player.tr("gui.admin-economy.shops-off") + } else { + val shops = ShopManager.all() + lines += player.tr("gui.admin-economy.shops-count", "amount" to shops.size) + lines += "" + lines += player.tr( + "gui.admin-economy.shops-created", + "amount" to PanelRender.money(flow.createdBy[FlowCategory.SHOP] ?: 0L), + ) + lines += player.tr( + "gui.admin-economy.shops-destroyed", + "amount" to PanelRender.money(flow.destroyedBy[FlowCategory.SHOP] ?: 0L), + ) + lines += player.tr( + "gui.admin-economy.shops-net", + "amount" to PanelRender.delta(player, flow.netOf(FlowCategory.SHOP)), + ) + lines += "" + lines += player.tr( + "gui.admin-economy.shops-lifetime", + "taken" to ShopRender.money(shops.sumOf { shop -> shop.entries.sumOf { it.stats.moneyIn } }), + "paid" to ShopRender.money(shops.sumOf { shop -> shop.entries.sumOf { it.stats.moneyOut } }), + ) + lines += "" + lines += player.tr("gui.admin-economy.click-shops") + } + + return PanelRender.card(Material.EMERALD, player.tr("gui.admin-economy.shops"), lines) + } + + private fun healthCard(player: Player): ItemStack { + val settings = EconomySettings.snapshot + val currency = CurrencyRegistry.primary + val provider = runCatching { EconomyUtil.economy.name }.getOrNull() + + val lines = listOf( + player.tr( + "gui.admin-economy.health-provider", + "provider" to (provider ?: player.tr("gui.admin.server-no-provider")), + ), + player.tr( + "gui.admin-economy.health-currency", + "currency" to ComponentUtil.escape(currency.plural), + "symbol" to ComponentUtil.escape(currency.symbol), + ), + player.tr( + "gui.admin-economy.health-storage", + "storage" to settings.storageType, + "seconds" to settings.flushIntervalSeconds, + ), + player.tr( + "gui.admin-economy.health-history", + "state" to player.tr(if (settings.history.enabled) "common.on" else "common.off"), + "days" to settings.history.retentionDays, + ), + player.tr( + "gui.admin-economy.health-stats", + "time" to (EconomyPulse.earliest()?.let(PanelRender::time) ?: player.tr("common.unknown")), + ), + "", + player.tr("gui.admin-economy.click-flush"), + ) + + return PanelRender.card(Material.REDSTONE_TORCH, player.tr("gui.admin-economy.health"), lines) + } + + private fun generationCard(player: Player, flow: EconomyPulse.Flow, days: Double): ItemStack { + val lines = mutableListOf( + player.tr("gui.admin-economy.generation-total", "amount" to PanelRender.money(flow.created)), + player.tr("gui.admin-economy.flow-rate", "amount" to PanelRender.money(perDay(flow.created, days))), + "", + ) + lines += breakdown(player, flow.createdBy, flow.created) + return PanelRender.card(Material.WATER_BUCKET, player.tr("gui.admin-economy.generation"), lines) + } + + private fun sinksCard(player: Player, flow: EconomyPulse.Flow, days: Double): ItemStack { + val lines = mutableListOf( + player.tr("gui.admin-economy.sinks-total", "amount" to PanelRender.money(flow.destroyed)), + player.tr("gui.admin-economy.flow-rate", "amount" to PanelRender.money(perDay(flow.destroyed, days))), + "", + ) + lines += breakdown(player, flow.destroyedBy, flow.destroyed) + return PanelRender.card(Material.HOPPER, player.tr("gui.admin-economy.sinks"), lines) + } + + private fun netCard( + player: Player, + flow: EconomyPulse.Flow, + supply: EconomyPulse.Supply?, + days: Double, + ): ItemStack { + val perDay = perDay(flow.net, days) + val total = supply?.total ?: 0L + val drift = if (total <= 0L) 0.0 else perDay.toDouble() / total + + val lines = mutableListOf( + player.tr("gui.admin-economy.net-total", "amount" to PanelRender.delta(player, flow.net)), + player.tr("gui.admin-economy.net-rate", "amount" to PanelRender.delta(player, perDay)), + player.tr("gui.admin-economy.net-drift", "percent" to PanelRender.rate(drift)), + "", + ) + + lines += when { + total <= 0L || perDay == 0L -> player.tr("gui.admin-economy.net-stable") + perDay > 0L -> player.tr("gui.admin-economy.net-doubling", "days" to total / perDay) + else -> player.tr("gui.admin-economy.net-emptying", "days" to total / -perDay) + } + + lines += "" + lines += player.tr("gui.admin-economy.net-lore") + + val material = when { + flow.net > 0L -> Material.LIME_DYE + flow.net < 0L -> Material.RED_DYE + else -> Material.GRAY_DYE + } + return PanelRender.card(material, player.tr("gui.admin-economy.net"), lines) + } + + private fun townyCard(player: Player, flow: EconomyPulse.Flow): ItemStack { + val created = flow.createdBy[FlowCategory.TOWNY] ?: 0L + val destroyed = flow.destroyedBy[FlowCategory.TOWNY] ?: 0L + val intoBanks = (flow.createdTo[AccountType.TOWN] ?: 0L) + (flow.createdTo[AccountType.NATION] ?: 0L) + val outOfBanks = + (flow.destroyedFrom[AccountType.TOWN] ?: 0L) + (flow.destroyedFrom[AccountType.NATION] ?: 0L) + + val lines = listOf( + player.tr("gui.admin-economy.towny-created", "amount" to PanelRender.money(created)), + player.tr("gui.admin-economy.towny-destroyed", "amount" to PanelRender.money(destroyed)), + player.tr("gui.admin-economy.towny-net", "amount" to PanelRender.delta(player, created - destroyed)), + "", + player.tr("gui.admin-economy.towny-in", "amount" to PanelRender.money(intoBanks)), + player.tr("gui.admin-economy.towny-out", "amount" to PanelRender.money(outOfBanks)), + "", + player.tr("gui.admin-economy.towny-lore"), + ) + + return PanelRender.card(Material.BELL, player.tr("gui.admin-economy.towny"), lines) + } + + private fun adminCard(player: Player, flow: EconomyPulse.Flow): ItemStack { + val given = flow.createdBy[FlowCategory.ADMIN] ?: 0L + val taken = flow.destroyedBy[FlowCategory.ADMIN] ?: 0L + + val lines = listOf( + player.tr("gui.admin-economy.admin-given", "amount" to PanelRender.money(given)), + player.tr("gui.admin-economy.admin-taken", "amount" to PanelRender.money(taken)), + player.tr("gui.admin-economy.admin-net", "amount" to PanelRender.delta(player, given - taken)), + "", + player.tr("gui.admin-economy.admin-set", "amount" to PanelRender.money(flow.adjusted)), + "", + player.tr("gui.admin-economy.admin-lore"), + ) + + return PanelRender.card(Material.COMMAND_BLOCK, player.tr("gui.admin-economy.admin"), lines) + } + + private fun newcomersCard(player: Player, flow: EconomyPulse.Flow): ItemStack { + val paid = flow.createdBy[FlowCategory.STARTING_BALANCE] ?: 0L + val each = CurrencyRegistry.primary.of(EconomySettings.snapshot.startingBalance).minor + + val lines = listOf( + player.tr("gui.admin-economy.newcomers-total", "amount" to PanelRender.money(paid)), + player.tr("gui.admin-economy.newcomers-count", "amount" to if (each <= 0L) 0L else paid / each), + "", + player.tr("gui.admin-economy.newcomers-each", "amount" to PanelRender.money(each)), + ) + + return PanelRender.card(Material.EGG, player.tr("gui.admin-economy.newcomers"), lines) + } + + private fun breakdownCard(player: Player): ItemStack = PanelRender.card( + Material.BOOK, + player.tr("gui.admin-economy.breakdown"), + listOf(player.tr("gui.admin-economy.breakdown-lore"), "", player.tr("gui.admin-economy.click-open")), + ) + + /** + * One column per slice of the window, as tall as the slice's net change is + * large next to the biggest one. + * + * The stack size is the bar: a column that moved nothing shows one pane and a + * column that moved the most shows sixty-four, which reads as a chart at a + * glance without a single pixel of custom texture. + */ + private fun renderChart(player: Player, inventory: Inventory, flow: EconomyPulse.Flow) { + val tallest = flow.slices.maxOfOrNull { abs(it.net) } ?: 0L + + flow.slices.forEachIndexed { index, slice -> + val slot = CHART.getOrNull(index) ?: return@forEachIndexed + val height = if (tallest <= 0L) 1 else ((abs(slice.net) * BAR_MAX) / tallest).toInt().coerceIn(1, BAR_MAX) + + val material = when { + slice.created == 0L && slice.destroyed == 0L -> Material.GRAY_STAINED_GLASS_PANE + slice.net > 0L -> Material.LIME_STAINED_GLASS_PANE + slice.net < 0L -> Material.RED_STAINED_GLASS_PANE + else -> Material.YELLOW_STAINED_GLASS_PANE + } + + val lines = listOf( + player.tr("gui.admin-economy.chart-created", "amount" to PanelRender.money(slice.created)), + player.tr("gui.admin-economy.chart-destroyed", "amount" to PanelRender.money(slice.destroyed)), + player.tr("gui.admin-economy.chart-net", "amount" to PanelRender.delta(player, slice.net)), + "", + player.tr("gui.admin-economy.chart-lore"), + ) + + val bar = PanelRender.card( + material, + player.tr( + "gui.admin-economy.chart", + "from" to PanelRender.time(slice.from), + "to" to PanelRender.time(slice.to), + ), + lines, + ) + bar.amount = height + inventory.setItem(slot, bar) + } + } + + // ── Helpers ───────────────────────────────────────────────────────── + + /** The three categories that moved the most, with their share of the total. */ + private fun breakdown(player: Player, amounts: Map, total: Long): List { + if (total <= 0L) return listOf(player.tr("gui.admin-economy.flow-empty")) + + return amounts.entries + .sortedByDescending { it.value } + .take(BREAKDOWN_LINES) + .map { (category, amount) -> + player.tr( + "gui.admin-economy.flow-line", + "name" to PanelRender.categoryName(player, category), + "amount" to PanelRender.money(amount), + "percent" to PanelRender.percent(PanelRender.share(amount, total)), + ) + } + } + + private fun measuring(player: Player, material: Material, nameKey: String): ItemStack = + PanelRender.card(material, player.tr(nameKey), listOf(player.tr("gui.admin-economy.measuring"))) + + private fun button(material: Material, name: String, lore: String): ItemStack = + PanelRender.card(material, name, listOf(lore)) + + private fun perDay(amount: Long, days: Double): Long = (amount / days).roundToLong() + + private fun name(entry: BaltopCache.Entry): String { + val stripped = if (entry.type.isGovernment) TownyAccountNaming.stripPrefix(entry.name) else entry.name + return ComponentUtil.escape(stripped) + } + + private fun giniLabel(gini: Double): String = when { + gini < 0.3 -> "gui.admin-economy.gini-even" + gini < 0.5 -> "gui.admin-economy.gini-fair" + gini < 0.7 -> "gui.admin-economy.gini-uneven" + else -> "gui.admin-economy.gini-extreme" + } + + private fun click(player: Player) { + player.playSound(Sound.sound(Key.key("minecraft:ui.button.click"), Sound.Source.UI, 1f, 1f)) + } + + companion object { + const val ID = "admin-economy" + + private const val ROWS = 5 + + private const val SUPPLY = 10 + private const val ACCOUNTS = 11 + private const val DISTRIBUTION = 12 + private const val LEADERS = 13 + private const val CIRCULATION = 14 + private const val SHOPS = 15 + private const val HEALTH = 16 + + private const val GENERATION = 19 + private const val SINKS = 20 + private const val NET = 21 + private const val TOWNY = 22 + private const val ADMIN = 23 + private const val NEWCOMERS = 24 + private const val BREAKDOWN = 25 + + private val CHART = (28..34).toList() + + private const val BACK = 38 + private const val WINDOW = 40 + private const val REFRESH = 42 + + /** How many lines a card spends naming the categories behind a total. */ + private const val BREAKDOWN_LINES = 3 + + /** How many accounts the leaderboard card lists. */ + private const val LEADER_LINES = 5 + + /** The tallest a chart column can be, which is a full stack. */ + private const val BAR_MAX = 64 + + private const val MILLIS_PER_DAY = 24.0 * 60.0 * 60.0 * 1000.0 + + /** Keeps a daily rate finite when the window is shorter than a day's worth of data. */ + private const val MINIMUM_DAYS = 1.0 / 24.0 + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/PanelRender.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/PanelRender.kt new file mode 100644 index 0000000..6c2de86 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/PanelRender.kt @@ -0,0 +1,94 @@ +package net.trilleo.mc.plugins.tritown.guis.admin + +import net.trilleo.mc.plugins.tritown.config.EconomySettings +import net.trilleo.mc.plugins.tritown.economy.CurrencyRegistry +import net.trilleo.mc.plugins.tritown.economy.EconomyFormat +import net.trilleo.mc.plugins.tritown.economy.Money +import net.trilleo.mc.plugins.tritown.enums.AccountType +import net.trilleo.mc.plugins.tritown.enums.FlowCategory +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.inventory.ItemStack +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** + * The pieces the admin panel's menus draw with. + * + * Every figure the panel shows is a number that has to read the same wherever + * it appears, so the money, the percentages and the cards themselves are built + * in one place rather than formatted again in each menu. + * + * Amounts arrive as minor units, which is how the economy holds and totals + * them; they are converted to text at this boundary and nowhere earlier. + */ +object PanelRender { + + private val percentFormat = DecimalFormat("0.0", DecimalFormatSymbols(Locale.ROOT)) + private val rateFormat = DecimalFormat("0.00", DecimalFormatSymbols(Locale.ROOT)) + + /** [minor] units of the primary currency, ready to embed in MiniMessage. */ + fun money(minor: Long): String = + if (!CurrencyRegistry.isLoaded) minor.toString() + else ComponentUtil.escape(EconomyFormat.plain(CurrencyRegistry.primary, Money(minor))) + + /** + * [minor] as a change: coloured and signed by the translation, so a figure + * that went up never reads the same as one that went down. + */ + fun delta(viewer: Player, minor: Long): String = when { + minor > 0L -> viewer.tr("gui.admin-economy.delta-up", "amount" to money(minor)) + minor < 0L -> viewer.tr("gui.admin-economy.delta-down", "amount" to money(-minor)) + else -> viewer.tr("gui.admin-economy.delta-flat") + } + + /** [fraction] as a percentage, e.g. `12.4%`. Anything that is not a number reads as zero. */ + fun percent(fraction: Double): String = + if (!fraction.isFinite()) "0.0%" else "${percentFormat.format(fraction * 100.0)}%" + + /** [fraction] as a percentage at two digits, for rates small enough that one would round them away. */ + fun rate(fraction: Double): String = + if (!fraction.isFinite()) "0.00%" else "${rateFormat.format(fraction * 100.0)}%" + + /** [part] as a share of [whole], or zero when there is no whole to share. */ + fun share(part: Long, whole: Long): Double = if (whole <= 0L) 0.0 else part.toDouble() / whole.toDouble() + + /** A menu card: a name and a block of wrapped lore. Blank lines are kept, so a card can be grouped. */ + fun card(material: Material, name: String, lines: List): ItemStack = itemStack(material) { + name(name) + meta { lore(LoreUtil.wrapLore(lines.joinToString(""))) } + } + + /** [millis] in the format `economy.history.time-format` sets, which is already the panel's clock elsewhere. */ + fun time(millis: Long): String = formatter().format(Instant.ofEpochMilli(millis)) + + /** A category's name in [viewer]'s language. */ + fun categoryName(viewer: Player, category: FlowCategory): String = viewer.tr(category.key) + + /** An account type's name in [viewer]'s language, as the plural a total is read in. */ + fun holderName(viewer: Player, type: AccountType): String = when (type) { + AccountType.PLAYER -> viewer.tr("gui.admin-economy.holder-players") + AccountType.TOWN -> viewer.tr("gui.admin-economy.holder-towns") + AccountType.NATION -> viewer.tr("gui.admin-economy.holder-nations") + AccountType.NPC -> viewer.tr("gui.admin-economy.holder-npcs") + AccountType.SERVER -> viewer.tr("gui.admin-economy.holder-server") + AccountType.UNKNOWN -> viewer.tr("gui.admin-economy.holder-unknown") + } + + private fun formatter(): DateTimeFormatter { + val pattern = if (EconomySettings.isLoaded) EconomySettings.snapshot.history.timeFormat else FALLBACK_TIME + return runCatching { DateTimeFormatter.ofPattern(pattern, Locale.ROOT) } + .getOrElse { DateTimeFormatter.ofPattern(FALLBACK_TIME, Locale.ROOT) } + .withZone(ZoneId.systemDefault()) + } + + private const val FALLBACK_TIME = "yyyy-MM-dd HH:mm" +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/PanelState.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/PanelState.kt new file mode 100644 index 0000000..014c646 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/PanelState.kt @@ -0,0 +1,37 @@ +package net.trilleo.mc.plugins.tritown.guis.admin + +import net.trilleo.mc.plugins.tritown.enums.StatsWindow +import org.bukkit.entity.Player +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * How far back each administrator is looking. + * + * Held here rather than in one menu because the overview and the breakdown show + * the same figures at different depths: switching to the last week in one and + * then opening the other should not quietly go back to the last day. + * + * The choice is deliberately not persisted — it is a way of looking at the + * panel, not a setting — and is dropped when the last of the panel's menus is + * closed. + */ +object PanelState { + + private val windows = ConcurrentHashMap() + + /** The window [player] is looking at. */ + fun window(player: Player): StatsWindow = windows[player.uniqueId] ?: StatsWindow.DAY + + /** Moves [player] to the next window, or the previous one when [forward] is false. */ + fun cycle(player: Player, forward: Boolean): StatsWindow { + val next = if (forward) window(player).next() else window(player).previous() + windows[player.uniqueId] = next + return next + } + + /** Forgets what [player] was looking at. */ + fun forget(player: Player) { + windows.remove(player.uniqueId) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/admin/PanelStateListener.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/admin/PanelStateListener.kt new file mode 100644 index 0000000..a250561 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/admin/PanelStateListener.kt @@ -0,0 +1,21 @@ +package net.trilleo.mc.plugins.tritown.listeners.admin + +import net.trilleo.mc.plugins.tritown.guis.admin.PanelState +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerQuitEvent + +/** + * Drops what an administrator was looking at when they leave. + * + * The panel's menus hand each other the chosen window, so it cannot be cleared + * when one of them closes — opening the next one closes the last. Quitting is + * the one moment where nothing is going to be opened next. + */ +class PanelStateListener : Listener { + + @EventHandler + fun onQuit(event: PlayerQuitEvent) { + PanelState.forget(event.player) + } +} 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 271139d..2e6c735 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 @@ -33,11 +33,14 @@ object GUIManager : Listener { private val guis = mutableMapOf() private val openGUIs = mutableMapOf>() + private lateinit var plugin: JavaPlugin + /** * Scans the GUIs package, instantiates every [PluginGUI] found, * stores them by id, and registers this manager as an event listener. */ fun registerAll(plugin: JavaPlugin) { + this.plugin = plugin val guiClasses = PackageScanner.findClasses( plugin, GUIS_PACKAGE, PluginGUI::class.java ) @@ -75,6 +78,21 @@ object GUIManager : Listener { return true } + /** + * Opens a registered GUI on the following tick. + * + * A click is still being delivered while its handler runs, and opening an + * inventory from inside that delivery leaves the server and the client + * disagreeing about what is on screen. Any menu reached by clicking inside + * another one is opened this way. + * + * @param player the player to open the GUI for + * @param id the unique identifier of the GUI to open + */ + fun openLater(player: Player, id: String) { + Bukkit.getScheduler().runTask(plugin, Runnable { open(player, id) }) + } + /** * Returns the [PluginGUI] registered under the given [id], * or `null` if no GUI with that id exists. diff --git a/src/main/resources/lang/en_US.yml b/src/main/resources/lang/en_US.yml index 607fec3..1ea8720 100644 --- a/src/main/resources/lang/en_US.yml +++ b/src/main/resources/lang/en_US.yml @@ -17,11 +17,19 @@ command: info: "Info" moderation: "Moderation" shop: "Shops" + admin: "Admin" help: header: "TriTown Commands" description: "Show all available commands" + admin: + description: "Open the admin panel" + players-only: "The admin panel is a menu, so only players can open it." + unknown-section: "There is no {section} section of the admin panel." + no-permission-section: "You don't have permission to open the {section} section!" + unavailable: "The admin panel is not available right now." + reload: description: "Reload the plugin configuration and translations" done: "Configuration reloaded!" @@ -216,6 +224,205 @@ gui: next-page: "Next Page" page: "Page {page}/{pages}" + # The admin panel. Amounts, counts and percentages are filled in by TriTown. + admin: + title: "Admin panel" + economy: "Economy" + economy-lore: "Supply, faucets, sinks and who holds what." + economy-supply: "In existence: {amount}" + economy-net: "Net over {window}: {amount}" + economy-accounts: "Accounts: {amount}" + economy-stats-off: "Statistics are switched off in config.yml." + shops: "Shops" + shops-lore: "What every shop has traded." + shops-count: "Shops: {amount}" + shops-taken: "Taken in: {amount}" + shops-paid: "Paid out: {amount}" + shops-off: "Shops are switched off." + server: "Server" + server-version: "TriTown {version}" + server-provider: "Economy: {provider}" + server-no-provider: "none" + server-towns: "Towns: {amount}" + server-nations: "Nations: {amount}" + server-online: "Online: {amount}" + server-newday: "New day in {time}" + click-open: "Click to open" + + admin-economy: + title: "Economy" + disabled: "Statistics are switched off" + disabled-lore: "Set economy.stats.enabled to true in config.yml and restart to collect them." + measuring: "The ledger has not been measured yet. Open this again in a moment." + measured: "Measured {time}" + + window: "Window: {window}" + window-lore: "Click for the next, right-click for the previous" + window-range: "{from} to {to}" + window-day: "the last day" + window-week: "the last week" + window-month: "the last month" + window-all: "all time" + + delta-up: "+{amount}" + delta-down: "-{amount}" + delta-flat: "no change" + + holder-players: "Players" + holder-towns: "Town banks" + holder-nations: "Nation banks" + holder-npcs: "NPCs" + holder-server: "Server account" + holder-unknown: "Unidentified" + + supply: "Money supply" + supply-total: "In existence: {amount}" + supply-wallets: "Player wallets: {amount} ({percent})" + supply-banks: "Town and nation banks: {amount} ({percent})" + supply-elsewhere: "Server and NPCs: {amount} ({percent})" + supply-change: "Change over {window}: {amount}" + supply-average: "Per wallet: {amount}" + + accounts: "Accounts" + accounts-total: "Total: {amount}" + accounts-wallets: "Player wallets: {amount}" + accounts-towns: "Town banks: {amount}" + accounts-nations: "Nation banks: {amount}" + accounts-other: "Server and NPCs: {amount}" + accounts-active: "Active this week: {amount} ({percent})" + + distribution: "Wealth distribution" + distribution-median: "Median wallet: {amount}" + distribution-mean: "Mean wallet: {amount}" + distribution-richest: "Richest wallet: {amount}" + distribution-top: "The richest tenth hold {percent}" + distribution-gini: "Inequality: {value} ({label})" + distribution-lore: "Measured across player wallets only." + gini-even: "even" + gini-fair: "fair" + gini-uneven: "uneven" + gini-extreme: "extreme" + + leaders: "Richest accounts" + leaders-line: "{rank}. {name} - {amount}" + leaders-empty: "Nobody holds anything yet." + leaders-updated: "Updated {time}" + + circulation: "Circulation" + circulation-volume: "Moved between accounts: {amount}" + circulation-count: "Payments: {amount}" + circulation-average: "Average payment: {amount}" + circulation-velocity: "Velocity: {percent} of the supply per day" + circulation-lore: "A payment moves money; it never creates or destroys any." + + shops: "Shops" + shops-count: "Shops: {amount}" + shops-created: "Paid out to players: {amount}" + shops-destroyed: "Taken from players: {amount}" + shops-net: "Effect on the supply: {amount}" + shops-lifetime: "All time: {taken} in, {paid} out" + shops-off: "Shops are switched off." + click-shops: "Click to see every shop" + + health: "Ledger" + health-provider: "Provider: {provider}" + health-currency: "Currency: {currency} ({symbol})" + health-storage: "Storage: {storage}, written every {seconds}s" + health-history: "History: {state}, kept {days} day(s)" + health-stats: "Figures kept since {time}" + click-flush: "Click to write everything to disk now" + + generation: "Money created" + generation-total: "Created: {amount}" + sinks: "Money removed" + sinks-total: "Removed: {amount}" + flow-rate: "Per day: {amount}" + flow-line: "- {name}: {amount} ({percent})" + flow-empty: "Nothing in this window." + + net: "Net change" + net-total: "Over the window: {amount}" + net-rate: "Per day: {amount}" + net-drift: "Supply drift: {percent} per day" + net-doubling: "At this rate the supply doubles in {days} day(s)." + net-emptying: "At this rate the supply empties in {days} day(s)." + net-stable: "The supply is holding steady." + net-lore: "Towny moves a bank deposit as a withdrawal and a deposit, so it lands on both sides above and cancels here." + + towny: "Towny" + towny-created: "Created: {amount}" + towny-destroyed: "Removed: {amount}" + towny-net: "Net: {amount}" + towny-in: "Into town and nation banks: {amount}" + towny-out: "Out of them: {amount}" + towny-lore: "Upkeep, taxes, plot sales and bank deposits." + + admin: "Administration" + admin-given: "Given out: {amount}" + admin-taken: "Taken back: {amount}" + admin-net: "Net: {amount}" + admin-set: "Moved by setting balances: {amount}" + admin-lore: "A balance that was set outright is counted apart, because the record does not say which way it went." + + newcomers: "New players" + newcomers-total: "Starting balances paid: {amount}" + newcomers-count: "That is about {amount} new player(s)." + newcomers-each: "Each new player receives {amount}." + + breakdown: "Full breakdown" + breakdown-lore: "Every source and every kind of account, in full." + click-open: "Click to open" + + chart: "{from} to {to}" + chart-created: "Created: {amount}" + chart-destroyed: "Removed: {amount}" + chart-net: "Net: {amount}" + chart-lore: "The stack size is the bar. Green grew the supply, red shrank it." + + back: "Back" + back-lore: "Return to the admin panel." + refresh: "Refresh" + refresh-lore: "Read every figure again." + + admin-flow: + title: "Money flow" + header: "Flow over {window}" + movements: "Movements: {amount}" + category: "{name}" + created: "Created: {amount} ({percent})" + destroyed: "Removed: {amount} ({percent})" + net: "Net: {amount}" + holders: "Who the money moved through" + holders-lore: "The same window, by the kind of account that gained or lost it." + holder: "{name}" + received: "Received: {amount}" + paid: "Paid: {amount}" + about-starting-balance: "Paid once to each new player." + about-shop: "The server's own shops selling and buying back." + about-towny: "Upkeep, taxes, plot sales and bank movements." + about-admin: "Balances given, taken, set or reset by an administrator." + about-payment: "Players paying each other." + about-external: "Another plugin, through Vault, with nothing to identify it." + about-other: "Recorded with a reason TriTown has no category for." + back: "Back" + back-lore: "Return to the economy panel." + + admin-shops: + title: "Shop figures" + header: "Every shop" + header-lore: "Counted since the figures were last reset." + count: "Shops: {amount}" + entries: "Entries: {amount}" + id: "Id: {id}" + empty: "No shops yet" + empty-lore: "Create one with /tritown shop create ." + disabled: "Shops are switched off" + disabled-lore: "Set shops.enabled to true in config.yml." + click-stats: "Click for this shop's own figures" + click-edit: "Shift-click to edit the shop" + back: "Back" + back-lore: "Return to the admin panel." + history: title: "Transaction History" header: "{name}" diff --git a/src/main/resources/lang/zh_CN.yml b/src/main/resources/lang/zh_CN.yml index 68a930c..e9b7331 100644 --- a/src/main/resources/lang/zh_CN.yml +++ b/src/main/resources/lang/zh_CN.yml @@ -17,11 +17,19 @@ command: info: "信息" moderation: "管理" shop: "商店" + admin: "管理" help: header: "TriTown 命令" description: "显示所有可用命令" + admin: + description: "打开管理面板" + players-only: "管理面板是菜单,只有玩家能打开。" + unknown-section: "管理面板没有 {section} 这个分区。" + no-permission-section: "你没有权限打开 {section} 分区!" + unavailable: "管理面板暂时不可用。" + reload: description: "重新载入插件配置与语言文件" done: "配置已重新载入!" @@ -215,6 +223,205 @@ gui: next-page: "下一页" page: "第 {page}/{pages} 页" + # 管理面板。金额、数量和百分比由 TriTown 填入。 + admin: + title: "管理面板" + economy: "经济" + economy-lore: "货币总量、产出、回收与持有情况。" + economy-supply: "流通总量:{amount}" + economy-net: "{window}净变化:{amount}" + economy-accounts: "账户数:{amount}" + economy-stats-off: "统计已在 config.yml 中关闭。" + shops: "商店" + shops-lore: "所有商店的交易情况。" + shops-count: "商店数:{amount}" + shops-taken: "收取:{amount}" + shops-paid: "支付:{amount}" + shops-off: "商店已关闭。" + server: "服务器" + server-version: "TriTown {version}" + server-provider: "经济提供方:{provider}" + server-no-provider: "无" + server-towns: "城镇:{amount}" + server-nations: "国家:{amount}" + server-online: "在线:{amount}" + server-newday: "距新的一天:{time}" + click-open: "点击 打开" + + admin-economy: + title: "经济" + disabled: "统计已关闭" + disabled-lore: "将 config.yml 中的 economy.stats.enabled 设为 true 并重启后开始记录。" + measuring: "账本还未被统计,稍后再打开。" + measured: "统计于 {time}" + + window: "时间范围:{window}" + window-lore: "点击 切换到下一个,右键 回到上一个" + window-range: "{from} 至 {to}" + window-day: "最近一天" + window-week: "最近一周" + window-month: "最近一个月" + window-all: "全部时间" + + delta-up: "+{amount}" + delta-down: "-{amount}" + delta-flat: "无变化" + + holder-players: "玩家" + holder-towns: "城镇银行" + holder-nations: "国家银行" + holder-npcs: "NPC" + holder-server: "服务器账户" + holder-unknown: "未识别" + + supply: "货币总量" + supply-total: "流通总量:{amount}" + supply-wallets: "玩家钱包:{amount} ({percent})" + supply-banks: "城镇与国家银行:{amount} ({percent})" + supply-elsewhere: "服务器与 NPC:{amount} ({percent})" + supply-change: "{window}变化:{amount}" + supply-average: "人均持有:{amount}" + + accounts: "账户" + accounts-total: "总计:{amount}" + accounts-wallets: "玩家钱包:{amount}" + accounts-towns: "城镇银行:{amount}" + accounts-nations: "国家银行:{amount}" + accounts-other: "服务器与 NPC:{amount}" + accounts-active: "本周活跃:{amount} ({percent})" + + distribution: "财富分布" + distribution-median: "钱包中位数:{amount}" + distribution-mean: "钱包平均值:{amount}" + distribution-richest: "最富钱包:{amount}" + distribution-top: "最富的十分之一持有 {percent}" + distribution-gini: "贫富差距:{value} ({label})" + distribution-lore: "仅统计玩家钱包。" + gini-even: "均衡" + gini-fair: "尚可" + gini-uneven: "失衡" + gini-extreme: "极端" + + leaders: "最富有的账户" + leaders-line: "{rank}. {name} - {amount}" + leaders-empty: "还没有人拥有财富。" + leaders-updated: "更新于 {time}" + + circulation: "资金流转" + circulation-volume: "账户间转移:{amount}" + circulation-count: "转账次数:{amount}" + circulation-average: "平均每笔:{amount}" + circulation-velocity: "流通速度:每天相当于总量的 {percent}" + circulation-lore: "转账只是移动资金,既不产生也不销毁。" + + shops: "商店" + shops-count: "商店数:{amount}" + shops-created: "向玩家支付:{amount}" + shops-destroyed: "从玩家收取:{amount}" + shops-net: "对总量的影响:{amount}" + shops-lifetime: "全部时间:收 {taken},支 {paid}" + shops-off: "商店已关闭。" + click-shops: "点击 查看所有商店" + + health: "账本" + health-provider: "提供方:{provider}" + health-currency: "货币:{currency} ({symbol})" + health-storage: "存储:{storage},每 {seconds} 秒写入一次" + health-history: "历史记录:{state},保留 {days} 天" + health-stats: "统计起自 {time}" + click-flush: "点击 立即写入磁盘" + + generation: "新增资金" + generation-total: "新增:{amount}" + sinks: "回收资金" + sinks-total: "回收:{amount}" + flow-rate: "每天:{amount}" + flow-line: "- {name}:{amount} ({percent})" + flow-empty: "这段时间内没有变动。" + + net: "净变化" + net-total: "这段时间内:{amount}" + net-rate: "每天:{amount}" + net-drift: "总量漂移:每天 {percent}" + net-doubling: "照此速度,货币总量将在 {days} 天后翻倍。" + net-emptying: "照此速度,货币总量将在 {days} 天后耗尽。" + net-stable: "货币总量保持稳定。" + net-lore: "Towny 把银行存款拆成一次支出和一次入账,因此两边都会计入,在净值中相互抵消。" + + towny: "Towny" + towny-created: "新增:{amount}" + towny-destroyed: "回收:{amount}" + towny-net: "净值:{amount}" + towny-in: "进入城镇与国家银行:{amount}" + towny-out: "从银行流出:{amount}" + towny-lore: "维护费、税收、地块交易与银行存取。" + + admin: "管理操作" + admin-given: "发放:{amount}" + admin-taken: "收回:{amount}" + admin-net: "净值:{amount}" + admin-set: "直接设定余额带来的变动:{amount}" + admin-lore: "直接设定余额单独统计,因为记录不会说明余额是增是减。" + + newcomers: "新玩家" + newcomers-total: "发放的初始余额:{amount}" + newcomers-count: "约合 {amount} 名新玩家。" + newcomers-each: "每名新玩家获得 {amount}。" + + breakdown: "完整明细" + breakdown-lore: "每一类来源与每一类账户的完整数据。" + click-open: "点击 打开" + + chart: "{from} 至 {to}" + chart-created: "新增:{amount}" + chart-destroyed: "回收:{amount}" + chart-net: "净值:{amount}" + chart-lore: "堆叠数量就是柱高。绿色表示总量上升,红色表示下降。" + + back: "返回" + back-lore: "回到管理面板。" + refresh: "刷新" + refresh-lore: "重新读取所有数据。" + + admin-flow: + title: "资金流向" + header: "{window}的资金流向" + movements: "变动笔数:{amount}" + category: "{name}" + created: "新增:{amount} ({percent})" + destroyed: "回收:{amount} ({percent})" + net: "净值:{amount}" + holders: "资金经过谁的账户" + holders-lore: "同一段时间,按账户类型划分。" + holder: "{name}" + received: "入账:{amount}" + paid: "支出:{amount}" + about-starting-balance: "每名新玩家只发放一次。" + about-shop: "服务器自己的商店卖出与回收。" + about-towny: "维护费、税收、地块交易与银行存取。" + about-admin: "管理员发放、收回、设定或重置余额。" + about-payment: "玩家之间的转账。" + about-external: "其他插件通过 Vault 发起,无法进一步识别。" + about-other: "记录的原因不属于 TriTown 已知的任何一类。" + back: "返回" + back-lore: "回到经济面板。" + + admin-shops: + title: "商店数据" + header: "全部商店" + header-lore: "从上次清零开始统计。" + count: "商店数:{amount}" + entries: "商品数:{amount}" + id: "ID:{id}" + empty: "还没有商店" + empty-lore: "使用 /tritown shop create 创建一个。" + disabled: "商店已关闭" + disabled-lore: "将 config.yml 中的 shops.enabled 设为 true。" + click-stats: "点击 查看这家商店的数据" + click-edit: "Shift+点击 编辑这家商店" + back: "返回" + back-lore: "回到管理面板。" + history: title: "交易记录" header: "{name}" From 2d9f87e162a1b1b1d22de2a910e3e2663daeb13e Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sat, 19 Sep 2026 19:29:07 +0800 Subject: [PATCH 10/14] Internal: Require new economy features to be wired into the statistics A movement that claims no source or reason is counted as another plugin's, so a faucet or sink added later without attribution quietly makes the admin panel wrong about where the server's currency comes from. Spell out the four steps every feature that moves money has to take, with a worked example in the developer guide. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 23 ++++++++++++++++++---- docs/DEVELOPER_GUIDE.md | 42 +++++++++++++++++++++++++++++++++++++++++ docs/UTILITY_GUIDE.md | 10 +++++++--- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d03dd0a..f0a2d2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,6 +175,25 @@ complete stack and no separate economy plugin is needed. See - **Charge before acting** — call `EconomyUtil.withdraw` and only perform the action when it returns `true`; refund with `deposit` if the action then fails. Never check `has` and withdraw separately. Use `EconomyUtil.transfer` for a payment between two accounts, which is atomic on TriTown's own economy. +- **Wire every new way money moves into the statistics.** The admin panel's figures are only as true as the + attribution behind them, and a movement nothing claims is filed as "Other plugins" — so a new faucet or sink that + skips this quietly makes the economy unreadable. For **every** feature that moves money: + 1. **Move it through `EconomyUtil`** (or `EconomyService` inside the economy itself), never by writing a balance: + that path is the only one `EconomyService.record` — and therefore `EconomyPulse` — ever sees. + 2. **Attribute it.** Use the four-argument `EconomyUtil.withdraw`/`deposit` with an `EconomyContext.SOURCE_*` and a + `TransactionReason` key, or wrap the work in `EconomyContext.with`. Add the `money.reason.*` key to both + language files. + 3. **Check `FlowCategory.of` covers that reason.** If the feature is a faucet or a sink in its own right — a job + payout, a daily reward, a repair fee, a lottery — give it a `FlowCategory`, spell out its `money.flow.*` key in + both language files, and map the reason to it. A real faucet must never land in `OTHER`. + 4. **Decide whether the panel should name it.** The full breakdown picks a new category up on its own; a card of + its own in `EconomyPanelGUI` is for a source worth watching separately. + See [Economy Statistics](docs/DEVELOPER_GUIDE.md#economy-statistics). +- **Never total raw reason strings** — a reason carries arguments (`money.reason.admin-set?admin=Bob`), so summing by + reason grows a row per player. `FlowCategory` is the grouping, and `FlowCategory.of` is the only place the mapping + lives. +- **The supply is measured, never accumulated** — `EconomyPulse.sample` walks the ledger on the flush task. Never keep + a running total of how much currency exists; it would drift the first time anything moved money unrecorded. - **A failure carries a key, not a sentence** — `EconomyResult.Failure` holds a `money.error.*` translation key, and the code that shows it picks the language. Those values are plain text, because Vault hands them straight to other plugins, which print them verbatim; TriTown's own commands colour them with `common.error`. @@ -192,10 +211,6 @@ complete stack and no separate economy plugin is needed. See reach TriTown as ordinary deposits and withdrawals. A `BankTransactionEvent` handler that wrote a record would double-count every town deposit. - **Costs and rewards are configurable** — put amounts in `config.yml`, not in Kotlin. -- **Server-wide figures go through `EconomyPulse`** — it is fed from `EconomyService.record`, so a new way of moving - money is counted without touching it. Group a movement with `FlowCategory`, never by totalling raw reason strings, - and remember the supply is measured off the ledger rather than accumulated. See - [Economy Statistics](docs/DEVELOPER_GUIDE.md#economy-statistics). ## Working with the Admin Panel diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 0e99b3c..cf76abb 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -2293,6 +2293,48 @@ number worth reading, and the panel says so on the card. bounded, stable and translatable through `money.flow.*`, and `FlowCategory.of(source, reason)` is the only place the mapping lives. +### Wiring a new feature in + +**Every feature that moves money has to reach these figures.** A movement nothing claims is filed as +`FlowCategory.EXTERNAL` — "Other plugins" — so a new faucet or sink that skips this makes the panel quietly wrong +about where the server's currency comes from. + +Say a daily reward is being added. Four steps, and only the first two are about the reward itself: + +**1. Move the money through `EconomyUtil`, naming where it came from and why.** Writing a balance any other way is +invisible to `EconomyService.record`, and therefore to everything here. + +```kotlin +EconomyUtil.deposit(player, amount, EconomyContext.SOURCE_COMMAND, TransactionReason.DAILY_REWARD) +``` + +**2. Give the reason a key**, in `TransactionReason`, with the wording in both language files. The key is what is +written to the transaction log, so it stays readable whatever language the server is later set to. + +```kotlin +const val DAILY_REWARD = "money.reason.daily-reward" +``` + +**3. Give it a category**, unless an existing one already describes it. A faucet or sink of its own — a payout, a +reward, a repair fee, a lottery — earns one; a variation on something already grouped does not. + +```kotlin +// enums/FlowCategory.kt +DAILY_REWARD, // the constant + +DAILY_REWARD -> "money.flow.daily-reward" // in `key`, spelled out for the language test + +key == TransactionReason.DAILY_REWARD -> DAILY_REWARD // in `of`, before the source fallbacks +``` + +Add `money.flow.daily-reward` to both language files. A real faucet must never be left landing in `OTHER`. + +**4. Decide whether the panel should name it.** The full breakdown (`EconomyFlowGUI`) picks a new category up on its +own and needs nothing; a card of its own in `EconomyPanelGUI` is for a source big enough that an owner wants it on the +first screen. Give a new category a `descriptionOf` line either way, so the breakdown can say what it is. + +Nothing in `EconomyPulse` changes for any of this — that is the point of feeding it from `EconomyService.record`. + ### Recording `EconomyService.record` files every transaction, and hands it to `EconomyPulse` before the history log — so the diff --git a/docs/UTILITY_GUIDE.md b/docs/UTILITY_GUIDE.md index 43f8081..bca139b 100644 --- a/docs/UTILITY_GUIDE.md +++ b/docs/UTILITY_GUIDE.md @@ -522,9 +522,10 @@ refunds the sender if the deposit fails. Use `format` for anything another plugin will print — it must never contain MiniMessage tags — and `formatRich` for TriTown's own messages. -The four-argument `withdraw` and `deposit` attach a source and a reason to the transaction, so it shows up in -`/eco history` as something other than an anonymous Vault call. A feature with a story to tell should use them rather -than reaching past `EconomyUtil` for the economy service: +**Attribute every movement.** The four-argument `withdraw` and `deposit` attach a source and a reason to the +transaction, which is what puts it in `/eco history` as something other than an anonymous Vault call *and* what lets +the admin panel say where the server's currency comes from. A movement that claims nothing is counted as another +plugin's, so any feature that moves money uses these rather than the two-argument pair or a reach past `EconomyUtil`: ```kotlin val reason = TransactionReason.of(TransactionReason.SHOP_BUY, "shop" to shop.displayName) @@ -534,6 +535,9 @@ EconomyUtil.withdraw(player, price, EconomyContext.SOURCE_SHOP, reason) The attribution travels on the calling thread, so it reaches the record without this having to know which provider won. Another plugin's economy keeps no such record and ignores it. +A new kind of movement also needs its reason grouped, or the figures file it under "Other plugins" — see +[Wiring a new feature in](DEVELOPER_GUIDE.md#wiring-a-new-feature-in). + --- ## ChatPrompt From 39acc0053f46204520ad0bbd01fcd52b95dff544 Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sat, 19 Sep 2026 19:44:25 +0800 Subject: [PATCH 11/14] Feature: Add custom ordering and sorting to shops --- CHANGELOG.md | 3 + README.md | 4 + docs/DEVELOPER_GUIDE.md | 29 +++ .../mc/plugins/tritown/enums/ShopSortMode.kt | 23 +++ .../tritown/guis/shop/ShopEditorGUI.kt | 187 ++++++++++++++++-- .../plugins/tritown/guis/shop/ShopRender.kt | 13 ++ .../plugins/tritown/guis/shop/ShopSortGUI.kt | 178 +++++++++++++++++ .../tritown/registration/PagedPluginGUI.kt | 16 ++ .../mc/plugins/tritown/shops/ShopSorting.kt | 52 +++++ src/main/resources/lang/en_US.yml | 29 +++ src/main/resources/lang/zh_CN.yml | 29 +++ 11 files changed, 542 insertions(+), 21 deletions(-) create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/ShopSortMode.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopSortGUI.kt create mode 100644 src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopSorting.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index b38d6a0..4c581e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ + `/tritown shop create ` makes one and drops you straight into the editor. Add an item by clicking it in your own inventory or dragging it over the menu — your item stays where it is, and everything about it is kept, so a renamed, enchanted or otherwise custom item is sold exactly as you made it. + + The order entries are in is the order players see. Right-click one in the editor to pick it up and click where it + belongs — on any page — or send it straight to the front or the back. A whole shop can also be sorted by item + name or by price in one go. + An entry can be sold, bought back, or both. Left-click buys one, shift-left-click buys as many as you can afford and carry, right-click sells one, and shift-right-click sells everything you are carrying. + A price can be money, items, or both at once, and so can a payout — so a shop can sell for currency, barter, or diff --git a/README.md b/README.md index 774b81e..c568ba4 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,10 @@ A shop is created with `/tt shop create `, which opens its editor. Everythin inventory — the item is copied, with every property it has, so a renamed and enchanted sword goes on the shelf as that exact sword. The stack size you click becomes the bundle: click a stack of 16 bread and one purchase is 16 loaves. The bundle can be changed afterwards, and is not limited to a stack — 128 bread is handed over as two. +- **Arranging it.** Entries are shown to players in the order they are in the editor. Right-click one to pick it up, + then click where it should go — including on another page — and it drops in front of whatever you clicked; two + buttons send it to the front or the back of the shop instead. A whole shop can be put in order at once by name or by + price, which replaces the arrangement you made by hand and asks before it does. - **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. diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index cf76abb..c8e6a33 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -482,6 +482,20 @@ override fun onContentClick(event: InventoryClickEvent, page: Int) { `pageSize` is how many items one page holds, should a subclass need it. +**Redraw in place with `refresh(player, inventory)`** when a click changes what the menu shows — an entry removed, an +item moved. Reopening the menu to show the change puts the viewer back on the first page of whatever they were part-way +through, which is exactly wrong for a menu being edited page by page. `refresh` clamps the page too, so the last item +leaving a page steps back rather than showing an empty one: + +```kotlin +override fun onContentClick(event: InventoryClickEvent, page: Int) { + event.isCancelled = true + val index = contentIndex(page, event.rawSlot) ?: return + entries.removeAt(index) + refresh(event.whoClicked as Player, event.inventory) +} +``` + ### Modes `PagedPluginGUI` supports two item-supply modes controlled by the `mode` constructor parameter: @@ -2654,6 +2668,7 @@ rather than in `getItems`. | `ShopEditorGUI` | One shop's entries; adds one from the administrator's own inventory | | `ShopEntryGUI` | One entry's prices, limit, 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 | | `ShopStatsGUI` | What a shop has traded | @@ -2668,6 +2683,20 @@ which opens the next menu on the following tick. event; a drag reads `event.oldCursor` and cancels too. A live slot would lose the item to a crash or a mistimed close, and an administrator setting up a shop is usually holding the only copy of what they are adding. +**Nor to reorder them.** The order of `ShopDefinition.entries` is the order players see, and the editor rearranges it +with a mark rather than a cursor: a right click writes the entry's id into the editor's `moving` map, the next click on +a slot says where it goes, and the entry always lands immediately before whatever was clicked. A stack held on the +cursor would not survive turning the page, and the same live-slot objection applies as for adding. While an entry is +marked the navigation row carries the move's own controls — the held entry, which puts it back down, and the two ends of +the shop — in the slots the editor's usual actions occupy, and clicks in the administrator's own inventory do nothing +so that a move cannot end in an accidental new entry. Every move redraws the page in place through +`PagedPluginGUI.refresh`, so a shop can be rearranged across pages without being thrown back to the first one. + +**`ShopSorting` is the only thing that sorts a shop**, and `ShopSortGUI` asks before it runs: an order is chosen, then +applied, because sorting overwrites an arrangement made by hand and nothing records how it was reached. An entry with +no buy price sorts last whichever way the prices run, and item names are compared in the server's own words rather than +each viewer's, because one shop has one order and it cannot depend on who is looking at it. + Anything free-form — a price, a permission node, a shop's name — is asked for in chat through `ChatPrompt`, because a chest menu has nowhere to type and a price of 12500 is not somewhere to click. diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/ShopSortMode.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/ShopSortMode.kt new file mode 100644 index 0000000..7a361e6 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/ShopSortMode.kt @@ -0,0 +1,23 @@ +package net.trilleo.mc.plugins.tritown.enums + +/** + * The orders the shop editor can arrange a shop's entries into. + * + * A sort is an alternative to arranging entries by hand, not a property of the + * shop: it is applied once, on request, and the result is an ordinary order + * that can be rearranged afterwards. + */ +enum class ShopSortMode { + + /** By item name, A to Z. */ + NAME, + + /** By item name, Z to A. */ + NAME_REVERSED, + + /** By what the entry is sold for, cheapest first. */ + PRICE, + + /** By what the entry is sold for, dearest first. */ + PRICE_REVERSED, +} 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 a90d1ae..9741188 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 @@ -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.trilleo.mc.plugins.tritown.enums.FillMode import net.trilleo.mc.plugins.tritown.enums.PagedLayout import net.trilleo.mc.plugins.tritown.registration.GUIManager @@ -18,12 +20,14 @@ import org.bukkit.event.inventory.ClickType import org.bukkit.event.inventory.InventoryClickEvent import org.bukkit.event.inventory.InventoryCloseEvent import org.bukkit.event.inventory.InventoryDragEvent +import org.bukkit.inventory.Inventory import org.bukkit.inventory.ItemStack import java.util.UUID import java.util.concurrent.ConcurrentHashMap /** - * What one shop sells, and where entries are added and removed. + * What one shop sells, in the order players see it, and where entries are + * added, rearranged and removed. * * An entry is added by clicking a stack in your own inventory, or dragging one * over the menu. Nothing actually moves: the stack is copied, custom data and @@ -31,6 +35,12 @@ import java.util.concurrent.ConcurrentHashMap * crash or a mistimed close, and an administrator setting up a shop is usually * holding the only copy of whatever they are adding. * + * Reordering works the same way — nothing is picked up in the inventory sense. + * A right click marks an entry as the one being moved, and the next click on a + * slot says where it goes. Real item movement would need live slots in a menu + * that must never hold the only copy of an item, and a mark survives turning + * the page, which a stack on the cursor does not. + * * The actions live in the navigation row rather than after the last entry, so * they stay under the same finger however many entries the shop grows. */ @@ -44,34 +54,75 @@ class ShopEditorGUI : PagedPluginGUI( private val editing = ConcurrentHashMap() + /** The entry each administrator is part-way through moving, by its id. */ + private val moving = ConcurrentHashMap() + /** Opens the editor for [shop]. */ fun open(player: Player, shop: ShopDefinition) { editing[player.uniqueId] = shop.id + moving.remove(player.uniqueId) GUIManager.open(player, ID) } override fun getItems(player: Player): List { val shop = shopOf(player) ?: return emptyList() - return shop.entries.map { entry -> icon(player, entry) } + val held = moving[player.uniqueId] + return shop.entries.map { entry -> icon(player, entry, held) } } override fun navButtons(player: Player): Map { val shop = shopOf(player) ?: return emptyMap() - return mapOf( - SLOT_ADD to button(player, Material.PAPER, "gui.shop-editor.add", "gui.shop-editor.add-lore"), - SLOT_SETTINGS to settingsButton(player, shop), - SLOT_STATS to button(player, Material.WRITABLE_BOOK, "gui.shop-editor.stats", "gui.shop-editor.stats-lore"), - SLOT_LIST to button(player, Material.ARROW, "gui.shop-editor.back", "gui.shop-editor.back-lore"), - ) + val held = moving[player.uniqueId]?.let(shop::entry) + val back = button(player, Material.ARROW, "gui.shop-editor.back", "gui.shop-editor.back-lore") + + if (held != null) { + return mapOf( + SLOT_HELD to heldButton(player, held), + SLOT_MOVE_FIRST to button( + player, Material.SPECTRAL_ARROW, + "gui.shop-editor.move-first", "gui.shop-editor.move-first-lore", + ), + SLOT_MOVE_LAST to button( + player, Material.TIPPED_ARROW, + "gui.shop-editor.move-last", "gui.shop-editor.move-last-lore", + ), + SLOT_LIST to back, + ) + } + + return buildMap { + put(SLOT_ADD, button(player, Material.PAPER, "gui.shop-editor.add", "gui.shop-editor.add-lore")) + put(SLOT_SETTINGS, settingsButton(player, shop)) + put( + SLOT_STATS, + button(player, Material.WRITABLE_BOOK, "gui.shop-editor.stats", "gui.shop-editor.stats-lore"), + ) + put(SLOT_LIST, back) + if (isSortable(shop)) { + put(SLOT_SORT, button(player, Material.HOPPER, "gui.shop-editor.sort", "gui.shop-editor.sort-lore")) + } + } } override fun onNavClick(event: InventoryClickEvent, offset: Int) { val player = event.whoClicked as? Player ?: return val shop = shopOf(player) ?: return + val held = moving[player.uniqueId] + + if (held != null) { + when (offset) { + SLOT_HELD -> release(player, event.inventory) + SLOT_MOVE_FIRST -> place(player, shop, held, 0, event.inventory) + SLOT_MOVE_LAST -> place(player, shop, held, shop.entries.size, event.inventory) + SLOT_LIST -> ShopRender.navigate { ShopListGUI.show(player) } + } + return + } when (offset) { SLOT_SETTINGS -> ShopRender.navigate { ShopSettingsGUI.show(player, shop) } SLOT_STATS -> ShopRender.navigate { ShopStatsGUI.show(player, shop) } + SLOT_SORT -> if (isSortable(shop)) ShopRender.navigate { ShopSortGUI.show(player, shop) } SLOT_LIST -> ShopRender.navigate { ShopListGUI.show(player) } } } @@ -85,6 +136,7 @@ class ShopEditorGUI : PagedPluginGUI( val player = event.whoClicked as? Player if (player != null && event.clickedInventory === player.inventory) { event.isCancelled = true + if (moving.containsKey(player.uniqueId)) return val shop = shopOf(player) ?: return event.currentItem?.let { add(player, shop, it) } return @@ -100,33 +152,90 @@ class ShopEditorGUI : PagedPluginGUI( val shop = shopOf(player) ?: return val index = contentIndex(page, event.rawSlot) ?: return + val held = moving[player.uniqueId] + if (held != null) { + place(player, shop, held, index, event.inventory) + return + } + val entry = shop.entries.getOrNull(index) ?: return - click(event.click, player, shop, entry) + click(event.click, player, shop, entry, event.inventory) } override fun onDrag(event: InventoryDragEvent) { event.isCancelled = true val player = event.whoClicked as? Player ?: return + if (moving.containsKey(player.uniqueId)) return val shop = shopOf(player) ?: return add(player, shop, event.oldCursor) } override fun onClose(event: InventoryCloseEvent) { super.onClose(event) - editing.remove((event.player as? Player)?.uniqueId ?: return) + val player = event.player as? Player ?: return + editing.remove(player.uniqueId) + moving.remove(player.uniqueId) + } + + private fun click( + click: ClickType, + player: Player, + shop: ShopDefinition, + entry: ShopEntry, + inventory: Inventory, + ) { + when (click) { + ClickType.SHIFT_LEFT -> { + shop.entries.remove(entry) + ShopManager.save() + player.sendPrefixed(player.tr("shop.editor.entry-removed")) + refresh(player, inventory) + } + + ClickType.RIGHT, ClickType.SHIFT_RIGHT -> { + moving[player.uniqueId] = entry.id + refresh(player, inventory) + playClick(player) + } + + else -> ShopRender.navigate { ShopEntryGUI.show(player, shop, entry) } + } } - private fun click(click: ClickType, player: Player, shop: ShopDefinition, entry: ShopEntry) { - if (click == ClickType.SHIFT_LEFT) { - shop.entries.remove(entry) - ShopManager.save() - player.sendPrefixed(player.tr("shop.editor.entry-removed")) - ShopRender.navigate { show(player, shop) } + /** + * Moves the entry being held to [index], the position of the slot clicked. + * + * The entry always lands immediately before whatever was clicked, whether + * it came from earlier or later in the shop — which is why the target is a + * slot back when it is moving forwards: by then the list has closed up + * behind it. A click past the last entry appends, and a click on the entry + * itself puts it down where it already is. + */ + private fun place(player: Player, shop: ShopDefinition, entryId: String, index: Int, inventory: Inventory) { + moving.remove(player.uniqueId) + + val from = shop.entries.indexOfFirst { it.id == entryId } + if (from < 0 || from == index) { + refresh(player, inventory) + playClick(player) return } - ShopRender.navigate { ShopEntryGUI.show(player, shop, entry) } + val entry = shop.entries.removeAt(from) + val target = (if (from < index) index - 1 else index).coerceIn(0, shop.entries.size) + shop.entries.add(target, entry) + ShopManager.save() + + refresh(player, inventory) + playClick(player) + } + + /** Puts the held entry back down without moving it. */ + private fun release(player: Player, inventory: Inventory) { + moving.remove(player.uniqueId) + refresh(player, inventory) + playClick(player) } /** @@ -150,16 +259,30 @@ class ShopEditorGUI : PagedPluginGUI( ShopRender.navigate { ShopEntryGUI.show(player, shop, entry) } } - private fun icon(player: Player, entry: ShopEntry): ItemStack { + private fun icon(player: Player, entry: ShopEntry, heldId: String?): ItemStack { val lore = buildList { add(player.tr("gui.shop-editor.bundle", "amount" to entry.bundleSize)) if (entry.isBuyable) addAll(buyLines(player, entry)) else add(player.tr("gui.shop-editor.not-buyable")) if (entry.isSellable) addAll(sellLines(player, entry)) else add(player.tr("gui.shop-editor.not-sellable")) - add(player.tr("gui.shop-editor.click-entry")) - add(player.tr("gui.shop-editor.shift-click-entry")) + + when { + heldId == entry.id -> { + add(player.tr("gui.shop-editor.being-moved")) + add(player.tr("gui.shop-editor.click-put-down")) + } + + heldId != null -> add(player.tr("gui.shop-editor.click-place-before")) + + else -> { + add(player.tr("gui.shop-editor.click-entry")) + add(player.tr("gui.shop-editor.right-click-entry")) + add(player.tr("gui.shop-editor.shift-click-entry")) + } + } } - return ShopRender.withLore(entry.displayStack(), lore) + val icon = ShopRender.withLore(entry.displayStack(), lore) + return if (heldId == entry.id) ShopRender.glowing(icon) else icon } private fun buyLines(player: Player, entry: ShopEntry): List = @@ -168,6 +291,14 @@ class ShopEditorGUI : PagedPluginGUI( private fun sellLines(player: Player, entry: ShopEntry): List = listOf(player.tr("gui.shop-editor.sell")) + ShopRender.costLines(player, entry.sell) + /** 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( + entry.displayStack(), + listOf(player.tr("gui.shop-editor.holding"), player.tr("gui.shop-editor.click-put-down")), + ) + ) + private fun settingsButton(player: Player, shop: ShopDefinition): ItemStack = itemStack(Material.COMPARATOR) { name(player.tr("gui.shop-editor.settings")) meta { lore(LoreUtil.wrapLore(player.tr("gui.shop-editor.settings-lore", "id" to shop.id))) } @@ -179,6 +310,12 @@ class ShopEditorGUI : PagedPluginGUI( meta { lore(LoreUtil.wrapLore(player.tr(loreKey))) } } + private fun playClick(player: Player) = + player.playSound(Sound.sound(Key.key("minecraft:ui.button.click"), Sound.Source.UI, 1f, 1f)) + + /** Sorting a shop with one entry in it would only be a way to lose the arrangement of a shop with many. */ + private fun isSortable(shop: ShopDefinition): Boolean = shop.entries.size > 1 + private fun shopOf(player: Player): ShopDefinition? = editing[player.uniqueId]?.let(ShopManager::get) companion object { @@ -189,6 +326,14 @@ class ShopEditorGUI : PagedPluginGUI( private const val SLOT_SETTINGS = 2 private const val SLOT_STATS = 3 private const val SLOT_LIST = 5 + private const val SLOT_SORT = 6 + + // While an entry is being moved the first three carry the move's own + // controls instead, so the row an administrator is already looking at + // answers the question in front of them. + private const val SLOT_HELD = SLOT_ADD + private const val SLOT_MOVE_FIRST = SLOT_SETTINGS + private const val SLOT_MOVE_LAST = SLOT_STATS /** Opens the editor for [shop] through the registered instance. */ fun show(player: Player, shop: ShopDefinition): Boolean { 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 49290a3..162fa3e 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 @@ -95,6 +95,19 @@ object ShopRender { MatchMode.MATERIAL -> player.tr("gui.shop.match-material") } + /** + * A copy of [item] that glints, for the one thing a menu is asking about. + * + * The glint is overridden rather than an enchantment added, because the + * goods are drawn exactly as they are sold and an enchantment on the icon + * would misdescribe what is on the shelf. + */ + fun glowing(item: ItemStack): ItemStack = item.clone().apply { + val meta = itemMeta ?: return@apply + meta.setEnchantmentGlintOverride(true) + itemMeta = meta + } + /** * A copy of [item] with [lines] added under whatever lore it already has. * diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopSortGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopSortGUI.kt new file mode 100644 index 0000000..58502b0 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopSortGUI.kt @@ -0,0 +1,178 @@ +package net.trilleo.mc.plugins.tritown.guis.shop + +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.enums.ShopSortMode +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PluginGUI +import net.trilleo.mc.plugins.tritown.shops.ShopDefinition +import net.trilleo.mc.plugins.tritown.shops.ShopManager +import net.trilleo.mc.plugins.tritown.shops.ShopSorting +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.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * Puts a whole shop in order at once. + * + * An order is chosen first and applied second, because sorting overwrites an + * arrangement that may have taken a while to make by hand and there is nothing + * to undo it with: the shop's order is what is saved, not how it was reached. + * That is also why the accept button only appears once an order is chosen — + * there is no single click here that rearranges a shop. + */ +class ShopSortGUI : PluginGUI( + id = ID, + titleKey = "gui.shop-sort.title", + rows = 3, + fillMode = FillMode.DARK, +) { + + private data class Pending(val shopId: String, val mode: ShopSortMode? = null) + + private val pending = ConcurrentHashMap() + + /** Asks [player] how [shop] should be ordered. */ + fun open(player: Player, shop: ShopDefinition) { + pending[player.uniqueId] = Pending(shop.id) + GUIManager.open(player, ID) + } + + override fun setup(player: Player, inventory: Inventory) { + val chosen = pending[player.uniqueId]?.mode + + inventory.setItem(SLOT_INFO, button(player, Material.BOOK, "gui.shop-sort.info", "gui.shop-sort.info-lore")) + + inventory.setItem(SLOT_NAME, choice(player, ShopSortMode.NAME, chosen)) + inventory.setItem(SLOT_NAME_REVERSED, choice(player, ShopSortMode.NAME_REVERSED, chosen)) + inventory.setItem(SLOT_PRICE, choice(player, ShopSortMode.PRICE, chosen)) + inventory.setItem(SLOT_PRICE_REVERSED, choice(player, ShopSortMode.PRICE_REVERSED, chosen)) + + if (chosen != null) { + inventory.setItem( + SLOT_CONFIRM, + button(player, Material.LIME_CONCRETE, "gui.shop-sort.confirm", "gui.shop-sort.confirm-lore"), + ) + } + inventory.setItem( + SLOT_CANCEL, + button(player, Material.RED_CONCRETE, "gui.shop-sort.cancel", "gui.shop-sort.cancel-lore"), + ) + } + + override fun onClick(event: InventoryClickEvent) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + val held = pending[player.uniqueId] ?: return + + val mode = MODE_SLOTS[event.rawSlot] + if (mode != null) { + pending[player.uniqueId] = held.copy(mode = mode) + setup(player, event.inventory) + return + } + + when (event.rawSlot) { + SLOT_CONFIRM -> apply(player) + SLOT_CANCEL -> back(player) + } + } + + override fun onClose(event: InventoryCloseEvent) { + pending.remove((event.player as? Player)?.uniqueId ?: return) + } + + private fun apply(player: Player) { + val held = pending[player.uniqueId] ?: return + val mode = held.mode ?: return + val shop = ShopManager.get(held.shopId) ?: return + + ShopSorting.sort(shop, mode) + ShopManager.save() + + player.sendPrefixed(player.tr("shop.editor.sorted", "amount" to shop.entries.size)) + ShopRender.navigate { ShopEditorGUI.show(player, shop) } + } + + /** Back to the editor the sort was asked for from, rather than out into the world. */ + private fun back(player: Player) { + val shop = pending[player.uniqueId]?.let { ShopManager.get(it.shopId) } + if (shop == null) { + player.closeInventory() + return + } + ShopRender.navigate { ShopEditorGUI.show(player, shop) } + } + + private fun choice(player: Player, mode: ShopSortMode, chosen: ShopSortMode?): ItemStack { + val lines = buildList { + add(player.tr(loreKey(mode))) + if (mode == chosen) add(player.tr("gui.shop-sort.chosen")) else add(player.tr("gui.shop-sort.click-choose")) + } + + val item = itemStack(material(mode)) { + name(player.tr(nameKey(mode))) + meta { lore(LoreUtil.wrapLore(lines.joinToString(""))) } + } + + return if (mode == chosen) ShopRender.glowing(item) else item + } + + private fun nameKey(mode: ShopSortMode): String = when (mode) { + ShopSortMode.NAME -> "gui.shop-sort.name" + ShopSortMode.NAME_REVERSED -> "gui.shop-sort.name-reversed" + ShopSortMode.PRICE -> "gui.shop-sort.price" + ShopSortMode.PRICE_REVERSED -> "gui.shop-sort.price-reversed" + } + + private fun loreKey(mode: ShopSortMode): String = when (mode) { + ShopSortMode.NAME, ShopSortMode.NAME_REVERSED -> "gui.shop-sort.name-lore" + ShopSortMode.PRICE, ShopSortMode.PRICE_REVERSED -> "gui.shop-sort.price-lore" + } + + private fun material(mode: ShopSortMode): Material = when (mode) { + ShopSortMode.NAME, ShopSortMode.NAME_REVERSED -> Material.NAME_TAG + ShopSortMode.PRICE, ShopSortMode.PRICE_REVERSED -> Material.GOLD_INGOT + } + + 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))) } + } + + companion object { + const val ID = "shop-sort" + + private const val SLOT_INFO = 4 + private const val SLOT_NAME = 10 + private const val SLOT_NAME_REVERSED = 12 + private const val SLOT_PRICE = 14 + private const val SLOT_PRICE_REVERSED = 16 + private const val SLOT_CONFIRM = 21 + private const val SLOT_CANCEL = 23 + + private val MODE_SLOTS = mapOf( + SLOT_NAME to ShopSortMode.NAME, + SLOT_NAME_REVERSED to ShopSortMode.NAME_REVERSED, + SLOT_PRICE to ShopSortMode.PRICE, + SLOT_PRICE_REVERSED to ShopSortMode.PRICE_REVERSED, + ) + + /** Opens the sort menu for [shop] through the registered instance. */ + fun show(player: Player, shop: ShopDefinition): Boolean { + val gui = GUIManager.getGUI(ID) as? ShopSortGUI ?: return false + gui.open(player, shop) + return true + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PagedPluginGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PagedPluginGUI.kt index 45d8f73..62513ff 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PagedPluginGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PagedPluginGUI.kt @@ -193,6 +193,22 @@ abstract class PagedPluginGUI( protected fun contentIndex(page: Int, rawSlot: Int): Int? = contentPositions[rawSlot]?.let { position -> page * pageSize + position } + /** + * Redraws the page [player] is looking at, into the inventory they have open. + * + * A menu whose contents change under a click — an entry removed, an item + * moved — would otherwise have to reopen itself to show the change, which + * drops the viewer back onto the first page of whatever they were part-way + * through. The page is clamped, so the last item leaving a page steps back + * rather than showing an empty one. + */ + protected fun refresh(player: Player, inventory: Inventory) { + val total = totalPages(player) + val page = (playerPages[player.uniqueId] ?: 0).coerceIn(0, total - 1) + playerPages[player.uniqueId] = page + renderPage(player, inventory, page) + } + // ----- PluginGUI overrides ------------------------------------------------ override fun setup(player: Player, inventory: Inventory) { diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopSorting.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopSorting.kt new file mode 100644 index 0000000..9f66edc --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopSorting.kt @@ -0,0 +1,52 @@ +package net.trilleo.mc.plugins.tritown.shops + +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import net.trilleo.mc.plugins.tritown.enums.ShopSortMode +import org.bukkit.inventory.ItemStack + +/** + * Puts a shop's entries in order. + * + * Sorting is done here rather than in the menu that asks for it, because the + * order of a shop is the shop's own business and a sorted list has to be + * written back into the same [ShopDefinition.entries] list the rest of the + * plugin holds on to. + */ +object ShopSorting { + + private val plain = PlainTextComponentSerializer.plainText() + + /** Rearranges [shop]'s entries into [mode]'s order, in place. */ + fun sort(shop: ShopDefinition, mode: ShopSortMode) { + val sorted = shop.entries.sortedWith(comparator(mode)) + shop.entries.clear() + shop.entries.addAll(sorted) + } + + /** + * An entry with no price sorts last whichever way the prices run, because + * a display piece has no price to rank it by and reversing the order is + * not a reason to put it at the top. + */ + private fun comparator(mode: ShopSortMode): Comparator = when (mode) { + ShopSortMode.NAME -> compareBy { name(it.item) } + ShopSortMode.NAME_REVERSED -> compareByDescending { name(it.item) } + ShopSortMode.PRICE -> compareBy { it.buy == null }.thenBy { it.buy?.money ?: 0.0 } + ShopSortMode.PRICE_REVERSED -> + compareBy { it.buy == null }.thenByDescending { it.buy?.money ?: 0.0 } + } + + /** + * What an item is called for the purpose of ordering it. + * + * A renamed item sorts under the name it was given; anything else sorts + * under its material, which is the server's own language rather than each + * viewer's — one shop has one order, and it cannot depend on who is + * looking at it. + */ + private fun name(item: ItemStack): String { + val meta = item.itemMeta + val custom = if (meta != null && meta.hasDisplayName()) meta.displayName()?.let(plain::serialize) else null + return (custom ?: item.type.name.replace('_', ' ')).lowercase() + } +} diff --git a/src/main/resources/lang/en_US.yml b/src/main/resources/lang/en_US.yml index 1ea8720..49dce8d 100644 --- a/src/main/resources/lang/en_US.yml +++ b/src/main/resources/lang/en_US.yml @@ -498,7 +498,16 @@ gui: not-buyable: "Not for sale" not-sellable: "Not bought back" click-entry: "Click to edit this entry" + right-click-entry: "Right-click to move it somewhere else" shift-click-entry: "Shift-click to remove it" + being-moved: "Being moved" + click-put-down: "Click to leave it where it is" + click-place-before: "Click to drop the held entry in front of this one" + holding: "You are moving this entry" + move-first: "To the front" + move-first-lore: "Put the entry you are moving at the very start of the shop." + move-last: "To the back" + move-last-lore: "Put the entry you are moving at the very end of the shop." add: "Add an entry" add-lore: "Click an item in your own inventory, or drag it here. Your item stays where it is." settings: "Shop settings" @@ -507,6 +516,25 @@ gui: stats-lore: "What this shop has traded." back: "All shops" back-lore: "Back to the list of shops." + sort: "Sort the whole shop" + sort-lore: "Put every entry in order at once, instead of moving them one at a time." + + shop-sort: + title: "Sort the shop" + info: "Choose an order" + info-lore: "This replaces the order the entries are in now, and there is no way back to it. Move entries one at a time to keep an order of your own." + name: "By name, A to Z" + name-reversed: "By name, Z to A" + name-lore: "A renamed item is ordered under the name you gave it." + price: "By price, cheapest first" + price-reversed: "By price, dearest first" + price-lore: "By what players pay for it. Entries that are not for sale go last." + click-choose: "Click to choose this order" + chosen: "Chosen" + confirm: "Sort the shop" + confirm-lore: "Put every entry in the order you chose." + cancel: "Cancel" + cancel-lore: "Back to the editor, nothing reordered." shop-entry: title: "Entry" @@ -608,6 +636,7 @@ shop: editor: entry-added: "Added {item} to the shop. Set a price next." entry-removed: "Removed that entry." + sorted: "Put all {amount} entries in order." # The sidebar. Which lines appear, and in what order, is set per board in diff --git a/src/main/resources/lang/zh_CN.yml b/src/main/resources/lang/zh_CN.yml index e9b7331..a0fc1e9 100644 --- a/src/main/resources/lang/zh_CN.yml +++ b/src/main/resources/lang/zh_CN.yml @@ -499,7 +499,16 @@ gui: not-buyable: "不出售" not-sellable: "不回收" click-entry: "单击 编辑这件商品" + right-click-entry: "右键 将其挑起来换个位置" shift-click-entry: "Shift + 单击 将其移除" + being-moved: "正在移动" + click-put-down: "单击 放回原位" + click-place-before: "单击 将手上的商品放到它前面" + holding: "你正在移动这件商品" + move-first: "放到最前" + move-first-lore: "将正在移动的商品放到商店的最前面。" + move-last: "放到最后" + move-last-lore: "将正在移动的商品放到商店的最后面。" add: "添加商品" add-lore: "单击你背包里的物品,或将其拖到这里。你的物品不会被拿走。" settings: "商店设置" @@ -508,6 +517,25 @@ gui: stats-lore: "这家商店的交易情况。" back: "商店列表" back-lore: "返回商店列表。" + sort: "整店排序" + sort-lore: "一次性排好所有商品,不用逐件移动。" + + shop-sort: + title: "商店排序" + info: "选择排序方式" + info-lore: "这会覆盖商品现在的顺序,且无法恢复。想保留自己排的顺序,就逐件移动。" + name: "按名称,A 到 Z" + name-reversed: "按名称,Z 到 A" + name-lore: "重命名过的物品按你取的名字排序。" + price: "按售价,从低到高" + price-reversed: "按售价,从高到低" + price-lore: "按玩家购买时支付的价格。不出售的商品排在最后。" + click-choose: "单击 选择这种顺序" + chosen: "已选择" + confirm: "开始排序" + confirm-lore: "按你选的顺序排列所有商品。" + cancel: "取消" + cancel-lore: "返回编辑器,不改变顺序。" shop-entry: title: "商品设置" @@ -609,6 +637,7 @@ shop: editor: entry-added: "已将 {item} 加入商店,接下来设置价格。" entry-removed: "已移除该商品。" + sorted: "已将全部 {amount} 件商品重新排序。" scoreboard: From 77419506d3c9d57771f103a97346c4c9daa5b694 Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sat, 19 Sep 2026 20:40:33 +0800 Subject: [PATCH 12/14] Improvement: Reformat source code --- .../net/trilleo/mc/plugins/tritown/Main.kt | 6 +----- .../mc/plugins/tritown/economy/EconomyPulse.kt | 8 ++------ .../plugins/tritown/economy/TransactionLog.kt | 14 -------------- .../economy/storage/JsonEconomyStorage.kt | 16 ---------------- .../tritown/economy/storage/JsonPulseStorage.kt | 6 +----- .../tritown/guis/admin/EconomyFlowGUI.kt | 2 +- .../tritown/guis/admin/EconomyPanelGUI.kt | 6 +----- .../plugins/tritown/guis/admin/PanelRender.kt | 2 +- .../mc/plugins/tritown/guis/admin/PanelState.kt | 2 +- .../plugins/tritown/guis/shop/ShopConfirmGUI.kt | 2 +- .../mc/plugins/tritown/guis/shop/ShopCostGUI.kt | 2 +- .../plugins/tritown/guis/shop/ShopEditorGUI.kt | 2 +- .../plugins/tritown/guis/shop/ShopEntryGUI.kt | 17 ++++------------- .../mc/plugins/tritown/guis/shop/ShopGUI.kt | 15 +++------------ .../mc/plugins/tritown/guis/shop/ShopRender.kt | 2 +- .../tritown/guis/shop/ShopSettingsGUI.kt | 2 +- .../mc/plugins/tritown/guis/shop/ShopSortGUI.kt | 2 +- .../plugins/tritown/guis/shop/ShopStatsGUI.kt | 2 +- .../mc/plugins/tritown/shops/ItemCodec.kt | 2 +- .../mc/plugins/tritown/shops/ShopEntry.kt | 2 +- .../mc/plugins/tritown/shops/ShopManager.kt | 8 ++------ .../tritown/shops/storage/JsonShopStorage.kt | 6 +----- .../mc/plugins/tritown/utils/ChatPrompt.kt | 2 +- src/main/resources/lang/zh_CN.yml | 4 ++-- .../shops/storage/JsonShopStorageTest.kt | 6 +----- 25 files changed, 31 insertions(+), 107 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 3a6c6d1..6e11c10 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt @@ -8,11 +8,7 @@ import net.trilleo.mc.plugins.tritown.config.ScoreboardSettings import net.trilleo.mc.plugins.tritown.config.ShopSettings import net.trilleo.mc.plugins.tritown.data.PlayerDataManager import net.trilleo.mc.plugins.tritown.data.ServerDataManager -import net.trilleo.mc.plugins.tritown.economy.CurrencyRegistry -import net.trilleo.mc.plugins.tritown.economy.EconomyFormat -import net.trilleo.mc.plugins.tritown.economy.EconomyPulse -import net.trilleo.mc.plugins.tritown.economy.EconomyService -import net.trilleo.mc.plugins.tritown.economy.TownyAccountNaming +import net.trilleo.mc.plugins.tritown.economy.* import net.trilleo.mc.plugins.tritown.economy.storage.JsonEconomyStorage import net.trilleo.mc.plugins.tritown.economy.storage.JsonPulseStorage import net.trilleo.mc.plugins.tritown.economy.vault.TriTownVaultEconomy diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyPulse.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyPulse.kt index 6da27b7..82e8217 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyPulse.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyPulse.kt @@ -1,14 +1,10 @@ package net.trilleo.mc.plugins.tritown.economy -import net.trilleo.mc.plugins.tritown.economy.storage.JsonPulseStorage -import net.trilleo.mc.plugins.tritown.economy.storage.StoredPulse -import net.trilleo.mc.plugins.tritown.economy.storage.StoredPulseBucket -import net.trilleo.mc.plugins.tritown.economy.storage.StoredPulseSample -import net.trilleo.mc.plugins.tritown.economy.storage.StorageSchema +import net.trilleo.mc.plugins.tritown.economy.storage.* import net.trilleo.mc.plugins.tritown.enums.AccountType import net.trilleo.mc.plugins.tritown.enums.FlowCategory import net.trilleo.mc.plugins.tritown.enums.TransactionType -import java.util.EnumMap +import java.util.* import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.LongAdder diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/TransactionLog.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/TransactionLog.kt index 1af6561..1c95853 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/TransactionLog.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/TransactionLog.kt @@ -6,20 +6,6 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.atomic.AtomicLong import kotlin.collections.ArrayDeque -import kotlin.collections.ArrayList -import kotlin.collections.List -import kotlin.collections.Map -import kotlin.collections.asReversed -import kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.emptyList -import kotlin.collections.emptyMap -import kotlin.collections.forEach -import kotlin.collections.isNotEmpty -import kotlin.collections.iterator -import kotlin.collections.set -import kotlin.collections.sortedBy -import kotlin.collections.toList /** * Keeps a short, readable history per account in memory, and queues everything diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/JsonEconomyStorage.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/JsonEconomyStorage.kt index b6469b7..88ed279 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/JsonEconomyStorage.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/JsonEconomyStorage.kt @@ -14,22 +14,6 @@ import java.nio.file.* import java.util.UUID import java.util.logging.Logger import kotlin.collections.ArrayDeque -import kotlin.collections.ArrayList -import kotlin.collections.Collection -import kotlin.collections.HashMap -import kotlin.collections.LinkedHashMap -import kotlin.collections.List -import kotlin.collections.Map -import kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.emptyList -import kotlin.collections.emptyMap -import kotlin.collections.forEach -import kotlin.collections.getOrPut -import kotlin.collections.map -import kotlin.collections.mapValues -import kotlin.collections.set -import kotlin.collections.toList /** * Stores every account in one JSON file under `/economy/`. diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/JsonPulseStorage.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/JsonPulseStorage.kt index a871968..3a2441a 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/JsonPulseStorage.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/JsonPulseStorage.kt @@ -2,11 +2,7 @@ package net.trilleo.mc.plugins.tritown.economy.storage import com.google.gson.GsonBuilder import java.io.File -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.StandardCopyOption -import java.nio.file.StandardOpenOption +import java.nio.file.* import java.util.logging.Logger /** diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyFlowGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyFlowGUI.kt index 2c7d853..a9526d3 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyFlowGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyFlowGUI.kt @@ -15,7 +15,7 @@ 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.UUID +import java.util.* import java.util.concurrent.ConcurrentHashMap /** diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyPanelGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyPanelGUI.kt index 5a81fa6..93a528f 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyPanelGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyPanelGUI.kt @@ -5,11 +5,7 @@ import net.kyori.adventure.sound.Sound import net.trilleo.mc.plugins.tritown.Main import net.trilleo.mc.plugins.tritown.config.EconomySettings import net.trilleo.mc.plugins.tritown.config.ShopSettings -import net.trilleo.mc.plugins.tritown.economy.BaltopCache -import net.trilleo.mc.plugins.tritown.economy.CurrencyRegistry -import net.trilleo.mc.plugins.tritown.economy.EconomyPulse -import net.trilleo.mc.plugins.tritown.economy.EconomyService -import net.trilleo.mc.plugins.tritown.economy.TownyAccountNaming +import net.trilleo.mc.plugins.tritown.economy.* import net.trilleo.mc.plugins.tritown.enums.AccountType import net.trilleo.mc.plugins.tritown.enums.FillMode import net.trilleo.mc.plugins.tritown.enums.FlowCategory diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/PanelRender.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/PanelRender.kt index 6c2de86..f202f74 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/PanelRender.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/PanelRender.kt @@ -18,7 +18,7 @@ import java.text.DecimalFormatSymbols import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter -import java.util.Locale +import java.util.* /** * The pieces the admin panel's menus draw with. diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/PanelState.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/PanelState.kt index 014c646..c07a7d2 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/PanelState.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/PanelState.kt @@ -2,7 +2,7 @@ package net.trilleo.mc.plugins.tritown.guis.admin import net.trilleo.mc.plugins.tritown.enums.StatsWindow 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/guis/shop/ShopConfirmGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopConfirmGUI.kt index 88d4f6e..7034722 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 @@ -19,7 +19,7 @@ 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.UUID +import java.util.* import java.util.concurrent.ConcurrentHashMap /** 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 f81523a..78562e5 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 @@ -18,7 +18,7 @@ import org.bukkit.event.inventory.InventoryCloseEvent import org.bukkit.event.inventory.InventoryDragEvent import org.bukkit.inventory.Inventory import org.bukkit.inventory.ItemStack -import java.util.UUID +import java.util.* import java.util.concurrent.ConcurrentHashMap /** 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 9741188..2c5c999 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 @@ -22,7 +22,7 @@ import org.bukkit.event.inventory.InventoryCloseEvent import org.bukkit.event.inventory.InventoryDragEvent import org.bukkit.inventory.Inventory import org.bukkit.inventory.ItemStack -import java.util.UUID +import java.util.* import java.util.concurrent.ConcurrentHashMap /** 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 e7fb321..9f43d9c 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 @@ -6,18 +6,8 @@ import net.trilleo.mc.plugins.tritown.enums.LimitPeriod import net.trilleo.mc.plugins.tritown.enums.MatchMode import net.trilleo.mc.plugins.tritown.registration.GUIManager import net.trilleo.mc.plugins.tritown.registration.PluginGUI -import net.trilleo.mc.plugins.tritown.shops.ShopCost -import net.trilleo.mc.plugins.tritown.shops.ShopDefinition -import net.trilleo.mc.plugins.tritown.shops.ShopEntry -import net.trilleo.mc.plugins.tritown.shops.ShopLimit -import net.trilleo.mc.plugins.tritown.shops.ShopManager -import net.trilleo.mc.plugins.tritown.shops.ShopPricing -import net.trilleo.mc.plugins.tritown.shops.ShopStock -import net.trilleo.mc.plugins.tritown.utils.ChatPrompt -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.shops.* +import net.trilleo.mc.plugins.tritown.utils.* import org.bukkit.Material import org.bukkit.entity.Player import org.bukkit.event.inventory.ClickType @@ -25,7 +15,7 @@ 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.UUID +import java.util.* import java.util.concurrent.ConcurrentHashMap /** @@ -102,6 +92,7 @@ class ShopEntryGUI : PluginGUI( SLOT_DISCOUNT -> entry.discountable = !entry.discountable SLOT_MATCH -> entry.matchMode = if (entry.matchMode == MatchMode.EXACT) MatchMode.MATERIAL else MatchMode.EXACT + SLOT_BACK -> return ShopRender.navigate { ShopEditorGUI.show(player, shop) } else -> return } 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 a7fa88c..13a401c 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 @@ -9,24 +9,15 @@ import net.trilleo.mc.plugins.tritown.enums.PagedLayout import net.trilleo.mc.plugins.tritown.enums.TownyRequirement import net.trilleo.mc.plugins.tritown.registration.GUIManager import net.trilleo.mc.plugins.tritown.registration.PagedPluginGUI -import net.trilleo.mc.plugins.tritown.shops.ShopAccess -import net.trilleo.mc.plugins.tritown.shops.ShopDefinition -import net.trilleo.mc.plugins.tritown.shops.ShopEntry -import net.trilleo.mc.plugins.tritown.shops.ShopLimits -import net.trilleo.mc.plugins.tritown.shops.ShopManager -import net.trilleo.mc.plugins.tritown.shops.ShopTrade -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.sendPrefixed -import net.trilleo.mc.plugins.tritown.utils.tr +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.ClickType import org.bukkit.event.inventory.InventoryClickEvent import org.bukkit.event.inventory.InventoryCloseEvent import org.bukkit.inventory.ItemStack -import java.util.UUID +import java.util.* import java.util.concurrent.ConcurrentHashMap /** 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 162fa3e..0dd35fb 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 @@ -2,10 +2,10 @@ package net.trilleo.mc.plugins.tritown.guis.shop import net.kyori.adventure.text.Component import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import net.trilleo.mc.plugins.tritown.Main 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.Main import net.trilleo.mc.plugins.tritown.shops.ShopCost import net.trilleo.mc.plugins.tritown.utils.ComponentUtil import net.trilleo.mc.plugins.tritown.utils.EconomyUtil diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopSettingsGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopSettingsGUI.kt index 3cd051c..6a03ce6 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopSettingsGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopSettingsGUI.kt @@ -17,7 +17,7 @@ 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.UUID +import java.util.* import java.util.concurrent.ConcurrentHashMap /** diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopSortGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopSortGUI.kt index 58502b0..305766f 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopSortGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopSortGUI.kt @@ -17,7 +17,7 @@ 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.UUID +import java.util.* import java.util.concurrent.ConcurrentHashMap /** 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 e3678bb..016b38e 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 @@ -17,7 +17,7 @@ import org.bukkit.event.inventory.ClickType import org.bukkit.event.inventory.InventoryClickEvent import org.bukkit.event.inventory.InventoryCloseEvent import org.bukkit.inventory.ItemStack -import java.util.UUID +import java.util.* import java.util.concurrent.ConcurrentHashMap /** diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ItemCodec.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ItemCodec.kt index 33addc0..9195b61 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ItemCodec.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ItemCodec.kt @@ -1,7 +1,7 @@ package net.trilleo.mc.plugins.tritown.shops import org.bukkit.inventory.ItemStack -import java.util.Base64 +import java.util.* /** * Turns an [ItemStack] into a string a shop file can hold, and back again. 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 8190834..b7e3c86 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 @@ -2,7 +2,7 @@ package net.trilleo.mc.plugins.tritown.shops import net.trilleo.mc.plugins.tritown.enums.MatchMode import org.bukkit.inventory.ItemStack -import java.util.UUID +import java.util.* /** * One line of goods in a shop. 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 2957a69..ba4fc9b 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 @@ -3,12 +3,8 @@ package net.trilleo.mc.plugins.tritown.shops 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.storage.ShopStorage -import net.trilleo.mc.plugins.tritown.shops.storage.ShopStorageException -import net.trilleo.mc.plugins.tritown.shops.storage.StoredCost -import net.trilleo.mc.plugins.tritown.shops.storage.StoredEntry -import net.trilleo.mc.plugins.tritown.shops.storage.StoredShop -import java.util.UUID +import net.trilleo.mc.plugins.tritown.shops.storage.* +import java.util.* import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean import java.util.logging.Logger 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 7b70433..aef564e 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 @@ -4,11 +4,7 @@ import com.google.gson.GsonBuilder import com.google.gson.JsonObject import com.google.gson.JsonParser import java.io.File -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.StandardCopyOption -import java.nio.file.StandardOpenOption +import java.nio.file.* import java.util.logging.Logger /** diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/ChatPrompt.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/ChatPrompt.kt index 6a58a1d..e792848 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/ChatPrompt.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/ChatPrompt.kt @@ -3,7 +3,7 @@ package net.trilleo.mc.plugins.tritown.utils import net.trilleo.mc.plugins.tritown.Main 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/resources/lang/zh_CN.yml b/src/main/resources/lang/zh_CN.yml index a0fc1e9..019028c 100644 --- a/src/main/resources/lang/zh_CN.yml +++ b/src/main/resources/lang/zh_CN.yml @@ -437,8 +437,8 @@ gui: source: "来源:{source}" time: "{time}" -# 侧边栏。显示哪些行、按什么顺序显示由 config.yml 中的各个板面决定;措辞与颜色在此设置。 -# 变量写作 %marker%,完整列表见 config.yml。 + # 侧边栏。显示哪些行、按什么顺序显示由 config.yml 中的各个板面决定;措辞与颜色在此设置。 + # 变量写作 %marker%,完整列表见 config.yml。 shop: title: "{name}" unknown: "商店" diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/JsonShopStorageTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/JsonShopStorageTest.kt index da71c39..246d8a7 100644 --- a/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/JsonShopStorageTest.kt +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/JsonShopStorageTest.kt @@ -4,11 +4,7 @@ import java.io.File import java.nio.file.Files import java.util.logging.Level import java.util.logging.Logger -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertTrue +import kotlin.test.* /** * The shop file is the only copy of work that took an administrator an From 25c59911662f786d335b9fc2a1115e81960a982d Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sat, 19 Sep 2026 20:54:37 +0800 Subject: [PATCH 13/14] Backend: Update Minecraft to 26.3 --- build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 5fcde2f..bde806f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,7 +33,7 @@ val serverPlugins: Configuration by configurations.creating { } dependencies { - compileOnly("io.papermc.paper:paper-api:26.2.build.+") + compileOnly("io.papermc.paper:paper-api:26.3.build.+") compileOnly("com.palmergames.bukkit.towny:towny:${providers.gradleProperty("towny_version").get()}") compileOnly("com.github.MilkBowl:VaultAPI:${providers.gradleProperty("vault_api_version").get()}") { isTransitive = false @@ -101,7 +101,7 @@ tasks.register("startServer") { workingDir(layout.projectDirectory.dir("run")) classpath(fileTree(layout.projectDirectory.dir("run")) { include("paper-*.jar") }) doFirst { - check(!classpath.isEmpty) { "No paper-*.jar in run/. Download Paper 26.2 from https://papermc.io/downloads/paper into run/." } + check(!classpath.isEmpty) { "No paper-*.jar in run/. Download Paper 26.3 from https://papermc.io/downloads/paper into run/." } } args("--nogui") standardInput = System.`in` From 1401242039849b0d343333d2a8251b28616aaa34 Mon Sep 17 00:00:00 2001 From: Trilleo Date: Sat, 19 Sep 2026 21:08:01 +0800 Subject: [PATCH 14/14] Update: Plugin version 1.1.0 release --- CHANGELOG.md | 2 ++ gradle.properties | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c581e7..76aeb47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## Version 1.1.0 + ### New Features #### Shops diff --git a/gradle.properties b/gradle.properties index 503069c..e216f5b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ kotlin.code.style=official # Plugin Properties -plugin_version=1.0.0 +plugin_version=1.1.0 # Dependency Versions towny_version=0.103.2.7