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