diff --git a/CHANGELOG.md b/CHANGELOG.md index efd2b6b..76aeb47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,115 @@ ## Unreleased +## Version 1.1.0 + +### New Features + +#### Shops + ++ Added admin shops: shops the server itself runs, set up entirely in game and opened by clicking an NPC. + + `/tritown shop create ` makes one and drops you straight into the editor. Add an item by clicking it in your + own inventory or dragging it over the menu — your item stays where it is, and everything about it is kept, so a + renamed, enchanted or otherwise custom item is sold exactly as you made it. + + The order entries are in is the order players see. Right-click one in the editor to pick it up and click where it + belongs — on any page — or send it straight to the front or the back. A whole shop can also be sorted by item + name or by price in one go. + + An entry can be sold, bought back, or both. Left-click buys one, shift-left-click buys as many as you can afford + and carry, right-click sells one, and shift-right-click sells everything you are carrying. + + A price can be money, items, or both at once, and so can a payout — so a shop can sell for currency, barter, or + ask for a fee alongside the materials. + + How many items one purchase hands over is set per entry, and is not capped at a stack: a bundle of 128 bread is + handed over as two stacks. + + An entry can have a stock that refills on a timer, a per-player limit that resets daily, weekly or never, or + neither. Both are shown on the item, counting down as players buy. + + Entries and whole shops can be locked behind a permission node or a standing in Towny — being in a town or a + nation, or being a mayor or a king. A locked entry either greys out with the reason or is hidden entirely. + + Town and nation members can be given a discount, set under `shops.discounts`. Discounts do not stack; the best + one applies, and the menu shows the saving. + + A purchase above `shops.confirm-above` asks for confirmation first, so a mis-click cannot empty an account. + + Every purchase and sale is recorded in the transaction log and shows up in `/eco history` as a shop movement, + naming the shop it happened at. + + `/tritown shop stats ` shows what a shop has traded and how much currency it has taken in and paid out, per + entry and in total. ++ Shops open from a [FancyNpcs](https://modrinth.com/plugin/fancynpcs) NPC. Bind one with + `/tritown shop bind ` and clicking it opens the shop. The binding follows the NPC rather than its name, so + renaming it in FancyNpcs does not break anything. FancyNpcs is optional — without it everything else still works, and + `/tritown shop open [player]` still opens a shop from the console or for testing. + +#### Admin Panel + ++ Added `/tritown admin`, an administration panel that opens as a menu. It is the way in to what an owner needs to read + about the server, starting with the economy; more sections will follow. ++ The economy panel puts the whole economy on one screen: + + **Money supply** — how much currency exists, how it splits between player wallets, town and nation banks and the + server's own accounts, how far it has moved over the window, and how much that is per wallet. The supply is + measured off the accounts themselves rather than added up from movements, so it is exact. + + **Faucets and sinks** — how much currency was created and how much was removed, each broken down by what caused + it: new players, shops, Towny, administrators or another plugin. The net says which way the economy is drifting, + per day and as a share of the supply, with how long it would take at that rate to double or run dry. + + **Wealth distribution** — the median, mean and largest wallet, the share held by the richest tenth, and an + inequality figure with a word for what it means. + + **Circulation** — how much money players moved between themselves, over how many payments, and how quickly the + supply turns over. + + **A chart** — the window drawn as seven columns, each as tall as its net change, so a payday, a sink nobody uses + or a runaway faucet shows up as a shape rather than a number. + + **Accounts, the richest accounts, the shops and the ledger's own settings**, so nothing needs a command to check. ++ Every figure can be read over the last day, the last week, the last month or everything on record. Click the clock to + change the window and the whole screen follows it. ++ A full breakdown lists every source of money and every kind of account with what it created, removed and netted, for + the same window. ++ The shop sales figures now have a home in the panel: one screen lists every shop with what it has taken in and paid + out, and clicking one opens the figures that shop already had. `/tritown shop stats ` still opens a single shop + directly, and shift-clicking a shop here opens its editor. ++ Each section has its own permission — `tritown.admin` to open the panel, `tritown.admin.economy` and + `tritown.admin.shops` for the sections — so a moderator can be given the reading without the editing. + +### Fixes + +#### Misc + ++ Fixed items being draggable into a plugin menu. Clicks were already blocked, but a drag across the menu was not. + +### Technical Details + +#### Economy + ++ The economy now keeps figures of its own, hour by hour: what was created and destroyed, what for, who held it, and a + measurement of the ledger taken on every flush. They live in `plugins/TriTown/economy/statistics.json` and are + written on the same interval as balances, so a crash costs at most one interval of them and never a balance. ++ `economy.stats.enabled` turns the figures off entirely, and `economy.stats.retention-days` says how far back they + reach — 30 days by default, or 0 to keep them forever. ++ Recording a movement costs no disk and no lock: the counters are plain adders, which matters because Towny moves + money from its own threads. ++ Transaction statistics are kept even when `economy.history.enabled` is off, since they cost nothing per transaction. + +#### Shops + ++ Added [FancyNpcs](https://modrinth.com/plugin/fancynpcs) as an optional dependency. TriTown builds and runs without + it; the parts that need it simply stay off. ++ Shops are stored in `plugins/TriTown/shops/shops.json`, written atomically with a backup copy in the same way + balances are. A shop you edit is saved immediately; stock levels and sales figures are written every + `shops.save-interval` seconds. + +#### Misc + ++ GUIs can now handle a drag through `onDrag`, which cancels the drag by default. The GUI manager also forgets a player + who quits with a menu open. ++ Paged GUIs gained `PagedLayout.FRAMED`, which insets the content and draws a border around it, and `navButtons`, + which puts a menu's own actions in the fixed navigation row instead of after the last item where they move as the + list grows. `contentIndex` turns a clicked slot into a position in the item list, which a framed layout needs. ++ `PlayerData` and `ServerData` gained `getJsonObject`, so a nested object that was written can be read back. ++ Added `ChatPrompt`, which asks a player a question in chat and hands the answer back on the server thread. Menus use + it for anything that has to be typed, such as a price or a permission node. ++ `GUIManager.openLater` opens a menu on the following tick, which is what a menu reached by clicking inside another + one needs so the server and the client do not disagree about what is on screen. + +#### Economy + ++ `EconomyUtil.withdraw` and `EconomyUtil.deposit` can now name the source and reason of a movement, so a feature no + longer has to reach past them for its transactions to be recorded as anything but an anonymous Vault call. + + ## Version 1.0.0 ### New Features diff --git a/CLAUDE.md b/CLAUDE.md index ac655a4..f0a2d2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ overview. | Platform | Paper API 26.2 (MC 26.2) | | Towny | 0.103.2.7 (`towny_version` in gradle.properties) | | Vault API | 1.7.1 (`vault_api_version` in gradle.properties) | +| FancyNpcs API | 2.9.2 (`fancynpcs_version`, API artifact only) | | Java toolchain | JDK 25 | ## After Every Change: Keep the Changelog and Docs in Sync @@ -57,9 +58,9 @@ Before finishing any task that changes the plugin, do all of the following: ``` The local test server lives in `run/` (gitignored). `copyPlugin` puts the matching Towny jar in `run/plugins/`, but the -Paper 26.2 jar (`run/paper-*.jar`) and Vault must be downloaded by hand, and `eula.txt` accepted, before `startServer` -works. No economy plugin is needed — TriTown supplies the economy itself. The server console reads commands from the -terminal running Gradle. +Paper 26.2 jar (`run/paper-*.jar`), Vault, and FancyNpcs (Maven carries its API only) must be downloaded by hand, and +`eula.txt` accepted, before `startServer` works. No economy plugin is needed — TriTown supplies the economy itself. +The server console reads commands from the terminal running Gradle. ## Repository Layout @@ -67,20 +68,22 @@ terminal running Gradle. src/main/kotlin/net/trilleo/mc/plugins/tritown/ ├── Main.kt # Plugin entry point (Main.instance, Main.reload()) ├── commands/ # Sub-commands (auto-registered) -│ ├── info/ -│ └── moderation/ +│ ├── admin/ economy/ info/ +│ └── moderation/ scoreboard/ shop/ ├── config/ # PluginConfig (typed config.yml wrapper), EconomySettings ├── data/ # JSON-persisted PlayerData / ServerData and their managers -├── economy/ # The economy: ledger, accounts, currencies, Vault provider, storage (not scanned) -├── enums/ # AccountType, DisplayLocation, FillMode, PagedGUIMode, ProviderMode, TransactionType -├── guis/ # GUIs (auto-registered, extend PluginGUI / PagedPluginGUI) +├── economy/ # The economy: ledger, accounts, currencies, Vault provider, statistics, +│ # storage (not scanned) +├── enums/ # AccountType, FlowCategory, StatsWindow, TransactionType, FillMode, … +├── guis/ # GUIs (auto-registered, extend PluginGUI / PagedPluginGUI); admin/ is the panel ├── items/ # Custom items (auto-registered, extend PluginItem) ├── listeners/ # Event listeners, including Towny events (auto-registered) ├── recipes/ # Recipes (auto-registered, implement PluginRecipe) ├── registration/ # Auto-registration engine (do not modify lightly) +├── shops/ # Admin shops: model, trading, storage, FancyNpcs bridge (not scanned) ├── tasks/ # Scheduled tasks (auto-registered, extend PluginTask) -└── utils/ # Lang, EconomyUtil, itemStack DSL, MessageUtil, LoreUtil, CountdownUtil, TeamUtil, - # TagUtil, PDCUtil, GameRuleUtil +└── utils/ # Lang, EconomyUtil, itemStack DSL, MessageUtil, LoreUtil, ChatPrompt, CountdownUtil, + # TeamUtil, TagUtil, PDCUtil, GameRuleUtil src/main/resources/ ├── config.yml plugin.yml └── lang/ # en_US.yml, zh_CN.yml — every player-facing string @@ -90,8 +93,8 @@ src/main/resources/ The plugin uses `PackageScanner` to discover components at startup — you **never** edit `plugin.yml` or wire things manually. Just extend the right base class and place the file in the correct package. Packages outside the table below -are never scanned, which is why the economy core lives in `economy/`: it has to be alive in `onLoad`, long before the -registrars run. +are never scanned, which is why the economy core lives in `economy/` and the shop core in `shops/`: both have to be +alive before the registrars build the commands and menus that read them. | Component | Base Class | Package | |:------------|:-------------------------------|:------------------------| @@ -172,6 +175,25 @@ complete stack and no separate economy plugin is needed. See - **Charge before acting** — call `EconomyUtil.withdraw` and only perform the action when it returns `true`; refund with `deposit` if the action then fails. Never check `has` and withdraw separately. Use `EconomyUtil.transfer` for a payment between two accounts, which is atomic on TriTown's own economy. +- **Wire every new way money moves into the statistics.** The admin panel's figures are only as true as the + attribution behind them, and a movement nothing claims is filed as "Other plugins" — so a new faucet or sink that + skips this quietly makes the economy unreadable. For **every** feature that moves money: + 1. **Move it through `EconomyUtil`** (or `EconomyService` inside the economy itself), never by writing a balance: + that path is the only one `EconomyService.record` — and therefore `EconomyPulse` — ever sees. + 2. **Attribute it.** Use the four-argument `EconomyUtil.withdraw`/`deposit` with an `EconomyContext.SOURCE_*` and a + `TransactionReason` key, or wrap the work in `EconomyContext.with`. Add the `money.reason.*` key to both + language files. + 3. **Check `FlowCategory.of` covers that reason.** If the feature is a faucet or a sink in its own right — a job + payout, a daily reward, a repair fee, a lottery — give it a `FlowCategory`, spell out its `money.flow.*` key in + both language files, and map the reason to it. A real faucet must never land in `OTHER`. + 4. **Decide whether the panel should name it.** The full breakdown picks a new category up on its own; a card of + its own in `EconomyPanelGUI` is for a source worth watching separately. + See [Economy Statistics](docs/DEVELOPER_GUIDE.md#economy-statistics). +- **Never total raw reason strings** — a reason carries arguments (`money.reason.admin-set?admin=Bob`), so summing by + reason grows a row per player. `FlowCategory` is the grouping, and `FlowCategory.of` is the only place the mapping + lives. +- **The supply is measured, never accumulated** — `EconomyPulse.sample` walks the ledger on the flush task. Never keep + a running total of how much currency exists; it would drift the first time anything moved money unrecorded. - **A failure carries a key, not a sentence** — `EconomyResult.Failure` holds a `money.error.*` translation key, and the code that shows it picks the language. Those values are plain text, because Vault hands them straight to other plugins, which print them verbatim; TriTown's own commands colour them with `common.error`. @@ -190,6 +212,41 @@ complete stack and no separate economy plugin is needed. See double-count every town deposit. - **Costs and rewards are configurable** — put amounts in `config.yml`, not in Kotlin. +## Working with the Admin Panel + +The panel in `guis/admin` is where an owner reads the server; `/tritown admin` opens it. See +[Admin Panel](docs/DEVELOPER_GUIDE.md#admin-panel). + +- **A section is a card and a menu.** Adding one means adding a card to `AdminPanelGUI` and a menu of its own; nothing + else in the panel changes. Give it its own permission under `tritown.admin.*` and do not draw a card the viewer + cannot open. +- **The panel reads, it does not write.** Anything that changes the server belongs in the command or menu that owns it, + not here. +- **Format through `PanelRender`** — money, percentages, rates, timestamps and the cards themselves, so the same figure + reads the same wherever it appears. Amounts stay in minor units until they reach it. +- **The window belongs to the viewer**, in `PanelState`, so every menu of the panel agrees on what is being looked at. + +## Working with Shops + +**Shops are the server's own, not a player's.** See [Shops](docs/DEVELOPER_GUIDE.md#shops). + +- **Go through `ShopManager`** — it is the only thing that reads or writes a shop. `save()` for a change to a + definition, which must never be lost; `markDirty()` for stock and statistics, which `ShopSaveTask` flushes. +- **Trade only through `ShopTrade`** — its ordering is what keeps a trade safe: everything that can refuse is asked + before anything is taken, and anything taken is remembered so it can be put back. Never charge and hand over in two + places. +- **Serialize items with `ItemCodec`** — Paper's byte form is the only round-trip that keeps every data component, so a + custom item survives. Never describe an item field by field. +- **Read Towny through `ShopAccess`** — it is the one place shops touch Towny, and it re-reads on every check. +- **Keep FancyNpcs isolated** — only `listeners/shop/ShopNpcListener` may name a FancyNpcs type in a signature, and + only `shops/npc/FancyNpcsAdapter` may touch the API. `ShopNpcBridge` exposes plain types so a server without the + plugin still loads everything else. Adding a FancyNpcs type to its signatures would take the shop command with it. +- **Attribute money with the four-argument `EconomyUtil.withdraw`/`deposit`** so a trade is recorded as a shop movement + rather than an anonymous Vault call. +- **Shop and entry names are administrator-written MiniMessage stored in the shop file**, not translation keys. Escape + anything player-written before embedding it; a shop's own name is deliberately not escaped, because an administrator + wrote it. + ## Versioning & Releases - `plugin_version` in [gradle.properties](gradle.properties) is the single source of truth for the plugin version. It diff --git a/README.md b/README.md index e86da12..c568ba4 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,20 @@ group them, inside a shared header and footer carrying the server name and addre in `config.yml`; the wording lives in the language files, so everyone reads it in their own language. Players turn it on and off with `/tt scoreboard`, and it takes turns with Towny's own plot HUD rather than fighting it for the screen. +**Shops the server runs.** Admin shops, set up entirely in game: click an item in your own inventory to put it on the +shelf and it is sold exactly as you made it, custom name, enchantments and all. An entry can be sold, bought back, or +both, and priced in currency, items, or a mix of the two. Give it a stock that refills on a timer, a limit on how much +each player may buy per day or per week, a permission node, or a requirement to be in a town or a nation — and give +town or nation members a discount while you are at it. Players reach a shop by clicking a +[FancyNpcs](https://modrinth.com/plugin/fancynpcs) NPC, and every sale is recorded in the transaction log and totalled +in a sales view. + +**An admin panel.** `/tt admin` opens a menu that reads the server back to you. The economy section shows how much +currency exists and who holds it, what created it and what removed it — new players, shops, Towny, administrators or +another plugin — with the net drift per day, how unevenly wealth is spread, how fast money circulates, and a chart of +the window drawn as columns. Read any of it over the last day, week or month, or over everything on record. Every +shop's takings are in there too, next to the economy they act on. + **English and Simplified Chinese.** Every message, menu and item TriTown shows is translated. By default each player sees whichever of the two their Minecraft client is set to, and everyone else sees English. Set `language` in `config.yml` to `en_US` or `zh_CN` to pick one for the whole server, or edit the files in `plugins/TriTown/lang/` to @@ -41,10 +55,12 @@ built on. | Java | 25+ | | Towny | 0.103.2.7+ | | Vault | 1.7+ | +| FancyNpcs | 2.9+ — optional, for shop NPCs | | Economy plugin | Not required — TriTown provides one | TriTown is an addon: Towny and Vault must both be installed, or TriTown will not load. An economy plugin is optional — install one only if you want it to supply the economy instead of TriTown, and set `economy.provider.mode` accordingly. +FancyNpcs is optional too: without it shops still work, they just cannot be opened by clicking an NPC. ## Building @@ -58,6 +74,8 @@ first start: - download a Paper 26.2 jar from [papermc.io](https://papermc.io/downloads/paper) into `run/`; - put Vault into `run/plugins/`; +- put [FancyNpcs](https://modrinth.com/plugin/fancynpcs) into `run/plugins/` as well, to test shop NPCs — only its API + is published to Maven, so the plugin itself is not fetched by the build; - accept the EULA in `run/eula.txt` after the first launch. Prebuilt jars are attached to every [GitHub release](https://github.com/Trilleo/TriTown/releases). @@ -73,12 +91,22 @@ Prebuilt jars are attached to every [GitHub release](https://github.com/Trilleo/ | `/baltop [page]` | List the richest accounts | | `/eco …` | Administer balances (OP only) | | `/tt scoreboard` | Show or hide the sidebar | +| `/tt shop …` | Set up the server's shops (OP only) | +| `/tt admin [section]` | Open the admin panel (OP only) | `/eco` takes `give`, `take` and `set` (` [currency]`), `reset ` back to the starting balance, `info ` for an account's details, `history [player]` to browse recorded transactions in a menu, and `flush` to write changed accounts to disk immediately. Each action has its own permission, `tritown.economy.admin.`; viewing someone else's history additionally needs `tritown.economy.admin.history.others`. +`/tt shop` takes `list` for every shop, `create [name]` to start one, `delete confirm` to remove one, +`edit ` to change what it offers, `open [player]` to open it for somebody, `bind ` and +`unbind ` to put an NPC behind the counter, and `stats ` for what it has traded. Each action has its own +permission, `tritown.shop.admin.`. Players have no shop command of their own — they click an NPC. + +`/tt admin` opens the panel itself, and `economy` or `shops` opens that section directly. Opening the panel needs +`tritown.admin`; the sections need `tritown.admin.economy` and `tritown.admin.shops` on top of it. + Commands are sub-commands of `/tritown` (alias `/tt`) unless noted. The economy commands are registered as top-level commands as well, which `economy.commands.top-level-aliases` turns off — they are then only reachable as `/tt balance`, `/tt pay` and `/tt baltop`. If another plugin already owns one of those names it keeps it, and TriTown's @@ -115,6 +143,13 @@ version stays available as `/tritown:balance` and so on. | `economy.history.retention-days` | `30` | How long rolled log files are kept; `0` keeps them forever | | `economy.history.roll-size-mb` | `16` | Size at which the transaction log is rolled aside | | `economy.history.time-format` | `yyyy-MM-dd HH:mm` | How timestamps are shown in the history view | +| `economy.stats.enabled` | `true` | Keep the hourly figures the admin panel reads | +| `economy.stats.retention-days` | `30` | How far back those figures reach; `0` keeps them forever | +| `shops.enabled` | `true` | Turn shops off entirely | +| `shops.save-interval` | `60` | Seconds between writing stock and sales figures; edits are saved immediately | +| `shops.confirm-above` | `1000.0` | Purchase total that asks for confirmation first; `0` never asks | +| `shops.sell-rate` | `0.5` | What the editor suggests as a payout, as a fraction of the buy price | +| `shops.discounts.` | `0.0` | Money off for `has-town`, `has-nation`, `is-mayor` or `is-king` | | `scoreboard.enabled` | `true` | Turn the sidebar off entirely | | `scoreboard.refresh-interval` | `2` | Seconds between redraws of a sidebar nothing has changed on | | `scoreboard.default-on` | `true` | Whether a player who has never used `/tt scoreboard` sees one | @@ -132,6 +167,44 @@ balance on disk is read. Changing it once accounts exist stops the plugin with a its economy with Vault, and its commands with the server, before either can be changed again. Everything else in the table is applied by `/tt reload`. +### Shops + +A shop is created with `/tt shop create `, which opens its editor. Everything else is done in the menus: + +- **Adding what it sells.** Click an item in your own inventory, or drag it over the menu. Nothing leaves your + inventory — the item is copied, with every property it has, so a renamed and enchanted sword goes on the shelf as + that exact sword. The stack size you click becomes the bundle: click a stack of 16 bread and one purchase is 16 + loaves. The bundle can be changed afterwards, and is not limited to a stack — 128 bread is handed over as two. +- **Arranging it.** Entries are shown to players in the order they are in the editor. Right-click one to pick it up, + then click where it should go — including on another page — and it drops in front of whatever you clicked; two + buttons send it to the front or the back of the shop instead. A whole shop can be put in order at once by name or by + price, which replaces the arrangement you made by hand and asks before it does. +- **Pricing it.** An entry has a buy side and a sell side, and each may be switched on or off on its own. Either side + can ask for money, for items, or for both at once. Money is typed in chat when you click the price; items are added + by clicking them in your inventory, and the stack size is the quantity. +- **Limiting it.** *Stock* is shared by everybody and refills to full on a timer. A *limit* is per player and resets + daily, weekly, or never. Both are optional, and an entry with neither is unlimited, which is what an admin shop + usually wants. +- **Locking it.** A shop, and each entry inside it, can require a permission node or a standing in Towny — being in a + town, being without one, being in a nation, being a mayor or being a king. A locked entry shows the reason by + default, or can be hidden entirely. + +`shops.discounts` takes money off for players who have earned it. Discounts do not stack: a mayor whose nation also has +a rate pays the better of the two, and the menu shows the old price struck through beside the new one. An individual +entry can opt out. + +Players never type a shop command. Bind an NPC with `/tt shop bind ` and clicking it opens the shop. The +binding is stored against the NPC itself rather than its name, so renaming it in FancyNpcs changes nothing, and an NPC +opens one shop at a time — binding it again moves it. + +The goods a shop sells are created and the money paid for them leaves the economy, so a shop is a sink, a faucet, or +both depending on how you price it. `/tt shop stats ` shows which, per entry and in total. Every trade is recorded +in the transaction log and appears in `/eco history` as a shop movement naming the shop. + +Shops live in `plugins/TriTown/shops/shops.json`, written atomically with a `.bak` copy beside it. A shop you edit is +written straight away; stock levels and sales figures are written every `shops.save-interval` seconds, so a crash costs +at most that long of counters and never a shop. + ### The sidebar Each board under `scoreboard.boards` has a `priority`, a `condition`, and a list of `lines`. A player sees the @@ -220,7 +293,8 @@ Full development guides are in the `docs/` directory: - [DEVELOPER_GUIDE.md](docs/DEVELOPER_GUIDE.md) — How to add commands, listeners, GUIs, tasks, items, and recipes, translate every string, use the configuration and data storage, and build on Towny and the Vault economy. - [UTILITY_GUIDE.md](docs/UTILITY_GUIDE.md) — Reference for the utility helpers (`itemStack` DSL, `Lang`, - `EconomyUtil`, `MessageUtil`, `CountdownUtil`, `TeamUtil`, `TagUtil`, `PDCUtil`, `GameRuleUtil`, `LoreUtil`). + `EconomyUtil`, `MessageUtil`, `ChatPrompt`, `CountdownUtil`, `TeamUtil`, `TagUtil`, `PDCUtil`, `GameRuleUtil`, + `LoreUtil`). - [COMMIT_STRUCTURE.md](docs/COMMIT_STRUCTURE.md) — Commit message conventions. - [RELEASING.md](docs/RELEASING.md) — Writing the changelog and publishing a release. diff --git a/build.gradle.kts b/build.gradle.kts index 813b5c1..bde806f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,6 +23,9 @@ repositories { maven { url = uri("https://jitpack.io") } + maven { + url = uri("https://repo.fancyinnovations.com/releases") + } } val serverPlugins: Configuration by configurations.creating { @@ -30,11 +33,16 @@ val serverPlugins: Configuration by configurations.creating { } dependencies { - compileOnly("io.papermc.paper:paper-api:26.2.build.+") + compileOnly("io.papermc.paper:paper-api:26.3.build.+") compileOnly("com.palmergames.bukkit.towny:towny:${providers.gradleProperty("towny_version").get()}") compileOnly("com.github.MilkBowl:VaultAPI:${providers.gradleProperty("vault_api_version").get()}") { isTransitive = false } + // API only, and optional at runtime: NPCs open shops when FancyNpcs is installed, and the feature stays off when + // it is not. The plugin jar itself is not on Maven and has to be installed by hand. + compileOnly("de.oliver:FancyNpcs:${providers.gradleProperty("fancynpcs_version").get()}") { + isTransitive = false + } serverPlugins("com.palmergames.bukkit.towny:towny:${providers.gradleProperty("towny_version").get()}") testImplementation(kotlin("test")) // Aligned with what Paper 26.2 bundles, since the plugin uses these at runtime through paper-api. @@ -74,6 +82,7 @@ tasks.jar { } // Copies Towny into the test server, replacing any other Towny version left behind by a version bump. +// FancyNpcs is not copied: only its API is published to Maven, and the plugin itself comes from Modrinth. tasks.register("copyServerPlugins") { val pluginsDir = layout.projectDirectory.dir("run/plugins") doFirst { delete(fileTree(pluginsDir) { include("towny-*.jar") }) } @@ -92,7 +101,7 @@ tasks.register("startServer") { workingDir(layout.projectDirectory.dir("run")) classpath(fileTree(layout.projectDirectory.dir("run")) { include("paper-*.jar") }) doFirst { - check(!classpath.isEmpty) { "No paper-*.jar in run/. Download Paper 26.2 from https://papermc.io/downloads/paper into run/." } + check(!classpath.isEmpty) { "No paper-*.jar in run/. Download Paper 26.3 from https://papermc.io/downloads/paper into run/." } } args("--nogui") standardInput = System.`in` diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 884f52b..c8e6a33 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -2,7 +2,7 @@ This guide explains how to create **commands**, **listeners**, **GUIs**, **tasks**, **custom items**, **recipes**, work with **translations** and the **configuration** system using TriTown's registration system, and how to build on -**Towny**, the **Vault economy**, and the **sidebar**. Commands, listeners, GUIs, tasks, custom items, and recipes all +**Towny**, the **Vault economy**, the **admin panel** and the **sidebar**. Commands, listeners, GUIs, tasks, custom items, and recipes all follow the same pattern: extend a base class (or implement an interface), place the file in the correct package, and the plugin handles the rest automatically at startup. The configuration system provides typed access to `config.yml` values. @@ -329,8 +329,31 @@ a count, and translate the key yourself there. | `setup` | Yes | Populate the inventory with items before it opens | | `title` | No | Build the title yourself when `titleKey` alone is not enough | | `onClick` | No | Handle click events (clicks are cancelled by default) | +| `onDrag` | No | Handle drag events (drags are cancelled by default) | | `onClose` | No | Handle cleanup when the GUI is closed | +`onClick` and `onDrag` both cancel by default, so a GUI cannot be used to take items out of it. Override either one only +when the menu reads what was clicked or dragged, and cancel the event there too unless the slot genuinely accepts it. + +A GUI that wants a click in the player's *own* inventory — to copy an item out of it, say — has to take it before the +base class does, because `PagedPluginGUI.onClick` ignores anything outside its own inventory: + +```kotlin +override fun onClick(event: InventoryClickEvent) { + val player = event.whoClicked as? Player + if (player != null && event.clickedInventory === player.inventory) { + event.isCancelled = true + event.currentItem?.let { copyIntoMenu(it) } + return + } + super.onClick(event) +} +``` + +Never open another inventory from inside a click handler: the click is still being delivered, and the server and client +end up disagreeing about what is on screen. Use `GUIManager.openLater(player, id)`, which opens it on the following +tick. + ### Opening a GUI Use `GUIManager.open(player, id)` to open a registered GUI for a player: @@ -340,6 +363,9 @@ import net.trilleo.mc.plugins.tritown.registration.GUIManager // Returns true if the GUI was found and opened, false otherwise GUIManager.open(player, "settings") + +// From inside a click handler, so the client is not left disagreeing about what is on screen +GUIManager.openLater(player, "settings") ``` ### Example @@ -429,6 +455,46 @@ For example, a 6-row GUI provides 45 content slots per page (rows 1–5). | `rows` | `Int` | `6` | Number of rows (2–6, each row = 9 slots) | | `fillMode` | `FillMode` | `FillMode.NONE` | Controls background filler; re-applied on every page render, not just initial open | | `mode` | `PagedGUIMode` | `PagedGUIMode.LIST` | Controls how items are supplied — see [Modes](#modes) below | +| `layout` | `PagedLayout` | `PagedLayout.FULL` | Whether the content area fills the menu or sits inside a border | + +### Layouts + +| Layout | Content slots (6 rows) | Description | +|:---------------------|:-----------------------|:----------------------------------------------------------------------------| +| `PagedLayout.FULL` | 45 | Every slot above the navigation row is content | +| `PagedLayout.FRAMED` | 28 | Content is inset by one slot on every side, with a black glass border round it | + +A framed menu carries the border into its navigation row too, so the whole edge is one colour rather than changing +where the controls start. + +**Do not index `getItems` by the raw slot.** Under a framed layout a slot is not a position in that list, because the +border sits between them. Use `contentIndex(page, rawSlot)`, which returns the position or `null` when the slot holds +no content: + +```kotlin +override fun onContentClick(event: InventoryClickEvent, page: Int) { + event.isCancelled = true + val index = contentIndex(page, event.rawSlot) ?: return + val entry = entries.getOrNull(index) ?: return + … +} +``` + +`pageSize` is how many items one page holds, should a subclass need it. + +**Redraw in place with `refresh(player, inventory)`** when a click changes what the menu shows — an entry removed, an +item moved. Reopening the menu to show the change puts the viewer back on the first page of whatever they were part-way +through, which is exactly wrong for a menu being edited page by page. `refresh` clamps the page too, so the last item +leaving a page steps back rather than showing an empty one: + +```kotlin +override fun onContentClick(event: InventoryClickEvent, page: Int) { + event.isCancelled = true + val index = contentIndex(page, event.rawSlot) ?: return + entries.removeAt(index) + refresh(event.whoClicked as Player, event.inventory) +} +``` ### Modes @@ -437,15 +503,19 @@ For example, a 6-row GUI provides 45 content slots per page (rows 1–5). | Mode | Override | Description | |:--------------------|:--------------|:-------------------------------------------------------------------------------------------------| | `PagedGUIMode.LIST` | `getItems` | Items are provided as a flat list and distributed automatically across pages (one item per slot) | -| `PagedGUIMode.SET` | `getSetItems` | Items are placed manually by page and slot, giving full control over each item's exact position | +| `PagedGUIMode.SET` | `getSetItems` | Items are placed manually by page and position, giving full control over each item's placement | ### Methods to Override | Method | Mode | Required | Description | |:-----------------|:-------|:---------|:-----------------------------------------------------------------| | `getItems` | `LIST` | Yes | Return the full list of items to paginate for a player | -| `getSetItems` | `SET` | Yes | Return a map of `page → (slot → item)` for manual placement | +| `getSetItems` | `SET` | Yes | Return a map of `page → (position → item)` for manual placement | | `onContentClick` | Both | No | Handle clicks on content slots (clicks are cancelled by default) | +| `navButtons` | Both | No | Buttons to place in the navigation row, keyed by offset | +| `onNavClick` | Both | No | Handle clicks on those buttons | + +A `SET` position is an index into the content area, not an inventory slot, for the same reason `contentIndex` exists. You do **not** need to override `setup`, `onClick`, or `onClose` — `PagedPluginGUI` handles them internally for pagination. If you need custom close logic, override `onClose` and call `super.onClose(event)` to ensure page state is @@ -461,6 +531,22 @@ The last row of the inventory contains: | 4 | Paper | **Page indicator** — displays "Page X/Y" | | 8 | Arrow | **Next Page** — hidden on the last page | +Offsets 1, 2, 3, 5, 6 and 7 are free, and `navButtons` puts a GUI's own actions there: + +```kotlin +override fun navButtons(player: Player): Map = mapOf( + 2 to itemStack(Material.COMPARATOR) { name(player.tr("gui.rewards.settings")) }, +) + +override fun onNavClick(event: InventoryClickEvent, offset: Int) { + if (offset == 2) openSettings(event.whoClicked as? Player ?: return) +} +``` + +**Put an action here rather than at the end of the content.** A button appended to the item list moves every time the +list grows, so the thing an administrator clicks most sits somewhere new after every edit. The navigation row never +moves. Anything placed at a reserved offset is ignored. + ### Example (LIST mode) ```kotlin @@ -1764,10 +1850,14 @@ data.set("kills", kills + 1) | `getDouble` | `getDouble(key, default = 0.0)` | Returns a `Double` value | | `getBoolean` | `getBoolean(key, default = false)` | Returns a `Boolean` value | | `getJsonArray` | `getJsonArray(key)` | Returns a `JsonArray` value, or an empty `JsonArray` when absent | +| `getJsonObject`| `getJsonObject(key)` | Returns a `JsonObject` value, or an empty `JsonObject` when absent | | `set` | `set(key, value)` | Stores a `String`, `Int`, `Double`, `Boolean`, `JsonArray`, or `JsonObject` | | `remove` | `remove(key)` | Removes the entry at `key` | | `has` | `has(key)` | Returns `true` when `key` exists | +`getJsonObject` hands back the stored object rather than a copy, so writing to it writes through; call `set` afterwards +anyway, because a key that was absent comes back as a fresh object that nothing is holding. + ### Custom Subclass Extend `PlayerData` to add strongly-typed Kotlin properties: @@ -1860,10 +1950,14 @@ data.set("eventCount", events + 1) | `getDouble` | `getDouble(key, default = 0.0)` | Returns a `Double` value | | `getBoolean` | `getBoolean(key, default = false)` | Returns a `Boolean` value | | `getJsonArray` | `getJsonArray(key)` | Returns a `JsonArray` value, or an empty `JsonArray` when absent | +| `getJsonObject`| `getJsonObject(key)` | Returns a `JsonObject` value, or an empty `JsonObject` when absent | | `set` | `set(key, value)` | Stores a `String`, `Int`, `Double`, `Boolean`, `JsonArray`, or `JsonObject` | | `remove` | `remove(key)` | Removes the entry at `key` | | `has` | `has(key)` | Returns `true` when `key` exists | +`getJsonObject` hands back the stored object rather than a copy, so writing to it writes through; call `set` afterwards +anyway, because a key that was absent comes back as a fresh object that nothing is holding. + ### Custom Subclass Extend `ServerData` to add strongly-typed Kotlin properties: @@ -2180,6 +2274,154 @@ at most one flush interval of changes. An SQL backend writing through on each tr --- +## Economy Statistics + +`EconomyPulse` is what the [admin panel](#admin-panel) reads. It answers two questions that need two different +mechanisms, which is why it keeps two things rather than one: + +| Kept | How | Answers | +|:-------------|:--------------------------------------------------------|:-------------------------------------------------| +| **Buckets** | Accumulated per hour as transactions happen | What created and destroyed currency, and what for | +| **Samples** | The whole ledger measured on the flush task | How much exists and who holds it, exactly | + +The supply is **measured, never accumulated**. Adding movements up would drift the first time anything moved money +without TriTown recording it; walking the accounts cannot. + +### What counts as what + +* A **deposit** is money entering the economy — it is created. +* A **withdrawal** is money leaving it — it is destroyed. +* A **transfer** moves money between two accounts and changes nothing, so it is counted separately as *circulation*, + and only the `TRANSFER_OUT` side is counted or every payment would show up at twice its size. +* A **set** carries how far a balance moved but not which way, so it is kept apart as an *adjustment* rather than + guessed at. + +Towny moves a bank deposit through Vault as a withdrawal from the player and a deposit into the town, not as a +transfer, so it lands on **both** gross sides and cancels in the net. The net — overall and per category — is the +number worth reading, and the panel says so on the card. + +### Categories + +`FlowCategory` groups a movement by what it was for. It exists because a transaction's `reason` carries arguments +(`money.reason.admin-set?admin=Bob`), so totalling by reason would produce a row per administrator. A category is +bounded, stable and translatable through `money.flow.*`, and `FlowCategory.of(source, reason)` is the only place the +mapping lives. + +### Wiring a new feature in + +**Every feature that moves money has to reach these figures.** A movement nothing claims is filed as +`FlowCategory.EXTERNAL` — "Other plugins" — so a new faucet or sink that skips this makes the panel quietly wrong +about where the server's currency comes from. + +Say a daily reward is being added. Four steps, and only the first two are about the reward itself: + +**1. Move the money through `EconomyUtil`, naming where it came from and why.** Writing a balance any other way is +invisible to `EconomyService.record`, and therefore to everything here. + +```kotlin +EconomyUtil.deposit(player, amount, EconomyContext.SOURCE_COMMAND, TransactionReason.DAILY_REWARD) +``` + +**2. Give the reason a key**, in `TransactionReason`, with the wording in both language files. The key is what is +written to the transaction log, so it stays readable whatever language the server is later set to. + +```kotlin +const val DAILY_REWARD = "money.reason.daily-reward" +``` + +**3. Give it a category**, unless an existing one already describes it. A faucet or sink of its own — a payout, a +reward, a repair fee, a lottery — earns one; a variation on something already grouped does not. + +```kotlin +// enums/FlowCategory.kt +DAILY_REWARD, // the constant + +DAILY_REWARD -> "money.flow.daily-reward" // in `key`, spelled out for the language test + +key == TransactionReason.DAILY_REWARD -> DAILY_REWARD // in `of`, before the source fallbacks +``` + +Add `money.flow.daily-reward` to both language files. A real faucet must never be left landing in `OTHER`. + +**4. Decide whether the panel should name it.** The full breakdown (`EconomyFlowGUI`) picks a new category up on its +own and needs nothing; a card of its own in `EconomyPanelGUI` is for a source big enough that an owner wants it on the +first screen. Give a new category a `descriptionOf` line either way, so the breakdown can say what it is. + +Nothing in `EconomyPulse` changes for any of this — that is the point of feeding it from `EconomyService.record`. + +### Recording + +`EconomyService.record` files every transaction, and hands it to `EconomyPulse` before the history log — so the +figures are kept even when `economy.history.enabled` is off. Recording must stay cheap and lock-free: it runs on +whichever thread moved the money, including Towny's. The counters are `LongAdder`s in concurrent maps, and nothing +here touches the disk. + +Only the primary currency is counted. A movement in any other currency is ignored rather than added to a total whose +minor units mean something else. + +### Sampling and storage + +`EconomyFlushTask` measures the ledger before each flush, next to the leaderboard rebuild, because both walk every +account and sorting belongs off the server thread. `EconomyService.start` and `shutdown` take one measurement each, so +the panel has something to show before the first flush and the supply after a restart is the one the server stopped +with. + +Figures live in `plugins/TriTown/economy/statistics.json`, written by `JsonPulseStorage` through a temporary file in +the same way balances are. There is deliberately **no backup copy**: statistics are worth keeping but nobody's money +depends on them, and a file that cannot be read simply starts the history again. A file written in another currency is +dropped rather than adopted. + +`economy.stats.enabled` turns the whole thing off; `economy.stats.retention-days` prunes buckets and samples older +than it. + +### Reading + +```kotlin +val flow = EconomyPulse.window(hours = 24, slices = 7) // 0 hours means everything still kept +flow.created // minor units that entered the economy +flow.netOf(FlowCategory.SHOP) // what the shops did to the supply +flow.slices // one column per slice, for a chart + +val supply = EconomyPulse.latest() // the last measurement, or null before the first one +val before = EconomyPulse.sampleAt(flow.from) // the measurement the window opened on +``` + +Amounts are **minor units** throughout, exactly as the ledger holds them; they become text only at the menu, through +`PanelRender`. + +--- + +## Admin Panel + +The panel lives in `guis/admin` and is opened by `/tritown admin` (`commands/admin/AdminCommand`). It is a reading +surface: apart from writing the economy to disk on request, nothing in it changes the server. + +| Menu | Id | Shows | +|:-------------------|:-----------------|:-------------------------------------------------------------------| +| `AdminPanelGUI` | `admin-panel` | The sections, each with enough of itself to say whether to open it | +| `EconomyPanelGUI` | `admin-economy` | Supply, accounts, distribution, faucets, sinks, net, and the chart | +| `EconomyFlowGUI` | `admin-flow` | Every category and every kind of account, in full | +| `AdminShopsGUI` | `admin-shops` | Every shop's takings, opening into that shop's own figures | + +Adding a section means adding a card to `AdminPanelGUI` and a menu of its own — nothing else in the panel changes. + +**The window belongs to the viewer, not to a menu.** `PanelState` holds which of `StatsWindow.DAY`, `WEEK`, `MONTH` +or `ALL` each administrator is looking at, so switching it in the overview and then opening the breakdown does not +quietly go back to the last day. It is dropped when they quit (`listeners/admin/PanelStateListener`) rather than when +a menu closes, because opening the next menu closes the last one. + +**`PanelRender` is the only place a figure becomes text** — money, percentages, rates, timestamps, category and +account-type names, and the cards themselves — so the same number reads the same wherever it appears. + +**The chart is stack sizes.** Each of the seven columns is a stained-glass pane whose stack size is its net change +next to the largest one, green where the supply grew and red where it shrank. It reads as a chart at a glance without +a single custom texture, and the exact figures are in the lore. + +Permissions: `tritown.admin` opens the panel, `tritown.admin.economy` and `tritown.admin.shops` open the sections. A +card the viewer may not open is not drawn at all. + +--- + ## Economy (Vault) Vault is a hard dependency (`depend` in `plugin.yml`); the Vault API is `compileOnly` (`vault_api_version` in @@ -2294,6 +2536,171 @@ get it later. --- +## Shops + +Admin shops: shops the server itself runs, defined in game and opened by clicking an NPC. The goods are created and the +money paid for them leaves the economy, so there is no shop account behind a shop holding either. + +Everything lives under `shops/`, which is **not a scanned package** — for the same reason `economy/` is not. The +manager has to be alive before the registrars build the menus and commands that read it. + +### The model + +| Type | What it is | +|:-----------------|:---------------------------------------------------------------------------------------------| +| `ShopDefinition` | One shop: `id`, `displayName`, a `ShopGate`, its entries, and the FancyNpcs ids bound to it | +| `ShopEntry` | One line of goods: the `ItemStack`, a buy `ShopCost`, a sell `ShopCost`, gate, limit, stock | +| `ShopCost` | A price or a payout: an amount of money, a list of `ItemStack`s, or both | +| `ShopGate` | A permission node and a `TownyRequirement`, plus whether a locked entry hides | +| `ShopLimit` | How much one player may buy per `LimitPeriod` window | +| `ShopStock` | A shared supply that refills to full on a timer | +| `ShopStats` | Bundles traded and currency moved, per entry | + +`id` is stable and is what NPC bindings and purchase counters are keyed by; `displayName` is administrator-written +MiniMessage and can be changed freely. Entry ids are UUIDs, so reordering or renaming never disturbs a counter. + +`bundle` is how many items one purchase moves, and it is deliberately **not** the template's stack size: a bundle may +exceed what a stack holds, and an `ItemStack` is not a safe place to keep a count of 128. `bundleSize`, +`displayStack()` and `goodsStacks(bundles)` are the three ways to ask about it — the last splits into stacks the game +allows, which is what is both measured for room and handed over, while the first two are for drawing. + +### Preserving an item + +`ItemCodec` wraps Paper's `ItemStack.serializeAsBytes()` / `deserializeBytes()` and Base64s the result. That is the only +round-trip that keeps every data component, so a renamed, enchanted, custom-model or plugin-invented item comes back +exactly as it went in, and Paper upgrades the embedded game version when Minecraft moves on. + +`ItemCodec.decode` returns `null` rather than throwing. One unreadable entry must not take a whole shop with it. + +### Storage + +`plugins/TriTown/shops/shops.json`, written by `JsonShopStorage` through a temporary file with the previous copy kept +as `.bak` — the same approach as `JsonEconomyStorage`. A file that will not parse falls back to the backup rather than +starting empty, because an empty start would be written back over the real data at the next save. + +The storage layer works on `StoredShop` / `StoredEntry` / `StoredCost`, which hold Base64 strings rather than +`ItemStack`s. That keeps it free of Bukkit and therefore testable without a server; `ShopManager` converts between +the stored and live shapes. + +Saving has two speeds, and the difference matters: + +| Call | When | Cost | +|:-----------------------|:-------------------------------------------------|:-----------------------------------------| +| `ShopManager.save()` | A definition changed — an edit that must not be lost | Writes the whole file now | +| `ShopManager.markDirty()` | Stock or statistics changed on a purchase | Nothing; `ShopSaveTask` flushes it later | + +### Trading + +`ShopTrade.buy` and `ShopTrade.sell` are the only places a trade happens, and both run on the server thread because +they touch an inventory. The ordering is what makes them safe: **everything that can refuse is asked before anything is +taken, and anything taken is remembered so it can be put back.** + +A buy, in order: + +1. Re-check the gate, the per-player limit and the stock, restocking lazily first. +2. Quote the price, applying the best discount the player's standing in Towny earns. +3. Check there is room for the goods. +4. Take the item side of the price, keeping what was removed. +5. Take the stock. +6. `EconomyUtil.withdraw(player, money, EconomyContext.SOURCE_SHOP, reason)` — on refusal, put the stock and the items + back and stop. +7. Hand over the goods, record the purchase against the player's limit, and update the statistics. + +A sell is the mirror image. Never check `has` and withdraw separately — `EconomyUtil.withdraw` does both in one step. + +The pricing, limit and stock arithmetic is deliberately free of Bukkit (`ShopPricing`, `ShopLimit`, `ShopStock`) so it +can be unit-tested, in the same way `EconomyLedger` is. + +### Attribution + +A shop movement must not appear in `/eco history` as an anonymous Vault call, so it goes through the attributed +overloads of `EconomyUtil`: + +```kotlin +val reason = TransactionReason.of(TransactionReason.SHOP_BUY, "shop" to shop.displayName) +EconomyUtil.withdraw(player, quote.money, EconomyContext.SOURCE_SHOP, reason) +``` + +`EconomyContext` is a thread-local, so the attribution reaches the record through the Vault provider on the same thread +without feature code ever naming `EconomyService`. Another plugin's economy keeps no such record and ignores it. + +### Access + +`ShopAccess` is the only place Towny is read, and it is read fresh on every check — a player who joins a town sees the +town's prices without relogging. `standing(player)` is called once per menu render rather than once per entry, because +every entry asks the same questions. + +Gate permissions are written by whoever set the shop up, so they cannot be registered at startup the way a command's +nodes are. They are checked as they stand and defined in the server's permissions plugin. + +### Per-player limits + +Counters live in the buyer's own `PlayerData` under `shop-limits`, keyed `"/"`, each holding a count +and the window it belongs to. A count from a window that has turned over is ignored rather than cleared, so nothing has +to sweep counters at midnight. `PlayerDataManager` only serves online players, which is the only case a purchase needs. + +### FancyNpcs + +FancyNpcs is a soft dependency, and the isolation that makes that work is worth understanding before changing it: + +- **`listeners/shop/ShopNpcListener`** is the only class naming a FancyNpcs type in a signature. `PackageScanner` + catches `NoClassDefFoundError` and skips a class it cannot load, so without FancyNpcs this listener simply never + registers. +- **`shops/npc/FancyNpcsAdapter`** is the only other class touching the API. It is `internal` and is reached solely + through `ShopNpcBridge`, so the JVM never resolves it on a server without the plugin. +- **`shops/npc/ShopNpcBridge`** exposes `List` and `String?` and nothing else. Anything that would put a + FancyNpcs type in its signatures would take `ShopCommand` down with it. + +Bindings store `NpcData.getId()`, not the name, so renaming an NPC changes nothing. An NPC opens one shop: binding it +again moves it rather than leaving it ambiguous. + +### The menus + +Every shop menu is in `guis/shop/`, and they all follow the singleton rules the GUI section sets out: which shop is open +is held per viewer in a `ConcurrentHashMap` and cleared in `onClose`, and a paged menu builds its items once +rather than in `getItems`. + +| Menu | What it does | +|:-------------------|:------------------------------------------------------------------------| +| `ShopGUI` | The player's view; buys and sells, and redraws only the entry traded | +| `ShopConfirmGUI` | A second look above `shops.confirm-above`; re-quotes on accept | +| `ShopListGUI` | Every shop, for an administrator | +| `ShopEditorGUI` | One shop's entries; adds one from the administrator's own inventory | +| `ShopEntryGUI` | One entry's prices, limit, stock and gate | +| `ShopCostGUI` | The item side of a price or a payout | +| `ShopSortGUI` | Puts a whole shop in one order, on an administrator's say-so | +| `ShopSettingsGUI` | A shop's name, gate and bound NPCs | +| `ShopStatsGUI` | What a shop has traded | + +Every one of them is framed: the paged menus through `PagedLayout.FRAMED`, and `ShopCostGUI`, which lays out its own +grid, through `GUIFrame` directly. The editor's actions — add, settings, figures, back — live in the navigation row via +`navButtons`, so they do not shuffle along as entries are added. + +`ShopRender` holds what they all draw with — item names, price lines, requirement names — and `ShopRender.navigate`, +which opens the next menu on the following tick. + +**Items are never taken to add them.** Clicking a stack in the administrator's own inventory copies it and cancels the +event; a drag reads `event.oldCursor` and cancels too. A live slot would lose the item to a crash or a mistimed close, +and an administrator setting up a shop is usually holding the only copy of what they are adding. + +**Nor to reorder them.** The order of `ShopDefinition.entries` is the order players see, and the editor rearranges it +with a mark rather than a cursor: a right click writes the entry's id into the editor's `moving` map, the next click on +a slot says where it goes, and the entry always lands immediately before whatever was clicked. A stack held on the +cursor would not survive turning the page, and the same live-slot objection applies as for adding. While an entry is +marked the navigation row carries the move's own controls — the held entry, which puts it back down, and the two ends of +the shop — in the slots the editor's usual actions occupy, and clicks in the administrator's own inventory do nothing +so that a move cannot end in an accidental new entry. Every move redraws the page in place through +`PagedPluginGUI.refresh`, so a shop can be rearranged across pages without being thrown back to the first one. + +**`ShopSorting` is the only thing that sorts a shop**, and `ShopSortGUI` asks before it runs: an order is chosen, then +applied, because sorting overwrites an arrangement made by hand and nothing records how it was reached. An entry with +no buy price sorts last whichever way the prices run, and item names are compared in the server's own words rather than +each viewer's, because one shop has one order and it cannot depend on who is looking at it. + +Anything free-form — a price, a permission node, a shop's name — is asked for in chat through `ChatPrompt`, because +a chest menu has nowhere to type and a price of 12500 is not somewhere to click. + + ## Sidebar The sidebar lives in `net.trilleo.mc.plugins.tritown.scoreboard`, which — like `economy` — is **not** one of the diff --git a/docs/UTILITY_GUIDE.md b/docs/UTILITY_GUIDE.md index 54d31e4..bca139b 100644 --- a/docs/UTILITY_GUIDE.md +++ b/docs/UTILITY_GUIDE.md @@ -11,6 +11,7 @@ reduce boilerplate and provide commonly needed functionality out of the box. | `TagUtil` | Per-player string tag management with player-data persistence | | `Lang` | Translations: per-player language files and the `tr()` helper | | `MessageUtil` | Prefix-decorated message sender for any command sender | +| `ChatPrompt` | Asks a player a question in chat and hands the answer back | | `EconomyUtil` | Economy access: balances, withdrawals, deposits, transfers, formatting | | `PDCUtil` | Persistent data container helpers for Entity, Chunk, and ItemStack | | `GameRuleUtil` | Convenient get, set, and toggle helpers for Minecraft game rules | @@ -504,6 +505,8 @@ EconomyUtil.deposit(player, 100.0) | `has(player, amount)` | Whether the player has at least `amount`. | | `withdraw(player, amount)` | Takes `amount`; returns `false` without charging if the player cannot afford it. | | `deposit(player, amount)` | Gives `amount`; returns `false` if the provider refuses. | +| `withdraw(…, source, reason)`| Takes `amount` and records where it went and why. | +| `deposit(…, source, reason)` | Gives `amount` and records where it came from and why. | | `transfer(from, to, amount)` | Moves `amount` between two players. | | `format(amount)` | Formats `amount` as plain text, the way other plugins print it. | | `formatRich(amount)` | Formats `amount` as a `Component`, using the configured MiniMessage pattern. | @@ -519,6 +522,68 @@ refunds the sender if the deposit fails. Use `format` for anything another plugin will print — it must never contain MiniMessage tags — and `formatRich` for TriTown's own messages. +**Attribute every movement.** The four-argument `withdraw` and `deposit` attach a source and a reason to the +transaction, which is what puts it in `/eco history` as something other than an anonymous Vault call *and* what lets +the admin panel say where the server's currency comes from. A movement that claims nothing is counted as another +plugin's, so any feature that moves money uses these rather than the two-argument pair or a reach past `EconomyUtil`: + +```kotlin +val reason = TransactionReason.of(TransactionReason.SHOP_BUY, "shop" to shop.displayName) +EconomyUtil.withdraw(player, price, EconomyContext.SOURCE_SHOP, reason) +``` + +The attribution travels on the calling thread, so it reaches the record without this having to know which provider won. +Another plugin's economy keeps no such record and ignores it. + +A new kind of movement also needs its reason grouped, or the figures file it under "Other plugins" — see +[Wiring a new feature in](DEVELOPER_GUIDE.md#wiring-a-new-feature-in). + +--- + +## ChatPrompt + +A chest menu has nowhere to type, so anything free-form an editor needs — a name, an exact price, a permission node +— is asked for in chat instead. `ChatPrompt` closes that loop: it sends the question, waits for the next thing the +player types, and hands it back. + +The answer arrives on the server thread, so a callback may touch Bukkit freely. Typing `cancel`, quitting, or being +asked something else instead drops the pending question without running the callback, and the answer never reaches the +chat channel. + +### Usage + +```kotlin +import net.trilleo.mc.plugins.tritown.utils.ChatPrompt +import net.trilleo.mc.plugins.tritown.utils.tr + +player.closeInventory() +ChatPrompt.ask(player, player.tr("gui.shop-entry.prompt-price")) { input -> + val price = input.toDoubleOrNull() + if (price == null) { + player.sendPrefixed(player.tr("common.error", "message" to player.tr("common.invalid-amount"))) + } else { + entry.buy = ShopCost(price) + } + ShopEntryGUI.show(player, shop, entry) +} +``` + +Reopen the menu on both paths, as above. A mistyped price should not leave the player standing in the world wondering +where the editor went. + +### Methods + +| Method | Description | +|:----------------------------------|:--------------------------------------------------------------------------| +| `ask(player, message, onInput)` | Sends `message` and runs `onInput` with what the player types next | +| `isWaiting(player)` | Whether the player is being asked something | +| `cancel(player)` | Drops any pending question without running its callback | +| `consume(player, message)` | Feeds a chat message in; used by `ChatPromptListener`, not by features | +| `CANCEL_WORD` | The word a player types to back out | + +Only one question can be waiting for a player at a time: asking a second drops the first, so two menus cannot both be +listening. + --- ## PDCUtil diff --git a/gradle.properties b/gradle.properties index 53e449e..e216f5b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,8 +1,9 @@ kotlin.code.style=official # Plugin Properties -plugin_version=1.0.0 +plugin_version=1.1.0 # Dependency Versions towny_version=0.103.2.7 vault_api_version=1.7.1 +fancynpcs_version=2.9.2 diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt index e2b86e0..6e11c10 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt @@ -5,18 +5,19 @@ import net.milkbowl.vault.economy.Economy import net.trilleo.mc.plugins.tritown.config.EconomySettings import net.trilleo.mc.plugins.tritown.config.PluginConfig import net.trilleo.mc.plugins.tritown.config.ScoreboardSettings +import net.trilleo.mc.plugins.tritown.config.ShopSettings import net.trilleo.mc.plugins.tritown.data.PlayerDataManager import net.trilleo.mc.plugins.tritown.data.ServerDataManager -import net.trilleo.mc.plugins.tritown.economy.CurrencyRegistry -import net.trilleo.mc.plugins.tritown.economy.EconomyFormat -import net.trilleo.mc.plugins.tritown.economy.EconomyService -import net.trilleo.mc.plugins.tritown.economy.TownyAccountNaming +import net.trilleo.mc.plugins.tritown.economy.* import net.trilleo.mc.plugins.tritown.economy.storage.JsonEconomyStorage +import net.trilleo.mc.plugins.tritown.economy.storage.JsonPulseStorage import net.trilleo.mc.plugins.tritown.economy.vault.TriTownVaultEconomy import net.trilleo.mc.plugins.tritown.economy.vault.VaultRegistration import net.trilleo.mc.plugins.tritown.enums.ProviderMode import net.trilleo.mc.plugins.tritown.registration.* import net.trilleo.mc.plugins.tritown.scoreboard.ScoreboardService +import net.trilleo.mc.plugins.tritown.shops.ShopManager +import net.trilleo.mc.plugins.tritown.shops.storage.JsonShopStorage import net.trilleo.mc.plugins.tritown.utils.EconomyUtil import net.trilleo.mc.plugins.tritown.utils.Lang import net.trilleo.mc.plugins.tritown.utils.MessageUtil @@ -57,6 +58,16 @@ class Main : JavaPlugin() { CurrencyRegistry.load(settings.currencies, settings.primaryCurrencyId) EconomyFormat.invalidate() EconomyService.initialize(logger, createStorage(settings), settings) + // Joins the economy here rather than in onEnable because Towny can + // already be moving money through the Vault provider by then, and + // those movements belong in the figures like any other. + if (settings.stats.enabled) { + EconomyPulse.start( + store = JsonPulseStorage(dataFolder, logger), + currency = CurrencyRegistry.primary, + retentionDays = settings.stats.retentionDays, + ) + } } catch (e: Exception) { logger.log(Level.SEVERE, "The economy could not be loaded", e) bootFailure = e.message ?: e.javaClass.simpleName @@ -83,6 +94,13 @@ class Main : JavaPlugin() { TownyAccountNaming.load() EconomyService.start(EconomySettings.snapshot.baltopIncludeTowns) + // Before the registrars, because the menus and commands they build read + // the shops as soon as they are asked to. + ShopSettings.load(pluginConfig) + if (ShopSettings.snapshot.enabled) { + ShopManager.start(JsonShopStorage(dataFolder, logger), logger) + } + ItemRegistrar.registerAll(this) RecipeRegistrar.registerAll(this) @@ -117,6 +135,10 @@ class Main : JavaPlugin() { ScoreboardSettings.load(pluginConfig, logger) ScoreboardService.reload() + + // Only the settings: re-reading the shop file would throw away an edit + // that has not been flushed, and nothing in that file comes from config.yml. + ShopSettings.load(pluginConfig) } override fun onDisable() { @@ -128,6 +150,8 @@ class Main : JavaPlugin() { TaskRegistrar.unregisterAll() RecipeRegistrar.unregisterAll() + ShopManager.shutdown() + PlayerDataManager.saveAll() ServerDataManager.save() diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/admin/AdminCommand.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/admin/AdminCommand.kt new file mode 100644 index 0000000..aec7df7 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/admin/AdminCommand.kt @@ -0,0 +1,79 @@ +package net.trilleo.mc.plugins.tritown.commands.admin + +import net.trilleo.mc.plugins.tritown.guis.admin.AdminPanelGUI +import net.trilleo.mc.plugins.tritown.guis.admin.AdminShopsGUI +import net.trilleo.mc.plugins.tritown.guis.admin.EconomyPanelGUI +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PluginCommand +import net.trilleo.mc.plugins.tritown.utils.sendPrefixed +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.command.CommandSender +import org.bukkit.entity.Player + +/** + * Opens the administration panel. + * + * The panel is where the server is read rather than changed, so the command + * takes nothing but the section to land on — everything it shows is a menu, and + * a section named here only saves a click. + */ +class AdminCommand : PluginCommand( + name = "admin", + description = "Open the admin panel", + usage = "/tritown admin [economy|shops]", + permission = PERMISSION, +) { + + override val extraPermissions = listOf(AdminPanelGUI.ECONOMY_PERMISSION, AdminPanelGUI.SHOPS_PERMISSION) + + override fun execute(sender: CommandSender, args: Array): Boolean { + val player = sender as? Player ?: run { + sender.sendPrefixed(sender.tr("command.admin.players-only")) + return true + } + + val section = args.firstOrNull()?.lowercase() + if (section != null && section !in SECTIONS) { + player.sendPrefixed(player.tr("command.admin.unknown-section", "section" to section)) + return true + } + + val permission = permissionFor(section) + if (permission != null && !player.hasPermission(permission)) { + player.sendPrefixed(player.tr("command.admin.no-permission-section", "section" to section.orEmpty())) + return true + } + + if (!GUIManager.open(player, guiFor(section))) { + player.sendPrefixed(player.tr("command.admin.unavailable")) + } + return true + } + + override fun tabComplete(sender: CommandSender, args: Array): List = + if (args.size == 1) { + SECTIONS.filter { + permissionFor(it)?.let(sender::hasPermission) != false && it.startsWith(args[0], ignoreCase = true) + } + } else { + emptyList() + } + + private fun guiFor(section: String?): String = when (section) { + "economy" -> EconomyPanelGUI.ID + "shops" -> AdminShopsGUI.ID + else -> AdminPanelGUI.ID + } + + private fun permissionFor(section: String?): String? = when (section) { + "economy" -> AdminPanelGUI.ECONOMY_PERMISSION + "shops" -> AdminPanelGUI.SHOPS_PERMISSION + else -> null + } + + private companion object { + const val PERMISSION = "tritown.admin" + + val SECTIONS = listOf("economy", "shops") + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/shop/ShopCommand.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/shop/ShopCommand.kt new file mode 100644 index 0000000..fdd71e8 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/shop/ShopCommand.kt @@ -0,0 +1,250 @@ +package net.trilleo.mc.plugins.tritown.commands.shop + +import net.trilleo.mc.plugins.tritown.config.ShopSettings +import net.trilleo.mc.plugins.tritown.guis.shop.ShopEditorGUI +import net.trilleo.mc.plugins.tritown.guis.shop.ShopGUI +import net.trilleo.mc.plugins.tritown.guis.shop.ShopListGUI +import net.trilleo.mc.plugins.tritown.guis.shop.ShopStatsGUI +import net.trilleo.mc.plugins.tritown.registration.PluginCommand +import net.trilleo.mc.plugins.tritown.shops.ShopDefinition +import net.trilleo.mc.plugins.tritown.shops.ShopLimits +import net.trilleo.mc.plugins.tritown.shops.ShopManager +import net.trilleo.mc.plugins.tritown.shops.npc.ShopNpcBridge +import net.trilleo.mc.plugins.tritown.utils.sendPrefixed +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Bukkit +import org.bukkit.command.CommandSender +import org.bukkit.entity.Player + +/** + * Sets shops up and puts NPCs behind their counters. + * + * Everything that describes a shop is edited in the menus rather than typed + * here; this covers what a menu cannot do — creating and deleting a shop, + * binding an NPC by name, and opening a shop for somebody from the console. + */ +class ShopCommand : PluginCommand( + name = "shop", + description = "Administer the server's shops", + usage = "/tritown shop ", + permission = PERMISSION, +) { + + override val extraPermissions = ACTIONS.map { permissionFor(it) } + + override fun execute(sender: CommandSender, args: Array): Boolean { + if (!ShopSettings.isLoaded || !ShopSettings.snapshot.enabled) { + sender.sendPrefixed(sender.tr("command.shop.disabled")) + return true + } + + if (!ShopManager.isReady) { + sender.sendPrefixed(sender.tr("command.shop.unavailable")) + return true + } + + val action = args.firstOrNull()?.lowercase() + if (action == null || action !in ACTIONS) { + sendUsage(sender) + return true + } + + if (!sender.hasPermission(permissionFor(action))) { + sender.sendPrefixed(sender.tr("command.shop.no-permission-action", "action" to action)) + return true + } + + when (action) { + "list" -> list(sender) + "create" -> create(sender, args) + "delete" -> delete(sender, args) + "edit" -> edit(sender, args) + "open" -> open(sender, args) + "bind" -> bind(sender, args) + "unbind" -> unbind(sender, args) + "stats" -> stats(sender, args) + } + return true + } + + override fun tabComplete(sender: CommandSender, args: Array): List = when (args.size) { + 1 -> ACTIONS.filter { sender.hasPermission(permissionFor(it)) && it.startsWith(args[0], ignoreCase = true) } + + 2 -> when (args[0].lowercase()) { + "unbind" -> ShopNpcBridge.names().filter { it.startsWith(args[1], ignoreCase = true) } + "create" -> emptyList() + else -> ShopManager.ids().filter { it.startsWith(args[1], ignoreCase = true) } + } + + 3 -> when (args[0].lowercase()) { + "bind" -> ShopNpcBridge.names().filter { it.startsWith(args[2], ignoreCase = true) } + "delete" -> listOf(CONFIRM).filter { it.startsWith(args[2], ignoreCase = true) } + "open" -> Bukkit.getOnlinePlayers().map { it.name }.filter { it.startsWith(args[2], ignoreCase = true) } + else -> emptyList() + } + + else -> emptyList() + } + + // ── Actions ───────────────────────────────────────────────────────── + + private fun list(sender: CommandSender) { + val player = sender as? Player ?: return sender.sendPrefixed(sender.tr("command.shop.players-only")) + ShopListGUI.show(player) + } + + private fun create(sender: CommandSender, args: Array) { + val id = args.getOrNull(1) + if (id == null) { + sender.sendPrefixed(sender.tr("command.shop.usage.create")) + return + } + + val name = args.drop(2).joinToString(" ").ifBlank { id } + val shop = ShopManager.create(id, name) + if (shop == null) { + sender.sendPrefixed(sender.tr("command.shop.create-failed", "id" to id)) + return + } + + sender.sendPrefixed(sender.tr("command.shop.created", "id" to shop.id)) + (sender as? Player)?.let { ShopEditorGUI.show(it, shop) } + } + + /** + * Deletes a shop, and only when the word is typed out. + * + * A shop can hold an afternoon of setting up and there is no undo, so the + * confirmation is deliberately something that cannot be reached by pressing + * tab twice. + */ + private fun delete(sender: CommandSender, args: Array) { + val shop = resolve(sender, args.getOrNull(1)) ?: return + + if (!args.getOrNull(2).equals(CONFIRM, ignoreCase = true)) { + sender.sendPrefixed(sender.tr("command.shop.delete-confirm", "id" to shop.id)) + return + } + + Bukkit.getOnlinePlayers().forEach { ShopLimits.forget(it, shop) } + ShopManager.delete(shop.id) + sender.sendPrefixed(sender.tr("command.shop.deleted", "id" to shop.id)) + } + + private fun edit(sender: CommandSender, args: Array) { + val player = sender as? Player ?: return sender.sendPrefixed(sender.tr("command.shop.players-only")) + val shop = resolve(sender, args.getOrNull(1)) ?: return + ShopEditorGUI.show(player, shop) + } + + /** Opens a shop for somebody, so a menu can be checked without an NPC and the console can open one. */ + private fun open(sender: CommandSender, args: Array) { + val shop = resolve(sender, args.getOrNull(1)) ?: return + + val target = args.getOrNull(2)?.let { Bukkit.getPlayerExact(it) } ?: sender as? Player + if (target == null) { + sender.sendPrefixed(sender.tr("command.shop.usage.open")) + return + } + + ShopGUI.show(target, shop) + if (target !== sender) { + sender.sendPrefixed(sender.tr("command.shop.opened", "id" to shop.id, "player" to target.name)) + } + } + + private fun bind(sender: CommandSender, args: Array) { + val shop = resolve(sender, args.getOrNull(1)) ?: return + + val npcName = args.getOrNull(2) + if (npcName == null) { + sender.sendPrefixed(sender.tr("command.shop.usage.bind")) + return + } + + if (!ShopNpcBridge.isAvailable) { + sender.sendPrefixed(sender.tr("command.shop.npcs-unavailable", "plugin" to ShopNpcBridge.PLUGIN_NAME)) + return + } + + val npcId = ShopNpcBridge.idOf(npcName) + if (npcId == null) { + sender.sendPrefixed(sender.tr("command.shop.npc-unknown", "npc" to npcName)) + return + } + + // An NPC opens one shop, so binding it to another moves it rather than leaving it ambiguous. + ShopManager.all().forEach { it.npcIds.remove(npcId) } + shop.npcIds += npcId + ShopManager.save() + + sender.sendPrefixed(sender.tr("command.shop.bound", "npc" to npcName, "id" to shop.id)) + } + + private fun unbind(sender: CommandSender, args: Array) { + val npcName = args.getOrNull(1) + if (npcName == null) { + sender.sendPrefixed(sender.tr("command.shop.usage.unbind")) + return + } + + // Resolved through FancyNpcs when it can be, so an NPC that has since been deleted can still be unbound by id. + val npcId = ShopNpcBridge.idOf(npcName) ?: npcName + val shop = ShopManager.all().firstOrNull { npcId in it.npcIds } + if (shop == null) { + sender.sendPrefixed(sender.tr("command.shop.npc-not-bound", "npc" to npcName)) + return + } + + shop.npcIds.remove(npcId) + ShopManager.save() + sender.sendPrefixed(sender.tr("command.shop.unbound", "npc" to npcName, "id" to shop.id)) + } + + private fun stats(sender: CommandSender, args: Array) { + val player = sender as? Player ?: return sender.sendPrefixed(sender.tr("command.shop.players-only")) + val shop = resolve(sender, args.getOrNull(1)) ?: return + ShopStatsGUI.show(player, shop) + } + + // ── Helpers ───────────────────────────────────────────────────────── + + private fun resolve(sender: CommandSender, id: String?): ShopDefinition? { + if (id == null) { + sendUsage(sender) + return null + } + + val shop = ShopManager.get(id) + if (shop == null) sender.sendPrefixed(sender.tr("command.shop.unknown", "id" to id)) + return shop + } + + private fun sendUsage(sender: CommandSender) { + sender.sendPrefixed(sender.tr("command.shop.usage.header")) + for (action in ACTIONS) { + if (sender.hasPermission(permissionFor(action))) sender.sendPrefixed(usageFor(sender, action)) + } + } + + /** Spelled out rather than built from the action, so every line is a key the translation test can see. */ + private fun usageFor(sender: CommandSender, action: String): String = when (action) { + "list" -> sender.tr("command.shop.usage.list") + "create" -> sender.tr("command.shop.usage.create") + "delete" -> sender.tr("command.shop.usage.delete") + "edit" -> sender.tr("command.shop.usage.edit") + "open" -> sender.tr("command.shop.usage.open") + "bind" -> sender.tr("command.shop.usage.bind") + "unbind" -> sender.tr("command.shop.usage.unbind") + else -> sender.tr("command.shop.usage.stats") + } + + private companion object { + const val PERMISSION = "tritown.shop.admin" + const val CONFIRM = "confirm" + + val ACTIONS = listOf("list", "create", "delete", "edit", "open", "bind", "unbind", "stats") + + fun permissionFor(action: String): String = "$PERMISSION.$action" + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/EconomySettings.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/EconomySettings.kt index 064cbcc..c63b21a 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/EconomySettings.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/EconomySettings.kt @@ -31,6 +31,7 @@ data class EconomySettings( val allowRescale: Boolean, val deleteAccountsOnDelete: Boolean, val history: HistorySettings, + val stats: StatsSettings, ) { /** @@ -48,6 +49,16 @@ data class EconomySettings( val timeFormat: String, ) + /** + * How much of the economy's own history the admin panel keeps. + * + * @param retentionDays how far back the hourly figures reach; 0 keeps them forever + */ + data class StatsSettings( + val enabled: Boolean, + val retentionDays: Int, + ) + /** The bounds the ledger should enforce, with the balance cap converted per currency. */ fun ledgerLimits(): LedgerLimits = LedgerLimits( capByCurrency = if (balanceCap <= 0.0) { @@ -123,6 +134,10 @@ data class EconomySettings( .coerceAtLeast(0L) * 1024L * 1024L, timeFormat = config.getString("economy.history.time-format", "yyyy-MM-dd HH:mm"), ), + stats = StatsSettings( + enabled = config.getBoolean("economy.stats.enabled", true), + retentionDays = config.getInt("economy.stats.retention-days", 30).coerceIn(0, 365), + ), ) } } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/ShopSettings.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/ShopSettings.kt new file mode 100644 index 0000000..d0c56f3 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/ShopSettings.kt @@ -0,0 +1,63 @@ +package net.trilleo.mc.plugins.tritown.config + +import net.trilleo.mc.plugins.tritown.enums.TownyRequirement + +/** + * An immutable snapshot of the `shops` block of `config.yml`. + * + * Built whole and swapped in on a reload, the same way [EconomySettings] is, so + * a shop being used while the configuration is re-read cannot see half of it. + * + * @param saveIntervalSeconds how often stock and statistics are written out; definitions save immediately + * @param confirmAbove the currency total that makes a purchase ask for confirmation first; 0 never asks + * @param sellRate what an entry pays back, as a fraction of its buy price, when no sell price was set + * @param discounts how much each standing in Towny takes off a price, as a fraction between 0 and 1 + */ +data class ShopSettings( + val enabled: Boolean, + val saveIntervalSeconds: Long, + val confirmAbove: Double, + val sellRate: Double, + val discounts: Map, +) { + + companion object { + + /** The standings a discount can be attached to, by their key in `config.yml`. */ + private val DISCOUNT_KEYS = mapOf( + "has-town" to TownyRequirement.HAS_TOWN, + "has-nation" to TownyRequirement.HAS_NATION, + "is-mayor" to TownyRequirement.IS_MAYOR, + "is-king" to TownyRequirement.IS_KING, + ) + + @Volatile + private var current: ShopSettings? = null + + /** The settings in force. */ + val snapshot: ShopSettings + get() = current ?: error("Shop settings have not been loaded yet") + + /** Whether [load] has run. */ + val isLoaded: Boolean + get() = current != null + + /** Reads the `shops` block from [config] and makes it the current snapshot. */ + fun load(config: PluginConfig): ShopSettings = read(config).also { current = it } + + private fun read(config: PluginConfig): ShopSettings { + val discounts = DISCOUNT_KEYS.mapNotNull { (key, requirement) -> + val rate = config.getDouble("shops.discounts.$key", 0.0).coerceIn(0.0, 1.0) + if (rate <= 0.0) null else requirement to rate + }.toMap() + + return ShopSettings( + enabled = config.getBoolean("shops.enabled", true), + saveIntervalSeconds = config.getLong("shops.save-interval", 60L).coerceIn(5L, 3600L), + confirmAbove = config.getDouble("shops.confirm-above", 0.0).coerceAtLeast(0.0), + sellRate = config.getDouble("shops.sell-rate", 0.5).coerceIn(0.0, 1.0), + discounts = discounts, + ) + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/data/PlayerData.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/data/PlayerData.kt index 4a8d1a5..44381de 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/data/PlayerData.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/data/PlayerData.kt @@ -66,6 +66,15 @@ open class PlayerData(val uuid: UUID) { fun getJsonArray(key: String): JsonArray = if (json.has(key) && json.get(key).isJsonArray) json.getAsJsonArray(key) else JsonArray() + /** + * Returns the [JsonObject] stored at [key], or an empty [JsonObject] when absent. + * + * The returned object is the stored one, not a copy, so writing to it writes + * through — call [set] afterwards only when the key was absent. + */ + fun getJsonObject(key: String): JsonObject = + if (json.has(key) && json.get(key).isJsonObject) json.getAsJsonObject(key) else JsonObject() + // ── Typed Setters ─────────────────────────────────────────────────── /** Stores a [String] value at [key]. */ diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/data/ServerData.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/data/ServerData.kt index 55684c8..6c91ed8 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/data/ServerData.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/data/ServerData.kt @@ -62,6 +62,15 @@ open class ServerData { fun getJsonArray(key: String): JsonArray = if (json.has(key) && json.get(key).isJsonArray) json.getAsJsonArray(key) else JsonArray() + /** + * Returns the [JsonObject] stored at [key], or an empty [JsonObject] when absent. + * + * The returned object is the stored one, not a copy, so writing to it writes + * through — call [set] afterwards only when the key was absent. + */ + fun getJsonObject(key: String): JsonObject = + if (json.has(key) && json.get(key).isJsonObject) json.getAsJsonObject(key) else JsonObject() + // ── Typed Setters ─────────────────────────────────────────────────── /** Stores a [String] value at [key]. */ diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyContext.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyContext.kt index a657415..945deef 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyContext.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyContext.kt @@ -42,4 +42,5 @@ object EconomyContext { const val SOURCE_COMMAND = "command" const val SOURCE_TOWNY = "towny" + const val SOURCE_SHOP = "shop" } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyPulse.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyPulse.kt new file mode 100644 index 0000000..82e8217 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyPulse.kt @@ -0,0 +1,522 @@ +package net.trilleo.mc.plugins.tritown.economy + +import net.trilleo.mc.plugins.tritown.economy.storage.* +import net.trilleo.mc.plugins.tritown.enums.AccountType +import net.trilleo.mc.plugins.tritown.enums.FlowCategory +import net.trilleo.mc.plugins.tritown.enums.TransactionType +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.LongAdder + +/** + * What the economy as a whole is doing: where money comes from, where it goes, + * and what is left holding it. + * + * Two different things are kept, because they answer different questions. + * *Buckets* accumulate movements hour by hour as they happen, which is the only + * way to know what created and destroyed currency. *Samples* measure the ledger + * outright on a timer, which is the only way to know the supply exactly — + * adding movements up would drift the moment anything moved money without + * TriTown seeing it. + * + * Money is created when it is deposited from outside the economy and destroyed + * when it is withdrawn out of it; a transfer moves it between two accounts and + * changes nothing, so it is counted separately as circulation. Towny moves bank + * deposits through Vault as a withdrawal and a deposit rather than as a + * transfer, so those land on both sides of the gross figures and cancel in the + * net — which is why the net, and the per-category net, are the numbers worth + * reading. + * + * Recording happens on whichever thread moved the money, including Towny's, so + * everything here is lock-free and never touches the disk. Sampling and writing + * happen on the flush task. + */ +object EconomyPulse { + + // ── Read models ───────────────────────────────────────────────────── + + /** One column of the flow chart: what happened between [from] and [to]. */ + data class Slice(val from: Long, val to: Long, val created: Long, val destroyed: Long) { + val net: Long get() = created - destroyed + } + + /** + * Everything that moved over one window, in minor units. + * + * @param createdBy money that entered the economy, by what it was for + * @param destroyedBy money that left it, by what it was for + * @param createdTo money that entered, by the kind of account that received it + * @param destroyedFrom money that left, by the kind of account it came from + * @param circulated money moved between accounts, counted once per transfer + * @param adjusted how far balances were moved by being set outright + */ + data class Flow( + val from: Long, + val to: Long, + val createdBy: Map, + val destroyedBy: Map, + val createdTo: Map, + val destroyedFrom: Map, + val circulated: Long, + val adjusted: Long, + val movements: Long, + val transfers: Long, + val slices: List, + ) { + val created: Long get() = createdBy.values.sum() + val destroyed: Long get() = destroyedBy.values.sum() + val net: Long get() = created - destroyed + + /** Whether anything at all happened in this window. */ + val isEmpty: Boolean get() = movements == 0L && transfers == 0L + + /** What [category] did to the supply: positive when it feeds the economy, negative when it drains it. */ + fun netOf(category: FlowCategory): Long = (createdBy[category] ?: 0L) - (destroyedBy[category] ?: 0L) + + /** Every category that saw any money at all, largest gross movement first. */ + fun categories(): List = (createdBy.keys + destroyedBy.keys) + .sortedByDescending { (createdBy[it] ?: 0L) + (destroyedBy[it] ?: 0L) } + } + + /** + * The ledger as it stood when it was measured. + * + * @param supply total held, by account type + * @param accounts how many accounts of each type exist + * @param activeWallets player wallets whose balance changed in the last week + * @param topShare the share of player wealth held by the richest tenth, 0 to 1 + * @param gini inequality across player wallets, 0 (identical) to 1 (one player holds everything) + */ + data class Supply( + val at: Long, + val supply: Map, + val accounts: Map, + val activeWallets: Int, + val median: Long, + val mean: Long, + val richest: Long, + val topShare: Double, + val gini: Double, + ) { + /** Every unit of currency in existence. */ + val total: Long get() = supply.values.sum() + + /** How much is held in player wallets rather than by a town, a nation or the server. */ + val held: Long get() = (supply[AccountType.PLAYER] ?: 0L) + (supply[AccountType.UNKNOWN] ?: 0L) + + /** How much sits in town and nation banks. */ + val banked: Long get() = (supply[AccountType.TOWN] ?: 0L) + (supply[AccountType.NATION] ?: 0L) + + /** How many accounts there are of every kind. */ + val accountCount: Int get() = accounts.values.sum() + + /** How many player wallets there are. */ + val wallets: Int get() = (accounts[AccountType.PLAYER] ?: 0) + (accounts[AccountType.UNKNOWN] ?: 0) + } + + // ── State ─────────────────────────────────────────────────────────── + + private val buckets = ConcurrentHashMap() + private val samples = ConcurrentHashMap() + private val dirty = AtomicBoolean(false) + + private var storage: JsonPulseStorage? = null + private var retentionHours: Long = 0L + + /** The currency the figures are in. A movement in any other currency is ignored rather than added to it. */ + @Volatile + private var currencyId: String = "" + + /** + * Whether statistics are switched on and loaded. + * + * Written last by [start] and read first by [record], so a recorder that + * sees this set is guaranteed to see everything [start] published before it. + */ + @Volatile + var isEnabled: Boolean = false + private set + + // ── Lifecycle ─────────────────────────────────────────────────────── + + /** + * Loads whatever was stored and starts recording. + * + * Called from `onLoad` alongside the rest of the economy, because Towny can + * move money through the Vault provider before TriTown has enabled and + * those movements should be counted like any other. + * + * @param retentionDays how far back the history reaches; 0 keeps everything + */ + fun start(store: JsonPulseStorage, currency: Currency, retentionDays: Int) { + buckets.clear() + samples.clear() + + storage = store + currencyId = currency.id + retentionHours = retentionDays.coerceAtLeast(0).toLong() * 24L + isEnabled = true + + store.load()?.let(::restore) + prune() + dirty.set(false) + } + + /** Writes anything outstanding and stops recording. */ + fun shutdown() { + if (!isEnabled) return + isEnabled = false + flush() + storage = null + buckets.clear() + samples.clear() + } + + // ── Recording ─────────────────────────────────────────────────────── + + /** + * Files one movement of money. + * + * Called from [EconomyService] for every transaction it records, on + * whichever thread made it. + * + * @param holder what kind of account the movement is filed against + */ + fun record( + type: TransactionType, + currency: Currency, + amount: Money, + holder: AccountType, + source: String, + reason: String, + ) { + if (!isEnabled || currency.id != currencyId) return + + val minor = amount.abs().minor + if (minor == 0L) return + + val bucket = buckets.computeIfAbsent(hourOf(System.currentTimeMillis())) { Bucket() } + bucket.movements.increment() + + when (type) { + TransactionType.DEPOSIT -> { + bucket.createdBy.adder(FlowCategory.of(source, reason)).add(minor) + bucket.createdTo.adder(holder).add(minor) + } + + TransactionType.WITHDRAW -> { + bucket.destroyedBy.adder(FlowCategory.of(source, reason)).add(minor) + bucket.destroyedFrom.adder(holder).add(minor) + } + + // A transfer is recorded against both accounts, so only one side is + // counted or every payment would show up as twice its own size. + TransactionType.TRANSFER_OUT -> { + bucket.circulated.add(minor) + bucket.transfers.increment() + } + + TransactionType.TRANSFER_IN -> Unit + + // The record carries how far the balance moved but not which way, so + // a set is kept apart from the money it did or did not create. + TransactionType.SET -> bucket.adjusted.add(minor) + + TransactionType.CLOSED -> Unit + } + + dirty.set(true) + } + + /** + * Measures the ledger and keeps the result as this hour's sample. + * + * Walks every account and sorts the player wallets, so this belongs on the + * flush task next to the leaderboard rebuild and nowhere near the server + * thread. + */ + fun sample(ledger: EconomyLedger, currency: Currency) { + if (!isEnabled || currency.id != currencyId) return + + val supply = EnumMap(AccountType::class.java) + val counts = EnumMap(AccountType::class.java) + val wallets = ArrayList(ledger.size) + val activeSince = System.currentTimeMillis() - ACTIVE_WINDOW_MS + var active = 0 + + for (account in ledger.accounts()) { + val balance = account.balance(currency.id).minor + supply.merge(account.type, balance, Long::plus) + counts.merge(account.type, 1, Int::plus) + + if (account.type == AccountType.PLAYER || account.type == AccountType.UNKNOWN) { + wallets.add(balance) + if (account.updatedAt >= activeSince) active++ + } + } + + wallets.sort() + val now = System.currentTimeMillis() + val measurement = Supply( + at = now, + supply = supply, + accounts = counts, + activeWallets = active, + median = median(wallets), + mean = if (wallets.isEmpty()) 0L else wallets.sum() / wallets.size, + richest = wallets.lastOrNull() ?: 0L, + topShare = topShare(wallets), + gini = gini(wallets), + ) + + // The sample is always replaced, so the panel shows when the ledger was + // last looked at, but an idle server does not rewrite the whole file + // every flush just because that time moved on. + val previous = samples.put(hourOf(now), measurement) + if (previous == null || previous.copy(at = now) != measurement) dirty.set(true) + + prune() + } + + /** Writes the statistics out, if anything has changed since the last write. */ + fun flush() { + val store = storage ?: return + if (!dirty.getAndSet(false)) return + store.save(snapshot()) + } + + // ── Reading ───────────────────────────────────────────────────────── + + /** + * Everything recorded over the last [hours], split into [slices] equal + * columns. + * + * @param hours how far back to reach; 0 or less means everything still kept + */ + fun window(hours: Int, slices: Int = 7): Flow { + val now = System.currentTimeMillis() + val from = if (hours > 0) now - hours * HOUR_MS else earliest() ?: now + return window(from, now, slices) + } + + /** The newest sample taken at or before [millis], or `null` when the ledger had not been measured by then. */ + fun sampleAt(millis: Long): Supply? = samples.values.filter { it.at <= millis }.maxByOrNull { it.at } + + /** The most recent measurement of the ledger, or `null` when none has been taken yet. */ + fun latest(): Supply? = samples.values.maxByOrNull { it.at } + + /** When the oldest record still kept starts, or `null` when nothing is recorded. */ + fun earliest(): Long? { + val bucket = buckets.keys.minOrNull() + val sample = samples.keys.minOrNull() + val hour = listOfNotNull(bucket, sample).minOrNull() ?: return null + return hour * HOUR_MS + } + + // ── Internals ─────────────────────────────────────────────────────── + + private fun window(from: Long, to: Long, slices: Int): Flow { + val createdBy = EnumMap(FlowCategory::class.java) + val destroyedBy = EnumMap(FlowCategory::class.java) + val createdTo = EnumMap(AccountType::class.java) + val destroyedFrom = EnumMap(AccountType::class.java) + val columns = LongArray(slices.coerceAtLeast(1) * 2) + + var circulated = 0L + var adjusted = 0L + var movements = 0L + var transfers = 0L + + val span = (to - from).coerceAtLeast(1L) + for ((hour, bucket) in buckets) { + val start = hour * HOUR_MS + // The hour the window starts in is half inside it; counting all of it + // is closer to the truth than dropping it, since a bucket cannot be split. + if (start + HOUR_MS <= from || start > to) continue + + for ((category, adder) in bucket.createdBy) createdBy.merge(category, adder.sum(), Long::plus) + for ((category, adder) in bucket.destroyedBy) destroyedBy.merge(category, adder.sum(), Long::plus) + for ((type, adder) in bucket.createdTo) createdTo.merge(type, adder.sum(), Long::plus) + for ((type, adder) in bucket.destroyedFrom) destroyedFrom.merge(type, adder.sum(), Long::plus) + + circulated += bucket.circulated.sum() + adjusted += bucket.adjusted.sum() + movements += bucket.movements.sum() + transfers += bucket.transfers.sum() + + val column = (((start - from).coerceAtLeast(0L) * slices) / span).toInt().coerceIn(0, slices - 1) + columns[column * 2] += bucket.createdBy.values.sumOf { it.sum() } + columns[column * 2 + 1] += bucket.destroyedBy.values.sumOf { it.sum() } + } + + val width = span / slices + return Flow( + from = from, + to = to, + createdBy = createdBy, + destroyedBy = destroyedBy, + createdTo = createdTo, + destroyedFrom = destroyedFrom, + circulated = circulated, + adjusted = adjusted, + movements = movements, + transfers = transfers, + slices = List(slices) { index -> + Slice( + from = from + width * index, + to = if (index == slices - 1) to else from + width * (index + 1), + created = columns[index * 2], + destroyed = columns[index * 2 + 1], + ) + }, + ) + } + + private fun prune() { + if (retentionHours <= 0L) return + val oldest = hourOf(System.currentTimeMillis()) - retentionHours + buckets.keys.removeIf { it < oldest } + samples.keys.removeIf { it < oldest } + } + + private fun snapshot(): StoredPulse = StoredPulse( + v = StorageSchema.CURRENT, + currency = currencyId, + buckets = buckets.entries.sortedBy { it.key }.map { (hour, bucket) -> + StoredPulseBucket( + hour = hour, + created = bucket.createdBy.sums(), + destroyed = bucket.destroyedBy.sums(), + createdTo = bucket.createdTo.sums(), + destroyedFrom = bucket.destroyedFrom.sums(), + circulated = bucket.circulated.sum(), + adjusted = bucket.adjusted.sum(), + movements = bucket.movements.sum(), + transfers = bucket.transfers.sum(), + ) + }, + samples = samples.entries.sortedBy { it.key }.map { (hour, sample) -> + StoredPulseSample( + hour = hour, + at = sample.at, + supply = sample.supply.mapKeys { it.key.name }, + accounts = sample.accounts.mapKeys { it.key.name }, + activeWallets = sample.activeWallets, + median = sample.median, + mean = sample.mean, + richest = sample.richest, + topShare = sample.topShare, + gini = sample.gini, + ) + }, + ) + + /** + * Seeds the statistics from [stored]. + * + * Figures written in another currency are dropped rather than adopted: the + * numbers are minor units, so reading cents as thousandths would multiply + * every past hour by ten. + */ + private fun restore(stored: StoredPulse) { + if (stored.currency.isNotEmpty() && stored.currency != currencyId) return + + for (entry in stored.buckets) { + buckets[entry.hour] = Bucket().apply { + for ((name, value) in entry.created) category(name)?.let { createdBy.adder(it).add(value) } + for ((name, value) in entry.destroyed) category(name)?.let { destroyedBy.adder(it).add(value) } + for ((name, value) in entry.createdTo) accountType(name)?.let { createdTo.adder(it).add(value) } + for ((name, value) in entry.destroyedFrom) accountType(name)?.let { destroyedFrom.adder(it).add(value) } + circulated.add(entry.circulated) + adjusted.add(entry.adjusted) + movements.add(entry.movements) + transfers.add(entry.transfers) + } + } + + for (entry in stored.samples) { + samples[entry.hour] = Supply( + at = entry.at, + supply = entry.supply.mapNotNull { (name, value) -> accountType(name)?.let { it to value } }.toMap(), + accounts = entry.accounts.mapNotNull { (name, value) -> accountType(name)?.let { it to value } } + .toMap(), + activeWallets = entry.activeWallets, + median = entry.median, + mean = entry.mean, + richest = entry.richest, + topShare = entry.topShare, + gini = entry.gini, + ) + } + } + + private fun category(name: String): FlowCategory? = + runCatching { FlowCategory.valueOf(name) }.getOrNull() + + private fun accountType(name: String): AccountType? = + runCatching { AccountType.valueOf(name) }.getOrNull() + + private fun median(sorted: List): Long = when { + sorted.isEmpty() -> 0L + sorted.size % 2 == 1 -> sorted[sorted.size / 2] + else -> (sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2 + } + + /** The share of all player wealth held by the richest tenth, which is the figure a server owner feels. */ + private fun topShare(sorted: List): Double { + if (sorted.isEmpty()) return 0.0 + val total = sorted.sumOf { it.coerceAtLeast(0L) } + if (total <= 0L) return 0.0 + val tenth = ((sorted.size + 9) / 10).coerceAtLeast(1) + val top = sorted.takeLast(tenth).sumOf { it.coerceAtLeast(0L) } + return top.toDouble() / total.toDouble() + } + + /** + * The Gini coefficient of the player wallets, from 0 (everyone holds the + * same) to 1 (one player holds everything). + * + * Debts are read as zero, because the measure is defined over non-negative + * shares and a server that allows overdrafts should not be able to push it + * past 1. + */ + private fun gini(sorted: List): Double { + if (sorted.size < 2) return 0.0 + + var total = 0.0 + var weighted = 0.0 + for ((index, value) in sorted.withIndex()) { + val amount = value.coerceAtLeast(0L).toDouble() + total += amount + weighted += amount * (index + 1) + } + if (total <= 0.0) return 0.0 + + val n = sorted.size + return ((2.0 * weighted) / (n * total) - (n + 1.0) / n).coerceIn(0.0, 1.0) + } + + private fun hourOf(millis: Long): Long = millis / HOUR_MS + + private fun ConcurrentHashMap.adder(key: K): LongAdder = + computeIfAbsent(key) { LongAdder() } + + private fun > ConcurrentHashMap.sums(): Map = + entries.associate { (key, adder) -> key.name to adder.sum() } + + /** One hour of movements. Every counter is an adder, so recording never blocks another thread. */ + private class Bucket { + val createdBy = ConcurrentHashMap() + val destroyedBy = ConcurrentHashMap() + val createdTo = ConcurrentHashMap() + val destroyedFrom = ConcurrentHashMap() + val circulated = LongAdder() + val adjusted = LongAdder() + val movements = LongAdder() + val transfers = LongAdder() + } + + private const val HOUR_MS = 60L * 60L * 1000L + private const val ACTIVE_WINDOW_MS = 7L * 24L * HOUR_MS +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyService.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyService.kt index 8cd6a5d..7c11cdd 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyService.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyService.kt @@ -99,8 +99,10 @@ object EconomyService { if (storage == null) return isReady = true Bukkit.getOnlinePlayers().forEach { ensurePlayerAccount(it) } - // Built once here so the leaderboard works before the first flush. + // Both built once here so the leaderboard and the admin panel have + // something to show before the first flush. BaltopCache.rebuild(ledger, CurrencyRegistry.primary, includeGovernmentsInBaltop) + EconomyPulse.sample(ledger, CurrencyRegistry.primary) } /** Re-applies the settings that can change without a restart. */ @@ -113,7 +115,11 @@ object EconomyService { fun shutdown() { if (storage == null) return isReady = false + // One last measurement, so the supply the panel shows after a restart is + // the supply the server stopped with rather than an hour-old one. + if (CurrencyRegistry.isLoaded) EconomyPulse.sample(ledger, CurrencyRegistry.primary) flush() + EconomyPulse.shutdown() runCatching { storage?.close() } storage = null transactions?.clear() @@ -305,9 +311,12 @@ object EconomyService { fun history(uuid: UUID): List = transactions?.recent(uuid) ?: emptyList() /** - * Files a transaction against [account], if history is switched on. + * Files a transaction against [account], for the history view and for the + * economy-wide statistics. * - * Recording never blocks: the record goes on a queue the flush task drains. + * Recording never blocks: the history goes on a queue the flush task drains, + * and the statistics are plain adders. The statistics are kept even when the + * history is switched off, since they cost no disk per transaction. */ fun record( account: UUID, @@ -318,21 +327,37 @@ object EconomyService { balanceAfter: Money, meta: Map = emptyMap(), ) { - val log = transactions ?: return + val holder = ledger.get(account)?.type ?: AccountType.UNKNOWN + // Money reaching a town, nation or NPC account through Vault has come // from Towny; saying so is more useful than the generic default. Towny // does not expose its own reason for the movement, so that is all that // can honestly be claimed here. - if (EconomyContext.current() == EconomyContext.DEFAULT && isTownyOwned(account)) { + if (EconomyContext.current() == EconomyContext.DEFAULT && isTownyOwned(holder)) { EconomyContext.with(EconomyContext.SOURCE_TOWNY, TransactionReason.TOWNY) { - log.record(account, counterparty, currency, type, amount, balanceAfter, meta) + file(account, counterparty, currency, type, amount, balanceAfter, holder, meta) } return } - log.record(account, counterparty, currency, type, amount, balanceAfter, meta) + file(account, counterparty, currency, type, amount, balanceAfter, holder, meta) + } + + private fun file( + account: UUID, + counterparty: UUID?, + currency: Currency, + type: TransactionType, + amount: Money, + balanceAfter: Money, + holder: AccountType, + meta: Map, + ) { + val context = EconomyContext.current() + EconomyPulse.record(type, currency, amount, holder, context.source, context.reason) + transactions?.record(account, counterparty, currency, type, amount, balanceAfter, meta) } - private fun isTownyOwned(uuid: UUID): Boolean = when (ledger.get(uuid)?.type) { + private fun isTownyOwned(type: AccountType): Boolean = when (type) { AccountType.TOWN, AccountType.NATION, AccountType.NPC, AccountType.SERVER -> true else -> false } @@ -371,6 +396,7 @@ object EconomyService { .onFailure { logger.log(Level.WARNING, "Failed to write the economy transaction log", it) } } + EconomyPulse.flush() pruneHistory(store) } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/TransactionLog.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/TransactionLog.kt index 1af6561..1c95853 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/TransactionLog.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/TransactionLog.kt @@ -6,20 +6,6 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.atomic.AtomicLong import kotlin.collections.ArrayDeque -import kotlin.collections.ArrayList -import kotlin.collections.List -import kotlin.collections.Map -import kotlin.collections.asReversed -import kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.emptyList -import kotlin.collections.emptyMap -import kotlin.collections.forEach -import kotlin.collections.isNotEmpty -import kotlin.collections.iterator -import kotlin.collections.set -import kotlin.collections.sortedBy -import kotlin.collections.toList /** * Keeps a short, readable history per account in memory, and queues everything diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/TransactionReason.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/TransactionReason.kt index eb9b5cb..5a9fdbc 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/TransactionReason.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/TransactionReason.kt @@ -25,6 +25,8 @@ object TransactionReason { const val TOWN_DELETED = "money.reason.town-deleted" const val ADMIN_SET = "money.reason.admin-set" const val ADMIN_RESET = "money.reason.admin-reset" + const val SHOP_BUY = "money.reason.shop-buy" + const val SHOP_SELL = "money.reason.shop-sell" /** Encodes [key] and its [args] into the single string that is stored with the transaction. */ fun of(key: String, vararg args: Pair): String = diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/JsonEconomyStorage.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/JsonEconomyStorage.kt index b6469b7..88ed279 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/JsonEconomyStorage.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/JsonEconomyStorage.kt @@ -14,22 +14,6 @@ import java.nio.file.* import java.util.UUID import java.util.logging.Logger import kotlin.collections.ArrayDeque -import kotlin.collections.ArrayList -import kotlin.collections.Collection -import kotlin.collections.HashMap -import kotlin.collections.LinkedHashMap -import kotlin.collections.List -import kotlin.collections.Map -import kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.emptyList -import kotlin.collections.emptyMap -import kotlin.collections.forEach -import kotlin.collections.getOrPut -import kotlin.collections.map -import kotlin.collections.mapValues -import kotlin.collections.set -import kotlin.collections.toList /** * Stores every account in one JSON file under `/economy/`. diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/JsonPulseStorage.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/JsonPulseStorage.kt new file mode 100644 index 0000000..3a2441a --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/JsonPulseStorage.kt @@ -0,0 +1,70 @@ +package net.trilleo.mc.plugins.tritown.economy.storage + +import com.google.gson.GsonBuilder +import java.io.File +import java.nio.file.* +import java.util.logging.Logger + +/** + * Keeps the economy statistics in one JSON file under `/economy/`. + * + * Written the same way balances are — to a temporary file that is then moved + * into place — so a crash mid-write leaves the previous file rather than a + * truncated one. Unlike balances there is no backup copy: statistics are worth + * keeping but nobody's money depends on them, and a file that cannot be read + * simply starts the history again. + */ +class JsonPulseStorage(directory: File, private val logger: Logger) { + + private val file = File(File(directory, DIRECTORY), FILE) + private val gson = GsonBuilder().setPrettyPrinting().create() + + /** Reads what was stored, or `null` when there is nothing readable yet. */ + fun load(): StoredPulse? { + if (!file.exists()) return null + return try { + val stored = gson.fromJson(file.readText(), StoredPulse::class.java) ?: return null + StorageSchema.checkReadable(stored.v, file.name) + stored + } catch (e: EconomyStorageException) { + // Statistics are not worth refusing to start over, unlike balances. + logger.warning(e.message) + null + } catch (e: Exception) { + logger.warning("Could not read ${file.name}: [${e.javaClass.simpleName}] ${e.message}") + null + } + } + + /** Writes [snapshot], replacing whatever was there. */ + fun save(snapshot: StoredPulse) { + try { + file.parentFile?.mkdirs() + writeAtomically(file.toPath(), gson.toJson(snapshot)) + } catch (e: Exception) { + logger.warning("Failed to write ${file.name}: [${e.javaClass.simpleName}] ${e.message}") + } + } + + private fun writeAtomically(target: Path, content: String) { + val temporary = target.resolveSibling("${target.fileName}.tmp") + Files.writeString( + temporary, + content, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE, + ) + + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING) + } + } + + private companion object { + const val DIRECTORY = "economy" + const val FILE = "statistics.json" + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/StoredPulse.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/StoredPulse.kt new file mode 100644 index 0000000..8459a60 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/storage/StoredPulse.kt @@ -0,0 +1,68 @@ +package net.trilleo.mc.plugins.tritown.economy.storage + +/** + * The economy statistics as they are written to storage. + * + * Amounts are minor units of the primary currency throughout, and the maps are + * keyed by enum name rather than by ordinal so that adding a category later + * cannot silently re-label the history already on disk. + * + * @param buckets one entry per hour that saw any movement + * @param samples one entry per hour the ledger was measured in + */ +data class StoredPulse( + val v: Int, + val currency: String, + val buckets: List, + val samples: List, +) + +/** + * What one hour of the economy did. + * + * @param hour hours since the epoch, which is what the bucket is keyed by + * @param created money that entered the economy, by [net.trilleo.mc.plugins.tritown.enums.FlowCategory] + * @param destroyed money that left it, by category + * @param createdTo money that entered, by the kind of account that received it + * @param destroyedFrom money that left, by the kind of account it came from + * @param circulated money moved between two accounts, counted once per transfer + * @param adjusted how far balances were moved by being set outright + */ +data class StoredPulseBucket( + val hour: Long, + val created: Map, + val destroyed: Map, + val createdTo: Map, + val destroyedFrom: Map, + val circulated: Long, + val adjusted: Long, + val movements: Long, + val transfers: Long, +) + +/** + * The state of the ledger at one moment, measured rather than accumulated. + * + * The supply is read straight off the accounts, so it is exact however the + * movements that produced it were attributed. + * + * @param hour hours since the epoch; one sample is kept per hour + * @param at when the sample was actually taken + * @param supply total held, by account type + * @param accounts how many accounts of each type exist + * @param activeWallets player wallets whose balance changed in the last week + * @param topShare the share of player wealth held by the richest tenth, 0 to 1 + * @param gini inequality across player wallets, 0 (identical) to 1 (one player holds everything) + */ +data class StoredPulseSample( + val hour: Long, + val at: Long, + val supply: Map, + val accounts: Map, + val activeWallets: Int, + val median: Long, + val mean: Long, + val richest: Long, + val topShare: Double, + val gini: Double, +) diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/FlowCategory.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/FlowCategory.kt new file mode 100644 index 0000000..88c4bdc --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/FlowCategory.kt @@ -0,0 +1,78 @@ +package net.trilleo.mc.plugins.tritown.enums + +import net.trilleo.mc.plugins.tritown.economy.EconomyContext +import net.trilleo.mc.plugins.tritown.economy.TransactionReason + +/** + * What a movement of money was for, in the few buckets an economy is actually + * read in. + * + * A transaction is recorded with a reason key and a source, which together are + * precise enough to read one line of a history but far too fine to total: the + * reason carries arguments (`money.reason.admin-set?admin=Bob`), so summing by + * reason would produce a row per administrator. A category is the stable, + * translatable grouping the statistics are kept in, and it never grows with the + * number of players. + */ +enum class FlowCategory { + + /** The balance a player is given the first time they join. Always a faucet. */ + STARTING_BALANCE, + + /** Bought from, or sold to, one of the server's own shops. */ + SHOP, + + /** Anything Towny moved: bank deposits and withdrawals, plot sales, upkeep, taxes. */ + TOWNY, + + /** An administrator giving, taking, setting or resetting a balance. */ + ADMIN, + + /** A payment from one account to another. */ + PAYMENT, + + /** Another plugin, through Vault, with nothing to identify it further. */ + EXTERNAL, + + /** Recorded with a reason TriTown has no category for. */ + OTHER; + + /** The translation key naming this category, spelled out so the language test can see it. */ + val key: String + get() = when (this) { + STARTING_BALANCE -> "money.flow.starting-balance" + SHOP -> "money.flow.shop" + TOWNY -> "money.flow.towny" + ADMIN -> "money.flow.admin" + PAYMENT -> "money.flow.payment" + EXTERNAL -> "money.flow.external" + OTHER -> "money.flow.other" + } + + companion object { + + /** + * The category a transaction recorded with [source] and [reason] belongs + * to. + * + * The reason is matched on its key alone, since anything after `?` is an + * argument. The source is only consulted when the reason says nothing + * useful, which is what happens when another plugin moves money through + * Vault inside an operation TriTown did attribute. + */ + fun of(source: String, reason: String): FlowCategory { + val key = reason.substringBefore('?') + return when { + key == TransactionReason.STARTING_BALANCE -> STARTING_BALANCE + key == TransactionReason.SHOP_BUY || key == TransactionReason.SHOP_SELL -> SHOP + key == TransactionReason.TOWNY || key == TransactionReason.TOWN_DELETED -> TOWNY + key == TransactionReason.ADMIN_SET || key == TransactionReason.ADMIN_RESET -> ADMIN + key == TransactionReason.PAYMENT -> PAYMENT + source == EconomyContext.SOURCE_SHOP -> SHOP + source == EconomyContext.SOURCE_TOWNY -> TOWNY + key == TransactionReason.EXTERNAL -> EXTERNAL + else -> OTHER + } + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/LimitPeriod.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/LimitPeriod.kt new file mode 100644 index 0000000..0f00c1d --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/LimitPeriod.kt @@ -0,0 +1,19 @@ +package net.trilleo.mc.plugins.tritown.enums + +/** + * How often a per-player purchase limit starts over. + * + * Windows are counted from the epoch rather than from the first purchase, so + * every player's limit rolls over at the same moment. + */ +enum class LimitPeriod { + + /** The limit is a lifetime total and never resets. */ + NONE, + + /** The limit resets every 24 hours. */ + DAILY, + + /** The limit resets every 7 days. */ + WEEKLY, +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/MatchMode.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/MatchMode.kt new file mode 100644 index 0000000..75f5d6b --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/MatchMode.kt @@ -0,0 +1,19 @@ +package net.trilleo.mc.plugins.tritown.enums + +/** + * How closely a player's stack has to resemble a shop entry before the shop + * will buy it back, or accept it as part of a price. + */ +enum class MatchMode { + + /** + * Every property must match: name, lore, enchantments, custom model data + * and any custom data a plugin attached. This is the default, so a shop + * that sells a custom item does not buy back a plain one of the same + * material. + */ + EXACT, + + /** Only the material must match, so any plain stack of it is accepted. */ + MATERIAL, +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/PagedLayout.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/PagedLayout.kt new file mode 100644 index 0000000..e0e1e14 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/PagedLayout.kt @@ -0,0 +1,22 @@ +package net.trilleo.mc.plugins.tritown.enums + +/** + * How a paged GUI arranges its content area. + * + * The navigation row is reserved either way; this only decides what the rest of + * the inventory does with the space above it. + */ +enum class PagedLayout { + + /** Every slot above the navigation row holds content. The most room, and no border. */ + FULL, + + /** + * Content sits in an inset block with a one-slot border around it. + * + * Costs the outer ring of slots — a six-row menu holds 28 items a page + * rather than 45 — and buys a menu that reads as a thing rather than as a + * grid of loose items. + */ + FRAMED, +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/ShopSortMode.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/ShopSortMode.kt new file mode 100644 index 0000000..7a361e6 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/ShopSortMode.kt @@ -0,0 +1,23 @@ +package net.trilleo.mc.plugins.tritown.enums + +/** + * The orders the shop editor can arrange a shop's entries into. + * + * A sort is an alternative to arranging entries by hand, not a property of the + * shop: it is applied once, on request, and the result is an ordinary order + * that can be rearranged afterwards. + */ +enum class ShopSortMode { + + /** By item name, A to Z. */ + NAME, + + /** By item name, Z to A. */ + NAME_REVERSED, + + /** By what the entry is sold for, cheapest first. */ + PRICE, + + /** By what the entry is sold for, dearest first. */ + PRICE_REVERSED, +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/StatsWindow.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/StatsWindow.kt new file mode 100644 index 0000000..121e465 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/StatsWindow.kt @@ -0,0 +1,33 @@ +package net.trilleo.mc.plugins.tritown.enums + +/** + * How far back the admin panel's figures reach. + * + * The window is a property of who is looking rather than of the data: the + * statistics are kept hour by hour, and a window is simply how many of those + * hours are added up. + */ +enum class StatsWindow(val hours: Int) { + + DAY(24), + WEEK(24 * 7), + MONTH(24 * 30), + + /** Everything still on record, however far back the retention reaches. */ + ALL(0); + + /** The translation key naming this window, spelled out so the language test can see it. */ + val key: String + get() = when (this) { + DAY -> "gui.admin-economy.window-day" + WEEK -> "gui.admin-economy.window-week" + MONTH -> "gui.admin-economy.window-month" + ALL -> "gui.admin-economy.window-all" + } + + /** The next window in the cycle, so a click can step through them. */ + fun next(): StatsWindow = entries[(ordinal + 1) % entries.size] + + /** The previous window in the cycle. */ + fun previous(): StatsWindow = entries[(ordinal + entries.size - 1) % entries.size] +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/TownyRequirement.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/TownyRequirement.kt new file mode 100644 index 0000000..670e43a --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/TownyRequirement.kt @@ -0,0 +1,29 @@ +package net.trilleo.mc.plugins.tritown.enums + +/** + * What a player's standing in Towny has to be before a shop or one of its + * entries is open to them. + * + * Checked against [com.palmergames.bukkit.towny.TownyAPI] at the moment of the + * click, never cached — Towny stays the source of truth. + */ +enum class TownyRequirement { + + /** No requirement at all. */ + NONE, + + /** The player must belong to a town. */ + HAS_TOWN, + + /** The player must not belong to a town, for a newcomers' shop. */ + NO_TOWN, + + /** The player's town must belong to a nation. */ + HAS_NATION, + + /** The player must be the mayor of their town. */ + IS_MAYOR, + + /** The player must be the king of their nation. */ + IS_KING, +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/AdminPanelGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/AdminPanelGUI.kt new file mode 100644 index 0000000..e7777b3 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/AdminPanelGUI.kt @@ -0,0 +1,146 @@ +package net.trilleo.mc.plugins.tritown.guis.admin + +import com.palmergames.bukkit.towny.TownyAPI +import net.trilleo.mc.plugins.tritown.Main +import net.trilleo.mc.plugins.tritown.config.ShopSettings +import net.trilleo.mc.plugins.tritown.economy.EconomyPulse +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.enums.StatsWindow +import net.trilleo.mc.plugins.tritown.guis.shop.ShopRender +import net.trilleo.mc.plugins.tritown.registration.GUIFrame +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PluginGUI +import net.trilleo.mc.plugins.tritown.shops.ShopManager +import net.trilleo.mc.plugins.tritown.utils.EconomyUtil +import net.trilleo.mc.plugins.tritown.utils.TownyUtil +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Bukkit +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack + +/** + * The way in to everything an administrator runs the server from. + * + * Deliberately thin: it names the sections and shows just enough of each to say + * whether it is worth opening. Everything a section knows lives in that + * section's own menu, so adding one here is adding a card, not rewriting this. + */ +class AdminPanelGUI : PluginGUI( + id = ID, + titleKey = "gui.admin.title", + rows = 3, + fillMode = FillMode.NONE, +) { + + override fun setup(player: Player, inventory: Inventory) { + GUIFrame.draw(inventory, listOf(ECONOMY_SLOT, SHOPS_SLOT, SERVER_SLOT)) + + if (player.hasPermission(ECONOMY_PERMISSION)) inventory.setItem(ECONOMY_SLOT, economy(player)) + if (player.hasPermission(SHOPS_PERMISSION)) inventory.setItem(SHOPS_SLOT, shops(player)) + inventory.setItem(SERVER_SLOT, server(player)) + } + + override fun onClick(event: InventoryClickEvent) { + event.isCancelled = true + val player = event.whoClicked as? Player ?: return + + when (event.rawSlot) { + ECONOMY_SLOT -> if (player.hasPermission(ECONOMY_PERMISSION)) { + GUIManager.openLater(player, EconomyPanelGUI.ID) + } + + SHOPS_SLOT -> if (player.hasPermission(SHOPS_PERMISSION)) { + GUIManager.openLater(player, AdminShopsGUI.ID) + } + } + } + + // ── Cards ─────────────────────────────────────────────────────────── + + private fun economy(player: Player): ItemStack { + val lines = mutableListOf(player.tr("gui.admin.economy-lore")) + + if (!EconomyPulse.isEnabled) { + lines += player.tr("gui.admin.economy-stats-off") + } else { + val supply = EconomyPulse.latest() + val flow = EconomyPulse.window(StatsWindow.DAY.hours) + lines += "" + lines += player.tr( + "gui.admin.economy-supply", + "amount" to PanelRender.money(supply?.total ?: 0L), + ) + lines += player.tr( + "gui.admin.economy-net", + "amount" to PanelRender.delta(player, flow.net), + "window" to player.tr(StatsWindow.DAY.key), + ) + lines += player.tr("gui.admin.economy-accounts", "amount" to (supply?.accountCount ?: 0)) + } + + lines += "" + lines += player.tr("gui.admin.click-open") + return PanelRender.card(Material.GOLD_INGOT, player.tr("gui.admin.economy"), lines) + } + + private fun shops(player: Player): ItemStack { + val lines = mutableListOf(player.tr("gui.admin.shops-lore")) + + if (!ShopSettings.isLoaded || !ShopSettings.snapshot.enabled || !ShopManager.isReady) { + lines += player.tr("gui.admin.shops-off") + } else { + val shops = ShopManager.all() + val moneyIn = shops.sumOf { shop -> shop.entries.sumOf { it.stats.moneyIn } } + val moneyOut = shops.sumOf { shop -> shop.entries.sumOf { it.stats.moneyOut } } + lines += "" + lines += player.tr("gui.admin.shops-count", "amount" to shops.size) + lines += player.tr("gui.admin.shops-taken", "amount" to ShopRender.money(moneyIn)) + lines += player.tr("gui.admin.shops-paid", "amount" to ShopRender.money(moneyOut)) + } + + lines += "" + lines += player.tr("gui.admin.click-open") + return PanelRender.card(Material.EMERALD, player.tr("gui.admin.shops"), lines) + } + + /** + * What the server is running, in the two or three numbers that say whether + * anything is wrong before the sections are opened. + */ + private fun server(player: Player): ItemStack { + val towny = TownyAPI.getInstance() + val provider = runCatching { EconomyUtil.economy.name }.getOrNull() + + val lines = listOf( + player.tr("gui.admin.server-version", "version" to Main.instance.pluginMeta.version), + player.tr( + "gui.admin.server-provider", + "provider" to (provider ?: player.tr("gui.admin.server-no-provider")), + ), + "", + player.tr("gui.admin.server-towns", "amount" to towny.towns.size), + player.tr("gui.admin.server-nations", "amount" to towny.nations.size), + player.tr("gui.admin.server-online", "amount" to Bukkit.getOnlinePlayers().size), + player.tr( + "gui.admin.server-newday", + "time" to TownyUtil.duration(player, TownyUtil.secondsUntilNewDay()), + ), + ) + + return PanelRender.card(Material.BEACON, player.tr("gui.admin.server"), lines) + } + + companion object { + const val ID = "admin-panel" + + const val ECONOMY_PERMISSION = "tritown.admin.economy" + const val SHOPS_PERMISSION = "tritown.admin.shops" + + private const val ECONOMY_SLOT = 11 + private const val SHOPS_SLOT = 13 + private const val SERVER_SLOT = 15 + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/AdminShopsGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/AdminShopsGUI.kt new file mode 100644 index 0000000..ce7d7f6 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/AdminShopsGUI.kt @@ -0,0 +1,153 @@ +package net.trilleo.mc.plugins.tritown.guis.admin + +import net.trilleo.mc.plugins.tritown.config.ShopSettings +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.enums.PagedLayout +import net.trilleo.mc.plugins.tritown.guis.shop.ShopEditorGUI +import net.trilleo.mc.plugins.tritown.guis.shop.ShopRender +import net.trilleo.mc.plugins.tritown.guis.shop.ShopStatsGUI +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PagedPluginGUI +import net.trilleo.mc.plugins.tritown.shops.ShopDefinition +import net.trilleo.mc.plugins.tritown.shops.ShopManager +import net.trilleo.mc.plugins.tritown.utils.ComponentUtil +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.inventory.ClickType +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.inventory.ItemStack + +/** + * What every shop on the server has traded, side by side. + * + * A single shop's figures already have a menu of their own, reached from its + * editor; this is the row above that, where a shop that is quietly draining the + * economy stands out next to the ones that are not. Clicking a shop opens its + * own figures, so the two are one view at two depths rather than two views of + * the same thing. + */ +class AdminShopsGUI : PagedPluginGUI( + id = ID, + titleKey = "gui.admin-shops.title", + rows = 6, + fillMode = FillMode.NONE, + layout = PagedLayout.FRAMED, +) { + + override fun getItems(player: Player): List { + if (!ShopSettings.isLoaded || !ShopSettings.snapshot.enabled || !ShopManager.isReady) { + return listOf( + PanelRender.card( + Material.BARRIER, + player.tr("gui.admin-shops.disabled"), + listOf(player.tr("gui.admin-shops.disabled-lore")), + ) + ) + } + + val shops = ShopManager.all() + if (shops.isEmpty()) { + return listOf( + PanelRender.card( + Material.BARRIER, + player.tr("gui.admin-shops.empty"), + listOf(player.tr("gui.admin-shops.empty-lore")), + ) + ) + } + + return listOf(header(player, shops)) + shops.map { row(player, it) } + } + + override fun navButtons(player: Player): Map = mapOf( + BACK_OFFSET to PanelRender.card( + Material.ARROW, + player.tr("gui.admin-shops.back"), + listOf(player.tr("gui.admin-shops.back-lore")), + ), + ) + + override fun onNavClick(event: InventoryClickEvent, offset: Int) { + val player = event.whoClicked as? Player ?: return + if (offset == BACK_OFFSET) GUIManager.openLater(player, AdminPanelGUI.ID) + } + + override fun onContentClick(event: InventoryClickEvent, page: Int) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + if (!ShopManager.isReady) return + + // The header takes the first position, so the shops start one behind it. + val index = (contentIndex(page, event.rawSlot) ?: return) - 1 + val shop = ShopManager.all().getOrNull(index) ?: return + + if (event.click == ClickType.SHIFT_LEFT) { + if (player.hasPermission(EDIT_PERMISSION)) ShopRender.navigate { ShopEditorGUI.show(player, shop) } + return + } + ShopRender.navigate { ShopStatsGUI.show(player, shop) } + } + + // ── Rendering ─────────────────────────────────────────────────────── + + private fun header(player: Player, shops: List): ItemStack { + val moneyIn = shops.sumOf { shop -> shop.entries.sumOf { it.stats.moneyIn } } + val moneyOut = shops.sumOf { shop -> shop.entries.sumOf { it.stats.moneyOut } } + val bought = shops.sumOf { shop -> shop.entries.sumOf { it.stats.bought } } + val sold = shops.sumOf { shop -> shop.entries.sumOf { it.stats.sold } } + + return PanelRender.card( + Material.WRITABLE_BOOK, + player.tr("gui.admin-shops.header"), + listOf( + player.tr("gui.admin-shops.count", "amount" to shops.size), + player.tr("gui.admin-shops.entries", "amount" to shops.sumOf { it.entries.size }), + "", + player.tr("gui.shop-stats.bought", "amount" to bought), + player.tr("gui.shop-stats.sold", "amount" to sold), + player.tr("gui.shop-stats.money-in", "amount" to ShopRender.money(moneyIn)), + player.tr("gui.shop-stats.money-out", "amount" to ShopRender.money(moneyOut)), + player.tr("gui.shop-stats.net", "amount" to ShopRender.money(moneyIn - moneyOut)), + "", + player.tr("gui.admin-shops.header-lore"), + ), + ) + } + + private fun row(player: Player, shop: ShopDefinition): ItemStack { + val moneyIn = shop.entries.sumOf { it.stats.moneyIn } + val moneyOut = shop.entries.sumOf { it.stats.moneyOut } + + val lines = listOf( + player.tr("gui.admin-shops.id", "id" to shop.id), + player.tr("gui.admin-shops.entries", "amount" to shop.entries.size), + "", + player.tr("gui.shop-stats.money-in", "amount" to ShopRender.money(moneyIn)), + player.tr("gui.shop-stats.money-out", "amount" to ShopRender.money(moneyOut)), + player.tr("gui.shop-stats.net", "amount" to ShopRender.money(moneyIn - moneyOut)), + "", + player.tr("gui.admin-shops.click-stats"), + player.tr("gui.admin-shops.click-edit"), + ) + + // A shop wears its own goods, so a list of them is recognisable before a + // single line of it has been read. The shop's name is written by an + // administrator as MiniMessage, which is why it is not escaped. + val icon = (shop.entries.firstOrNull()?.displayStack() ?: ItemStack(Material.CHEST)).clone() + icon.editMeta { meta -> + meta.displayName(ComponentUtil.parse(shop.displayName)) + meta.lore(null) + } + + return ShopRender.withLore(icon, lines) + } + + companion object { + const val ID = "admin-shops" + + private const val EDIT_PERMISSION = "tritown.shop.admin.edit" + private const val BACK_OFFSET = 2 + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyFlowGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyFlowGUI.kt new file mode 100644 index 0000000..a9526d3 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyFlowGUI.kt @@ -0,0 +1,209 @@ +package net.trilleo.mc.plugins.tritown.guis.admin + +import net.trilleo.mc.plugins.tritown.economy.EconomyPulse +import net.trilleo.mc.plugins.tritown.enums.AccountType +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.enums.FlowCategory +import net.trilleo.mc.plugins.tritown.enums.PagedLayout +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PagedPluginGUI +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.inventory.ClickType +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +/** + * Every source of money and every holder of it, in full. + * + * The overview shows the three largest of each because that is what fits on a + * card; this is the same window opened out, so a category that only ever moves a + * little is still visible, and each one carries its own net — which is the line + * that says whether it feeds the economy or drains it. + * + * The figures are built once when the menu opens, because a paged menu asks for + * its items again on every page turn and re-totalling the window each time would + * do the same work several times per click. + */ +class EconomyFlowGUI : PagedPluginGUI( + id = ID, + titleKey = "gui.admin-flow.title", + rows = 6, + fillMode = FillMode.NONE, + layout = PagedLayout.FRAMED, +) { + + private val snapshots = ConcurrentHashMap>() + + override fun setup(player: Player, inventory: Inventory) { + snapshots[player.uniqueId] = render(player) + super.setup(player, inventory) + } + + override fun getItems(player: Player): List = snapshots[player.uniqueId] ?: emptyList() + + override fun navButtons(player: Player): Map = mapOf( + BACK_OFFSET to PanelRender.card( + Material.ARROW, + player.tr("gui.admin-flow.back"), + listOf(player.tr("gui.admin-flow.back-lore")), + ), + WINDOW_OFFSET to PanelRender.card( + Material.CLOCK, + player.tr("gui.admin-economy.window", "window" to player.tr(PanelState.window(player).key)), + listOf(player.tr("gui.admin-economy.window-lore")), + ), + ) + + override fun onNavClick(event: InventoryClickEvent, offset: Int) { + val player = event.whoClicked as? Player ?: return + + when (offset) { + BACK_OFFSET -> GUIManager.openLater(player, EconomyPanelGUI.ID) + WINDOW_OFFSET -> { + PanelState.cycle(player, event.click != ClickType.RIGHT) + setup(player, event.inventory) + } + } + } + + override fun onClose(event: InventoryCloseEvent) { + super.onClose(event) + snapshots.remove((event.player as? Player)?.uniqueId ?: return) + } + + // ── Rendering ─────────────────────────────────────────────────────── + + private fun render(player: Player): List { + if (!EconomyPulse.isEnabled) { + return listOf( + PanelRender.card( + Material.BARRIER, + player.tr("gui.admin-economy.disabled"), + listOf(player.tr("gui.admin-economy.disabled-lore")), + ) + ) + } + + val window = PanelState.window(player) + val flow = EconomyPulse.window(window.hours) + + val items = mutableListOf(header(player, flow, window.key)) + flow.categories().forEach { items += category(player, flow, it) } + + items += PanelRender.card( + Material.PAPER, + player.tr("gui.admin-flow.holders"), + listOf(player.tr("gui.admin-flow.holders-lore")), + ) + holders(flow).forEach { items += holder(player, flow, it) } + + return items + } + + private fun header(player: Player, flow: EconomyPulse.Flow, windowKey: String): ItemStack = PanelRender.card( + Material.BOOK, + player.tr("gui.admin-flow.header", "window" to player.tr(windowKey)), + listOf( + player.tr( + "gui.admin-economy.window-range", + "from" to PanelRender.time(flow.from), + "to" to PanelRender.time(flow.to), + ), + "", + player.tr("gui.admin-economy.generation-total", "amount" to PanelRender.money(flow.created)), + player.tr("gui.admin-economy.sinks-total", "amount" to PanelRender.money(flow.destroyed)), + player.tr("gui.admin-economy.net-total", "amount" to PanelRender.delta(player, flow.net)), + "", + player.tr("gui.admin-flow.movements", "amount" to flow.movements), + player.tr("gui.admin-economy.circulation-volume", "amount" to PanelRender.money(flow.circulated)), + ), + ) + + private fun category(player: Player, flow: EconomyPulse.Flow, category: FlowCategory): ItemStack { + val created = flow.createdBy[category] ?: 0L + val destroyed = flow.destroyedBy[category] ?: 0L + + return PanelRender.card( + material(category), + player.tr("gui.admin-flow.category", "name" to PanelRender.categoryName(player, category)), + listOf( + player.tr( + "gui.admin-flow.created", + "amount" to PanelRender.money(created), + "percent" to PanelRender.percent(PanelRender.share(created, flow.created)), + ), + player.tr( + "gui.admin-flow.destroyed", + "amount" to PanelRender.money(destroyed), + "percent" to PanelRender.percent(PanelRender.share(destroyed, flow.destroyed)), + ), + player.tr("gui.admin-flow.net", "amount" to PanelRender.delta(player, created - destroyed)), + "", + player.tr(descriptionOf(category)), + ), + ) + } + + private fun holder(player: Player, flow: EconomyPulse.Flow, type: AccountType): ItemStack { + val received = flow.createdTo[type] ?: 0L + val paid = flow.destroyedFrom[type] ?: 0L + + return PanelRender.card( + material(type), + player.tr("gui.admin-flow.holder", "name" to PanelRender.holderName(player, type)), + listOf( + player.tr("gui.admin-flow.received", "amount" to PanelRender.money(received)), + player.tr("gui.admin-flow.paid", "amount" to PanelRender.money(paid)), + player.tr("gui.admin-flow.net", "amount" to PanelRender.delta(player, received - paid)), + ), + ) + } + + /** Every kind of account that saw any money, most movement first. */ + private fun holders(flow: EconomyPulse.Flow): List = + (flow.createdTo.keys + flow.destroyedFrom.keys) + .sortedByDescending { (flow.createdTo[it] ?: 0L) + (flow.destroyedFrom[it] ?: 0L) } + + private fun material(category: FlowCategory): Material = when (category) { + FlowCategory.STARTING_BALANCE -> Material.EGG + FlowCategory.SHOP -> Material.EMERALD + FlowCategory.TOWNY -> Material.BELL + FlowCategory.ADMIN -> Material.COMMAND_BLOCK + FlowCategory.PAYMENT -> Material.ENDER_PEARL + FlowCategory.EXTERNAL -> Material.REDSTONE + FlowCategory.OTHER -> Material.PAPER + } + + private fun material(type: AccountType): Material = when (type) { + AccountType.PLAYER -> Material.PLAYER_HEAD + AccountType.TOWN -> Material.BRICKS + AccountType.NATION -> Material.GOLDEN_HELMET + AccountType.NPC -> Material.VILLAGER_SPAWN_EGG + AccountType.SERVER -> Material.COMMAND_BLOCK + AccountType.UNKNOWN -> Material.BARRIER + } + + /** Spelled out rather than built from the enum, so every key is a literal the language test can see. */ + private fun descriptionOf(category: FlowCategory): String = when (category) { + FlowCategory.STARTING_BALANCE -> "gui.admin-flow.about-starting-balance" + FlowCategory.SHOP -> "gui.admin-flow.about-shop" + FlowCategory.TOWNY -> "gui.admin-flow.about-towny" + FlowCategory.ADMIN -> "gui.admin-flow.about-admin" + FlowCategory.PAYMENT -> "gui.admin-flow.about-payment" + FlowCategory.EXTERNAL -> "gui.admin-flow.about-external" + FlowCategory.OTHER -> "gui.admin-flow.about-other" + } + + companion object { + const val ID = "admin-flow" + + private const val BACK_OFFSET = 2 + private const val WINDOW_OFFSET = 6 + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyPanelGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyPanelGUI.kt new file mode 100644 index 0000000..93a528f --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyPanelGUI.kt @@ -0,0 +1,614 @@ +package net.trilleo.mc.plugins.tritown.guis.admin + +import net.kyori.adventure.key.Key +import net.kyori.adventure.sound.Sound +import net.trilleo.mc.plugins.tritown.Main +import net.trilleo.mc.plugins.tritown.config.EconomySettings +import net.trilleo.mc.plugins.tritown.config.ShopSettings +import net.trilleo.mc.plugins.tritown.economy.* +import net.trilleo.mc.plugins.tritown.enums.AccountType +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.enums.FlowCategory +import net.trilleo.mc.plugins.tritown.guis.shop.ShopRender +import net.trilleo.mc.plugins.tritown.registration.GUIFrame +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PluginGUI +import net.trilleo.mc.plugins.tritown.shops.ShopManager +import net.trilleo.mc.plugins.tritown.utils.ComponentUtil +import net.trilleo.mc.plugins.tritown.utils.EconomyUtil +import net.trilleo.mc.plugins.tritown.utils.sendPrefixed +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.inventory.ClickType +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack +import kotlin.math.abs +import kotlin.math.roundToLong + +/** + * What the server's economy is doing, on one screen. + * + * The top row is the economy as it stands — how much currency exists, who holds + * it and how unevenly. The middle row is what has moved over the window the + * viewer has chosen, which is where money is created and where it drains away. + * The bottom row is the same window drawn as a column per slice, so a payday, a + * duplication bug or a shop nobody can afford shows up as a shape rather than a + * number. + * + * Nothing here is cached: the figures are read from [EconomyPulse] when the menu + * is drawn, which costs a walk over the hours still kept and no disk at all. The + * ledger itself is only ever measured on the flush task, so a menu that opens + * shows the last measurement rather than taking a new one. + */ +class EconomyPanelGUI : PluginGUI( + id = ID, + titleKey = "gui.admin-economy.title", + rows = ROWS, + fillMode = FillMode.NONE, +) { + + override fun setup(player: Player, inventory: Inventory) { + GUIFrame.draw(inventory, GUIFrame.contentSlots(ROWS)) + render(player, inventory) + } + + override fun onClick(event: InventoryClickEvent) { + event.isCancelled = true + val player = event.whoClicked as? Player ?: return + + when (event.rawSlot) { + SHOPS -> if (player.hasPermission(AdminPanelGUI.SHOPS_PERMISSION)) { + GUIManager.openLater(player, AdminShopsGUI.ID) + } + + BREAKDOWN -> GUIManager.openLater(player, EconomyFlowGUI.ID) + + BACK -> GUIManager.openLater(player, AdminPanelGUI.ID) + + WINDOW -> { + PanelState.cycle(player, event.click != ClickType.RIGHT) + click(player) + render(player, event.inventory) + } + + REFRESH -> { + click(player) + render(player, event.inventory) + } + + HEALTH -> { + val plugin = Main.instance + plugin.server.scheduler.runTaskAsynchronously(plugin, Runnable { EconomyService.flush() }) + player.sendPrefixed(player.tr("command.eco.flushing")) + } + } + } + + // ── Rendering ─────────────────────────────────────────────────────── + + private fun render(player: Player, inventory: Inventory) { + inventory.setItem( + BACK, + button(Material.ARROW, player.tr("gui.admin-economy.back"), player.tr("gui.admin-economy.back-lore")), + ) + + if (!EconomyPulse.isEnabled) { + renderDisabled(player, inventory) + return + } + + val window = PanelState.window(player) + val flow = EconomyPulse.window(window.hours, CHART.size) + val supply = EconomyPulse.latest() + val previous = EconomyPulse.sampleAt(flow.from) + val days = ((flow.to - flow.from).toDouble() / MILLIS_PER_DAY).coerceAtLeast(MINIMUM_DAYS) + + inventory.setItem(SUPPLY, supplyCard(player, supply, previous, window.key)) + inventory.setItem(ACCOUNTS, accountsCard(player, supply)) + inventory.setItem(DISTRIBUTION, distributionCard(player, supply)) + inventory.setItem(LEADERS, leadersCard(player)) + inventory.setItem(CIRCULATION, circulationCard(player, flow, supply, days)) + inventory.setItem(SHOPS, shopsCard(player, flow)) + inventory.setItem(HEALTH, healthCard(player)) + + inventory.setItem(GENERATION, generationCard(player, flow, days)) + inventory.setItem(SINKS, sinksCard(player, flow, days)) + inventory.setItem(NET, netCard(player, flow, supply, days)) + inventory.setItem(TOWNY, townyCard(player, flow)) + inventory.setItem(ADMIN, adminCard(player, flow)) + inventory.setItem(NEWCOMERS, newcomersCard(player, flow)) + inventory.setItem(BREAKDOWN, breakdownCard(player)) + + renderChart(player, inventory, flow) + + inventory.setItem( + WINDOW, + PanelRender.card( + Material.CLOCK, + player.tr("gui.admin-economy.window", "window" to player.tr(window.key)), + listOf( + player.tr( + "gui.admin-economy.window-range", + "from" to PanelRender.time(flow.from), + "to" to PanelRender.time(flow.to), + ), + "", + player.tr("gui.admin-economy.window-lore"), + ), + ), + ) + inventory.setItem( + REFRESH, + button( + Material.SPYGLASS, + player.tr("gui.admin-economy.refresh"), + player.tr("gui.admin-economy.refresh-lore"), + ), + ) + } + + /** Everything but the way out, for a server that has switched the figures off. */ + private fun renderDisabled(player: Player, inventory: Inventory) { + val slots = GUIFrame.contentSlots(ROWS) + val pane = GUIFrame.pane() + for (slot in slots) inventory.setItem(slot, pane.clone()) + + inventory.setItem( + slots[slots.size / 2], + PanelRender.card( + Material.BARRIER, + player.tr("gui.admin-economy.disabled"), + listOf(player.tr("gui.admin-economy.disabled-lore")), + ), + ) + } + + private fun supplyCard( + player: Player, + supply: EconomyPulse.Supply?, + previous: EconomyPulse.Supply?, + windowKey: String, + ): ItemStack { + if (supply == null) return measuring(player, Material.GOLD_BLOCK, "gui.admin-economy.supply") + + val elsewhere = supply.total - supply.held - supply.banked + val lines = listOf( + player.tr("gui.admin-economy.supply-total", "amount" to PanelRender.money(supply.total)), + "", + player.tr( + "gui.admin-economy.supply-wallets", + "amount" to PanelRender.money(supply.held), + "percent" to PanelRender.percent(PanelRender.share(supply.held, supply.total)), + ), + player.tr( + "gui.admin-economy.supply-banks", + "amount" to PanelRender.money(supply.banked), + "percent" to PanelRender.percent(PanelRender.share(supply.banked, supply.total)), + ), + player.tr( + "gui.admin-economy.supply-elsewhere", + "amount" to PanelRender.money(elsewhere), + "percent" to PanelRender.percent(PanelRender.share(elsewhere, supply.total)), + ), + "", + player.tr( + "gui.admin-economy.supply-change", + "window" to player.tr(windowKey), + "amount" to PanelRender.delta(player, supply.total - (previous?.total ?: supply.total)), + ), + player.tr( + "gui.admin-economy.supply-average", + "amount" to PanelRender.money(if (supply.wallets == 0) 0L else supply.held / supply.wallets), + ), + player.tr("gui.admin-economy.measured", "time" to PanelRender.time(supply.at)), + ) + + return PanelRender.card(Material.GOLD_BLOCK, player.tr("gui.admin-economy.supply"), lines) + } + + private fun accountsCard(player: Player, supply: EconomyPulse.Supply?): ItemStack { + if (supply == null) return measuring(player, Material.PLAYER_HEAD, "gui.admin-economy.accounts") + + val other = (supply.accounts[AccountType.NPC] ?: 0) + (supply.accounts[AccountType.SERVER] ?: 0) + val lines = listOf( + player.tr("gui.admin-economy.accounts-total", "amount" to supply.accountCount), + "", + player.tr("gui.admin-economy.accounts-wallets", "amount" to supply.wallets), + player.tr("gui.admin-economy.accounts-towns", "amount" to (supply.accounts[AccountType.TOWN] ?: 0)), + player.tr("gui.admin-economy.accounts-nations", "amount" to (supply.accounts[AccountType.NATION] ?: 0)), + player.tr("gui.admin-economy.accounts-other", "amount" to other), + "", + player.tr( + "gui.admin-economy.accounts-active", + "amount" to supply.activeWallets, + "percent" to PanelRender.percent( + if (supply.wallets == 0) 0.0 else supply.activeWallets.toDouble() / supply.wallets + ), + ), + ) + + return PanelRender.card(Material.PLAYER_HEAD, player.tr("gui.admin-economy.accounts"), lines) + } + + private fun distributionCard(player: Player, supply: EconomyPulse.Supply?): ItemStack { + if (supply == null) return measuring(player, Material.COMPARATOR, "gui.admin-economy.distribution") + + val lines = listOf( + player.tr("gui.admin-economy.distribution-median", "amount" to PanelRender.money(supply.median)), + player.tr("gui.admin-economy.distribution-mean", "amount" to PanelRender.money(supply.mean)), + player.tr("gui.admin-economy.distribution-richest", "amount" to PanelRender.money(supply.richest)), + "", + player.tr("gui.admin-economy.distribution-top", "percent" to PanelRender.percent(supply.topShare)), + player.tr( + "gui.admin-economy.distribution-gini", + "value" to PanelRender.percent(supply.gini), + "label" to player.tr(giniLabel(supply.gini)), + ), + "", + player.tr("gui.admin-economy.distribution-lore"), + ) + + return PanelRender.card(Material.COMPARATOR, player.tr("gui.admin-economy.distribution"), lines) + } + + private fun leadersCard(player: Player): ItemStack { + val snapshot = BaltopCache.current + val lines = mutableListOf() + + if (snapshot.entries.isEmpty()) { + lines += player.tr("gui.admin-economy.leaders-empty") + } else { + snapshot.entries.take(LEADER_LINES).forEachIndexed { index, entry -> + lines += player.tr( + "gui.admin-economy.leaders-line", + "rank" to index + 1, + "name" to name(entry), + "amount" to PanelRender.money(entry.balance.minor), + ) + } + lines += "" + lines += player.tr("gui.admin-economy.leaders-updated", "time" to PanelRender.time(snapshot.refreshedAt)) + } + + return PanelRender.card(Material.DIAMOND, player.tr("gui.admin-economy.leaders"), lines) + } + + private fun circulationCard( + player: Player, + flow: EconomyPulse.Flow, + supply: EconomyPulse.Supply?, + days: Double, + ): ItemStack { + val average = if (flow.transfers == 0L) 0L else flow.circulated / flow.transfers + val velocity = if (supply == null || supply.total <= 0L) { + 0.0 + } else { + (flow.circulated / days) / supply.total + } + + val lines = listOf( + player.tr("gui.admin-economy.circulation-volume", "amount" to PanelRender.money(flow.circulated)), + player.tr("gui.admin-economy.circulation-count", "amount" to flow.transfers), + player.tr("gui.admin-economy.circulation-average", "amount" to PanelRender.money(average)), + "", + player.tr("gui.admin-economy.circulation-velocity", "percent" to PanelRender.rate(velocity)), + "", + player.tr("gui.admin-economy.circulation-lore"), + ) + + return PanelRender.card(Material.ENDER_PEARL, player.tr("gui.admin-economy.circulation"), lines) + } + + private fun shopsCard(player: Player, flow: EconomyPulse.Flow): ItemStack { + val lines = mutableListOf() + + if (!ShopSettings.isLoaded || !ShopSettings.snapshot.enabled || !ShopManager.isReady) { + lines += player.tr("gui.admin-economy.shops-off") + } else { + val shops = ShopManager.all() + lines += player.tr("gui.admin-economy.shops-count", "amount" to shops.size) + lines += "" + lines += player.tr( + "gui.admin-economy.shops-created", + "amount" to PanelRender.money(flow.createdBy[FlowCategory.SHOP] ?: 0L), + ) + lines += player.tr( + "gui.admin-economy.shops-destroyed", + "amount" to PanelRender.money(flow.destroyedBy[FlowCategory.SHOP] ?: 0L), + ) + lines += player.tr( + "gui.admin-economy.shops-net", + "amount" to PanelRender.delta(player, flow.netOf(FlowCategory.SHOP)), + ) + lines += "" + lines += player.tr( + "gui.admin-economy.shops-lifetime", + "taken" to ShopRender.money(shops.sumOf { shop -> shop.entries.sumOf { it.stats.moneyIn } }), + "paid" to ShopRender.money(shops.sumOf { shop -> shop.entries.sumOf { it.stats.moneyOut } }), + ) + lines += "" + lines += player.tr("gui.admin-economy.click-shops") + } + + return PanelRender.card(Material.EMERALD, player.tr("gui.admin-economy.shops"), lines) + } + + private fun healthCard(player: Player): ItemStack { + val settings = EconomySettings.snapshot + val currency = CurrencyRegistry.primary + val provider = runCatching { EconomyUtil.economy.name }.getOrNull() + + val lines = listOf( + player.tr( + "gui.admin-economy.health-provider", + "provider" to (provider ?: player.tr("gui.admin.server-no-provider")), + ), + player.tr( + "gui.admin-economy.health-currency", + "currency" to ComponentUtil.escape(currency.plural), + "symbol" to ComponentUtil.escape(currency.symbol), + ), + player.tr( + "gui.admin-economy.health-storage", + "storage" to settings.storageType, + "seconds" to settings.flushIntervalSeconds, + ), + player.tr( + "gui.admin-economy.health-history", + "state" to player.tr(if (settings.history.enabled) "common.on" else "common.off"), + "days" to settings.history.retentionDays, + ), + player.tr( + "gui.admin-economy.health-stats", + "time" to (EconomyPulse.earliest()?.let(PanelRender::time) ?: player.tr("common.unknown")), + ), + "", + player.tr("gui.admin-economy.click-flush"), + ) + + return PanelRender.card(Material.REDSTONE_TORCH, player.tr("gui.admin-economy.health"), lines) + } + + private fun generationCard(player: Player, flow: EconomyPulse.Flow, days: Double): ItemStack { + val lines = mutableListOf( + player.tr("gui.admin-economy.generation-total", "amount" to PanelRender.money(flow.created)), + player.tr("gui.admin-economy.flow-rate", "amount" to PanelRender.money(perDay(flow.created, days))), + "", + ) + lines += breakdown(player, flow.createdBy, flow.created) + return PanelRender.card(Material.WATER_BUCKET, player.tr("gui.admin-economy.generation"), lines) + } + + private fun sinksCard(player: Player, flow: EconomyPulse.Flow, days: Double): ItemStack { + val lines = mutableListOf( + player.tr("gui.admin-economy.sinks-total", "amount" to PanelRender.money(flow.destroyed)), + player.tr("gui.admin-economy.flow-rate", "amount" to PanelRender.money(perDay(flow.destroyed, days))), + "", + ) + lines += breakdown(player, flow.destroyedBy, flow.destroyed) + return PanelRender.card(Material.HOPPER, player.tr("gui.admin-economy.sinks"), lines) + } + + private fun netCard( + player: Player, + flow: EconomyPulse.Flow, + supply: EconomyPulse.Supply?, + days: Double, + ): ItemStack { + val perDay = perDay(flow.net, days) + val total = supply?.total ?: 0L + val drift = if (total <= 0L) 0.0 else perDay.toDouble() / total + + val lines = mutableListOf( + player.tr("gui.admin-economy.net-total", "amount" to PanelRender.delta(player, flow.net)), + player.tr("gui.admin-economy.net-rate", "amount" to PanelRender.delta(player, perDay)), + player.tr("gui.admin-economy.net-drift", "percent" to PanelRender.rate(drift)), + "", + ) + + lines += when { + total <= 0L || perDay == 0L -> player.tr("gui.admin-economy.net-stable") + perDay > 0L -> player.tr("gui.admin-economy.net-doubling", "days" to total / perDay) + else -> player.tr("gui.admin-economy.net-emptying", "days" to total / -perDay) + } + + lines += "" + lines += player.tr("gui.admin-economy.net-lore") + + val material = when { + flow.net > 0L -> Material.LIME_DYE + flow.net < 0L -> Material.RED_DYE + else -> Material.GRAY_DYE + } + return PanelRender.card(material, player.tr("gui.admin-economy.net"), lines) + } + + private fun townyCard(player: Player, flow: EconomyPulse.Flow): ItemStack { + val created = flow.createdBy[FlowCategory.TOWNY] ?: 0L + val destroyed = flow.destroyedBy[FlowCategory.TOWNY] ?: 0L + val intoBanks = (flow.createdTo[AccountType.TOWN] ?: 0L) + (flow.createdTo[AccountType.NATION] ?: 0L) + val outOfBanks = + (flow.destroyedFrom[AccountType.TOWN] ?: 0L) + (flow.destroyedFrom[AccountType.NATION] ?: 0L) + + val lines = listOf( + player.tr("gui.admin-economy.towny-created", "amount" to PanelRender.money(created)), + player.tr("gui.admin-economy.towny-destroyed", "amount" to PanelRender.money(destroyed)), + player.tr("gui.admin-economy.towny-net", "amount" to PanelRender.delta(player, created - destroyed)), + "", + player.tr("gui.admin-economy.towny-in", "amount" to PanelRender.money(intoBanks)), + player.tr("gui.admin-economy.towny-out", "amount" to PanelRender.money(outOfBanks)), + "", + player.tr("gui.admin-economy.towny-lore"), + ) + + return PanelRender.card(Material.BELL, player.tr("gui.admin-economy.towny"), lines) + } + + private fun adminCard(player: Player, flow: EconomyPulse.Flow): ItemStack { + val given = flow.createdBy[FlowCategory.ADMIN] ?: 0L + val taken = flow.destroyedBy[FlowCategory.ADMIN] ?: 0L + + val lines = listOf( + player.tr("gui.admin-economy.admin-given", "amount" to PanelRender.money(given)), + player.tr("gui.admin-economy.admin-taken", "amount" to PanelRender.money(taken)), + player.tr("gui.admin-economy.admin-net", "amount" to PanelRender.delta(player, given - taken)), + "", + player.tr("gui.admin-economy.admin-set", "amount" to PanelRender.money(flow.adjusted)), + "", + player.tr("gui.admin-economy.admin-lore"), + ) + + return PanelRender.card(Material.COMMAND_BLOCK, player.tr("gui.admin-economy.admin"), lines) + } + + private fun newcomersCard(player: Player, flow: EconomyPulse.Flow): ItemStack { + val paid = flow.createdBy[FlowCategory.STARTING_BALANCE] ?: 0L + val each = CurrencyRegistry.primary.of(EconomySettings.snapshot.startingBalance).minor + + val lines = listOf( + player.tr("gui.admin-economy.newcomers-total", "amount" to PanelRender.money(paid)), + player.tr("gui.admin-economy.newcomers-count", "amount" to if (each <= 0L) 0L else paid / each), + "", + player.tr("gui.admin-economy.newcomers-each", "amount" to PanelRender.money(each)), + ) + + return PanelRender.card(Material.EGG, player.tr("gui.admin-economy.newcomers"), lines) + } + + private fun breakdownCard(player: Player): ItemStack = PanelRender.card( + Material.BOOK, + player.tr("gui.admin-economy.breakdown"), + listOf(player.tr("gui.admin-economy.breakdown-lore"), "", player.tr("gui.admin-economy.click-open")), + ) + + /** + * One column per slice of the window, as tall as the slice's net change is + * large next to the biggest one. + * + * The stack size is the bar: a column that moved nothing shows one pane and a + * column that moved the most shows sixty-four, which reads as a chart at a + * glance without a single pixel of custom texture. + */ + private fun renderChart(player: Player, inventory: Inventory, flow: EconomyPulse.Flow) { + val tallest = flow.slices.maxOfOrNull { abs(it.net) } ?: 0L + + flow.slices.forEachIndexed { index, slice -> + val slot = CHART.getOrNull(index) ?: return@forEachIndexed + val height = if (tallest <= 0L) 1 else ((abs(slice.net) * BAR_MAX) / tallest).toInt().coerceIn(1, BAR_MAX) + + val material = when { + slice.created == 0L && slice.destroyed == 0L -> Material.GRAY_STAINED_GLASS_PANE + slice.net > 0L -> Material.LIME_STAINED_GLASS_PANE + slice.net < 0L -> Material.RED_STAINED_GLASS_PANE + else -> Material.YELLOW_STAINED_GLASS_PANE + } + + val lines = listOf( + player.tr("gui.admin-economy.chart-created", "amount" to PanelRender.money(slice.created)), + player.tr("gui.admin-economy.chart-destroyed", "amount" to PanelRender.money(slice.destroyed)), + player.tr("gui.admin-economy.chart-net", "amount" to PanelRender.delta(player, slice.net)), + "", + player.tr("gui.admin-economy.chart-lore"), + ) + + val bar = PanelRender.card( + material, + player.tr( + "gui.admin-economy.chart", + "from" to PanelRender.time(slice.from), + "to" to PanelRender.time(slice.to), + ), + lines, + ) + bar.amount = height + inventory.setItem(slot, bar) + } + } + + // ── Helpers ───────────────────────────────────────────────────────── + + /** The three categories that moved the most, with their share of the total. */ + private fun breakdown(player: Player, amounts: Map, total: Long): List { + if (total <= 0L) return listOf(player.tr("gui.admin-economy.flow-empty")) + + return amounts.entries + .sortedByDescending { it.value } + .take(BREAKDOWN_LINES) + .map { (category, amount) -> + player.tr( + "gui.admin-economy.flow-line", + "name" to PanelRender.categoryName(player, category), + "amount" to PanelRender.money(amount), + "percent" to PanelRender.percent(PanelRender.share(amount, total)), + ) + } + } + + private fun measuring(player: Player, material: Material, nameKey: String): ItemStack = + PanelRender.card(material, player.tr(nameKey), listOf(player.tr("gui.admin-economy.measuring"))) + + private fun button(material: Material, name: String, lore: String): ItemStack = + PanelRender.card(material, name, listOf(lore)) + + private fun perDay(amount: Long, days: Double): Long = (amount / days).roundToLong() + + private fun name(entry: BaltopCache.Entry): String { + val stripped = if (entry.type.isGovernment) TownyAccountNaming.stripPrefix(entry.name) else entry.name + return ComponentUtil.escape(stripped) + } + + private fun giniLabel(gini: Double): String = when { + gini < 0.3 -> "gui.admin-economy.gini-even" + gini < 0.5 -> "gui.admin-economy.gini-fair" + gini < 0.7 -> "gui.admin-economy.gini-uneven" + else -> "gui.admin-economy.gini-extreme" + } + + private fun click(player: Player) { + player.playSound(Sound.sound(Key.key("minecraft:ui.button.click"), Sound.Source.UI, 1f, 1f)) + } + + companion object { + const val ID = "admin-economy" + + private const val ROWS = 5 + + private const val SUPPLY = 10 + private const val ACCOUNTS = 11 + private const val DISTRIBUTION = 12 + private const val LEADERS = 13 + private const val CIRCULATION = 14 + private const val SHOPS = 15 + private const val HEALTH = 16 + + private const val GENERATION = 19 + private const val SINKS = 20 + private const val NET = 21 + private const val TOWNY = 22 + private const val ADMIN = 23 + private const val NEWCOMERS = 24 + private const val BREAKDOWN = 25 + + private val CHART = (28..34).toList() + + private const val BACK = 38 + private const val WINDOW = 40 + private const val REFRESH = 42 + + /** How many lines a card spends naming the categories behind a total. */ + private const val BREAKDOWN_LINES = 3 + + /** How many accounts the leaderboard card lists. */ + private const val LEADER_LINES = 5 + + /** The tallest a chart column can be, which is a full stack. */ + private const val BAR_MAX = 64 + + private const val MILLIS_PER_DAY = 24.0 * 60.0 * 60.0 * 1000.0 + + /** Keeps a daily rate finite when the window is shorter than a day's worth of data. */ + private const val MINIMUM_DAYS = 1.0 / 24.0 + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/PanelRender.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/PanelRender.kt new file mode 100644 index 0000000..f202f74 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/PanelRender.kt @@ -0,0 +1,94 @@ +package net.trilleo.mc.plugins.tritown.guis.admin + +import net.trilleo.mc.plugins.tritown.config.EconomySettings +import net.trilleo.mc.plugins.tritown.economy.CurrencyRegistry +import net.trilleo.mc.plugins.tritown.economy.EconomyFormat +import net.trilleo.mc.plugins.tritown.economy.Money +import net.trilleo.mc.plugins.tritown.enums.AccountType +import net.trilleo.mc.plugins.tritown.enums.FlowCategory +import net.trilleo.mc.plugins.tritown.utils.ComponentUtil +import net.trilleo.mc.plugins.tritown.utils.LoreUtil +import net.trilleo.mc.plugins.tritown.utils.itemStack +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.inventory.ItemStack +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.* + +/** + * The pieces the admin panel's menus draw with. + * + * Every figure the panel shows is a number that has to read the same wherever + * it appears, so the money, the percentages and the cards themselves are built + * in one place rather than formatted again in each menu. + * + * Amounts arrive as minor units, which is how the economy holds and totals + * them; they are converted to text at this boundary and nowhere earlier. + */ +object PanelRender { + + private val percentFormat = DecimalFormat("0.0", DecimalFormatSymbols(Locale.ROOT)) + private val rateFormat = DecimalFormat("0.00", DecimalFormatSymbols(Locale.ROOT)) + + /** [minor] units of the primary currency, ready to embed in MiniMessage. */ + fun money(minor: Long): String = + if (!CurrencyRegistry.isLoaded) minor.toString() + else ComponentUtil.escape(EconomyFormat.plain(CurrencyRegistry.primary, Money(minor))) + + /** + * [minor] as a change: coloured and signed by the translation, so a figure + * that went up never reads the same as one that went down. + */ + fun delta(viewer: Player, minor: Long): String = when { + minor > 0L -> viewer.tr("gui.admin-economy.delta-up", "amount" to money(minor)) + minor < 0L -> viewer.tr("gui.admin-economy.delta-down", "amount" to money(-minor)) + else -> viewer.tr("gui.admin-economy.delta-flat") + } + + /** [fraction] as a percentage, e.g. `12.4%`. Anything that is not a number reads as zero. */ + fun percent(fraction: Double): String = + if (!fraction.isFinite()) "0.0%" else "${percentFormat.format(fraction * 100.0)}%" + + /** [fraction] as a percentage at two digits, for rates small enough that one would round them away. */ + fun rate(fraction: Double): String = + if (!fraction.isFinite()) "0.00%" else "${rateFormat.format(fraction * 100.0)}%" + + /** [part] as a share of [whole], or zero when there is no whole to share. */ + fun share(part: Long, whole: Long): Double = if (whole <= 0L) 0.0 else part.toDouble() / whole.toDouble() + + /** A menu card: a name and a block of wrapped lore. Blank lines are kept, so a card can be grouped. */ + fun card(material: Material, name: String, lines: List): ItemStack = itemStack(material) { + name(name) + meta { lore(LoreUtil.wrapLore(lines.joinToString(""))) } + } + + /** [millis] in the format `economy.history.time-format` sets, which is already the panel's clock elsewhere. */ + fun time(millis: Long): String = formatter().format(Instant.ofEpochMilli(millis)) + + /** A category's name in [viewer]'s language. */ + fun categoryName(viewer: Player, category: FlowCategory): String = viewer.tr(category.key) + + /** An account type's name in [viewer]'s language, as the plural a total is read in. */ + fun holderName(viewer: Player, type: AccountType): String = when (type) { + AccountType.PLAYER -> viewer.tr("gui.admin-economy.holder-players") + AccountType.TOWN -> viewer.tr("gui.admin-economy.holder-towns") + AccountType.NATION -> viewer.tr("gui.admin-economy.holder-nations") + AccountType.NPC -> viewer.tr("gui.admin-economy.holder-npcs") + AccountType.SERVER -> viewer.tr("gui.admin-economy.holder-server") + AccountType.UNKNOWN -> viewer.tr("gui.admin-economy.holder-unknown") + } + + private fun formatter(): DateTimeFormatter { + val pattern = if (EconomySettings.isLoaded) EconomySettings.snapshot.history.timeFormat else FALLBACK_TIME + return runCatching { DateTimeFormatter.ofPattern(pattern, Locale.ROOT) } + .getOrElse { DateTimeFormatter.ofPattern(FALLBACK_TIME, Locale.ROOT) } + .withZone(ZoneId.systemDefault()) + } + + private const val FALLBACK_TIME = "yyyy-MM-dd HH:mm" +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/PanelState.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/PanelState.kt new file mode 100644 index 0000000..c07a7d2 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/PanelState.kt @@ -0,0 +1,37 @@ +package net.trilleo.mc.plugins.tritown.guis.admin + +import net.trilleo.mc.plugins.tritown.enums.StatsWindow +import org.bukkit.entity.Player +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +/** + * How far back each administrator is looking. + * + * Held here rather than in one menu because the overview and the breakdown show + * the same figures at different depths: switching to the last week in one and + * then opening the other should not quietly go back to the last day. + * + * The choice is deliberately not persisted — it is a way of looking at the + * panel, not a setting — and is dropped when the last of the panel's menus is + * closed. + */ +object PanelState { + + private val windows = ConcurrentHashMap() + + /** The window [player] is looking at. */ + fun window(player: Player): StatsWindow = windows[player.uniqueId] ?: StatsWindow.DAY + + /** Moves [player] to the next window, or the previous one when [forward] is false. */ + fun cycle(player: Player, forward: Boolean): StatsWindow { + val next = if (forward) window(player).next() else window(player).previous() + windows[player.uniqueId] = next + return next + } + + /** Forgets what [player] was looking at. */ + fun forget(player: Player) { + windows.remove(player.uniqueId) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopConfirmGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopConfirmGUI.kt new file mode 100644 index 0000000..7034722 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopConfirmGUI.kt @@ -0,0 +1,141 @@ +package net.trilleo.mc.plugins.tritown.guis.shop + +import net.kyori.adventure.key.Key +import net.kyori.adventure.sound.Sound +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PluginGUI +import net.trilleo.mc.plugins.tritown.shops.ShopDefinition +import net.trilleo.mc.plugins.tritown.shops.ShopEntry +import net.trilleo.mc.plugins.tritown.shops.ShopManager +import net.trilleo.mc.plugins.tritown.shops.ShopTrade +import net.trilleo.mc.plugins.tritown.utils.LoreUtil +import net.trilleo.mc.plugins.tritown.utils.itemStack +import net.trilleo.mc.plugins.tritown.utils.sendPrefixed +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +/** + * A last look at an expensive purchase before it goes through. + * + * Only shown above `shops.confirm-above`, so an ordinary purchase is still a + * single click. The price is re-quoted when the player accepts rather than + * being carried over from here, so a discount that lapsed while the menu sat + * open cannot be spent. + */ +class ShopConfirmGUI : PluginGUI( + id = ID, + titleKey = "gui.shop-confirm.title", + rows = 3, + fillMode = FillMode.DARK, +) { + + private data class Pending(val shopId: String, val entryId: String, val bundles: Int) + + private val pending = ConcurrentHashMap() + + /** Asks [player] to confirm buying [bundles] of [entry]. */ + fun open(player: Player, shop: ShopDefinition, entry: ShopEntry, bundles: Int) { + pending[player.uniqueId] = Pending(shop.id, entry.id, bundles) + GUIManager.open(player, ID) + } + + override fun setup(player: Player, inventory: Inventory) { + val (shop, entry, bundles) = resolve(player) ?: return + val quote = ShopTrade.quoteBuy(player, entry, bundles) + + val lines = buildList { + add(player.tr("gui.shop-confirm.amount", "amount" to entry.bundleSize * bundles)) + if (quote != null && quote.hasMoney) { + add(player.tr("gui.shop-confirm.price", "price" to ShopRender.money(quote.money))) + } + entry.buy?.items?.forEach { add(ShopRender.itemLine(player, it, bundles)) } + } + + inventory.setItem(SLOT_GOODS, ShopRender.withLore(entry.displayStack(), lines)) + inventory.setItem( + SLOT_ACCEPT, + button(player, Material.LIME_CONCRETE, "gui.shop-confirm.accept", "gui.shop-confirm.accept-lore"), + ) + inventory.setItem( + SLOT_CANCEL, + button(player, Material.RED_CONCRETE, "gui.shop-confirm.cancel", "gui.shop-confirm.cancel-lore"), + ) + } + + override fun onClick(event: InventoryClickEvent) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + when (event.rawSlot) { + SLOT_ACCEPT -> accept(player) + SLOT_CANCEL -> back(player) + else -> return + } + } + + override fun onClose(event: InventoryCloseEvent) { + pending.remove((event.player as? Player)?.uniqueId ?: return) + } + + private fun accept(player: Player) { + val (shop, entry, bundles) = resolve(player) ?: return + when (val result = ShopTrade.buy(player, shop, entry, bundles)) { + is ShopTrade.Result.Success -> { + player.sendPrefixed( + player.tr( + "shop.traded", + "amount" to entry.bundleSize * result.bundles, + "item" to ShopRender.itemName(entry.item), + "price" to ShopRender.money(result.money), + ) + ) + player.playSound(Sound.sound(Key.key("minecraft:entity.villager.yes"), Sound.Source.UI, 1f, 1f)) + } + + is ShopTrade.Result.Failure -> player.sendPrefixed( + player.tr("common.error", "message" to player.tr(result.key, *result.args.toTypedArray())) + ) + } + + back(player) + } + + /** Returns to the shop the purchase came from, so a cancel does not dump the player back into the world. */ + private fun back(player: Player) { + val shop = pending[player.uniqueId]?.let { ShopManager.get(it.shopId) } + if (shop == null) { + player.closeInventory() + return + } + ShopRender.navigate { ShopGUI.show(player, shop) } + } + + private fun resolve(player: Player): Triple? { + val held = pending[player.uniqueId] ?: return null + val shop = ShopManager.get(held.shopId) ?: return null + val entry = shop.entry(held.entryId) ?: return null + return Triple(shop, entry, held.bundles) + } + + private fun button(player: Player, material: Material, nameKey: String, loreKey: String): ItemStack = + itemStack(material) { + name(player.tr(nameKey)) + meta { lore(LoreUtil.wrapLore(player.tr(loreKey))) } + } + + companion object { + const val ID = "shop-confirm" + + private const val SLOT_GOODS = 13 + private const val SLOT_ACCEPT = 11 + private const val SLOT_CANCEL = 15 + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopCostGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopCostGUI.kt new file mode 100644 index 0000000..78562e5 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopCostGUI.kt @@ -0,0 +1,165 @@ +package net.trilleo.mc.plugins.tritown.guis.shop + +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.registration.GUIFrame +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PluginGUI +import net.trilleo.mc.plugins.tritown.shops.ShopCost +import net.trilleo.mc.plugins.tritown.shops.ShopDefinition +import net.trilleo.mc.plugins.tritown.shops.ShopEntry +import net.trilleo.mc.plugins.tritown.shops.ShopManager +import net.trilleo.mc.plugins.tritown.utils.LoreUtil +import net.trilleo.mc.plugins.tritown.utils.itemStack +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.event.inventory.InventoryDragEvent +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +/** + * The items on one side of an entry — what a purchase costs in goods, or what a + * sale pays out in them. + * + * Items are added by clicking a stack in your own inventory, exactly as in the + * shop editor, and the stack's own size becomes the quantity: clicking a stack + * of 8 iron makes the price 8 iron. Nothing leaves the administrator's + * inventory at any point. + */ +class ShopCostGUI : PluginGUI( + id = ID, + titleKey = "gui.shop-cost.title", + rows = ROWS, + fillMode = FillMode.NONE, +) { + + private data class Target(val shopId: String, val entryId: String, val buying: Boolean) + + private val editing = ConcurrentHashMap() + + /** Opens the item side of [entry]'s price when [buying], or of its payout when not. */ + fun open(player: Player, shop: ShopDefinition, entry: ShopEntry, buying: Boolean) { + editing[player.uniqueId] = Target(shop.id, entry.id, buying) + GUIManager.open(player, ID) + } + + override fun setup(player: Player, inventory: Inventory) { + val (_, entry, buying) = resolve(player) ?: return + + inventory.clear() + GUIFrame.draw(inventory, CONTENT_SLOTS) + + cost(entry, buying).items.forEachIndexed { index, item -> + CONTENT_SLOTS.getOrNull(index)?.let { inventory.setItem(it, describe(player, item)) } + } + + inventory.setItem( + SLOT_BACK, + button(player, Material.ARROW, "gui.shop-cost.back", "gui.shop-cost.back-lore"), + ) + inventory.setItem( + SLOT_HINT, + button(player, Material.PAPER, "gui.shop-cost.add", "gui.shop-cost.add-lore"), + ) + } + + override fun onClick(event: InventoryClickEvent) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + val (shop, entry, buying) = resolve(player) ?: return + + if (event.clickedInventory === player.inventory) { + event.currentItem?.let { add(player, entry, buying, it) } + setup(player, event.view.topInventory) + return + } + + if (event.rawSlot == SLOT_BACK) { + ShopRender.navigate { ShopEntryGUI.show(player, shop, entry) } + return + } + + val position = CONTENT_SLOTS.indexOf(event.rawSlot) + if (position < 0) return + + val items = cost(entry, buying).items + if (position >= items.size) return + + apply(entry, buying, items.filterIndexed { index, _ -> index != position }) + ShopManager.save() + setup(player, event.inventory) + } + + override fun onDrag(event: InventoryDragEvent) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + val (_, entry, buying) = resolve(player) ?: return + add(player, entry, buying, event.oldCursor) + setup(player, event.view.topInventory) + } + + override fun onClose(event: InventoryCloseEvent) { + editing.remove((event.player as? Player)?.uniqueId ?: return) + } + + private fun add(player: Player, entry: ShopEntry, buying: Boolean, stack: ItemStack) { + if (stack.type.isAir) return + + val items = cost(entry, buying).items + if (items.size >= CONTENT_SLOTS.size) return + + apply(entry, buying, items + stack.clone()) + ShopManager.save() + } + + private fun apply(entry: ShopEntry, buying: Boolean, items: List) { + if (buying) { + entry.buy = (entry.buy ?: ShopCost.FREE).copy(items = items) + } else { + entry.sell = (entry.sell ?: ShopCost.FREE).copy(items = items) + } + } + + private fun cost(entry: ShopEntry, buying: Boolean): ShopCost = + (if (buying) entry.buy else entry.sell) ?: ShopCost.FREE + + private fun describe(player: Player, item: ItemStack): ItemStack = + ShopRender.withLore(item, listOf(player.tr("gui.shop-cost.click-remove"))) + + private fun button(player: Player, material: Material, nameKey: String, loreKey: String): ItemStack = + itemStack(material) { + name(player.tr(nameKey)) + meta { lore(LoreUtil.wrapLore(player.tr(loreKey))) } + } + + private fun resolve(player: Player): Triple? { + val target = editing[player.uniqueId] ?: return null + val shop = ShopManager.get(target.shopId) ?: return null + val entry = shop.entry(target.entryId) ?: return null + return Triple(shop, entry, target.buying) + } + + companion object { + const val ID = "shop-cost" + + /** The slots inside the border; the last row carries the way back out. */ + private val CONTENT_SLOTS = GUIFrame.contentSlots(ROWS) + + private const val ROWS = 6 + private const val SLOT_BACK = 45 + private const val SLOT_HINT = 49 + + /** Opens the item side of a price through the registered instance. */ + fun show(player: Player, shop: ShopDefinition, entry: ShopEntry, buying: Boolean): Boolean { + val gui = GUIManager.getGUI(ID) as? ShopCostGUI ?: return false + gui.open(player, shop, entry, buying) + return true + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEditorGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEditorGUI.kt new file mode 100644 index 0000000..2c5c999 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEditorGUI.kt @@ -0,0 +1,345 @@ +package net.trilleo.mc.plugins.tritown.guis.shop + +import net.kyori.adventure.key.Key +import net.kyori.adventure.sound.Sound +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.enums.PagedLayout +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PagedPluginGUI +import net.trilleo.mc.plugins.tritown.shops.ShopCost +import net.trilleo.mc.plugins.tritown.shops.ShopDefinition +import net.trilleo.mc.plugins.tritown.shops.ShopEntry +import net.trilleo.mc.plugins.tritown.shops.ShopManager +import net.trilleo.mc.plugins.tritown.utils.LoreUtil +import net.trilleo.mc.plugins.tritown.utils.itemStack +import net.trilleo.mc.plugins.tritown.utils.sendPrefixed +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.inventory.ClickType +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.event.inventory.InventoryDragEvent +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +/** + * What one shop sells, in the order players see it, and where entries are + * added, rearranged and removed. + * + * An entry is added by clicking a stack in your own inventory, or dragging one + * over the menu. Nothing actually moves: the stack is copied, custom data and + * all, and stays where it was. An editor that took the item would lose it to a + * crash or a mistimed close, and an administrator setting up a shop is usually + * holding the only copy of whatever they are adding. + * + * Reordering works the same way — nothing is picked up in the inventory sense. + * A right click marks an entry as the one being moved, and the next click on a + * slot says where it goes. Real item movement would need live slots in a menu + * that must never hold the only copy of an item, and a mark survives turning + * the page, which a stack on the cursor does not. + * + * The actions live in the navigation row rather than after the last entry, so + * they stay under the same finger however many entries the shop grows. + */ +class ShopEditorGUI : PagedPluginGUI( + id = ID, + titleKey = "gui.shop-editor.title", + rows = 6, + fillMode = FillMode.NONE, + layout = PagedLayout.FRAMED, +) { + + private val editing = ConcurrentHashMap() + + /** The entry each administrator is part-way through moving, by its id. */ + private val moving = ConcurrentHashMap() + + /** Opens the editor for [shop]. */ + fun open(player: Player, shop: ShopDefinition) { + editing[player.uniqueId] = shop.id + moving.remove(player.uniqueId) + GUIManager.open(player, ID) + } + + override fun getItems(player: Player): List { + val shop = shopOf(player) ?: return emptyList() + val held = moving[player.uniqueId] + return shop.entries.map { entry -> icon(player, entry, held) } + } + + override fun navButtons(player: Player): Map { + val shop = shopOf(player) ?: return emptyMap() + val held = moving[player.uniqueId]?.let(shop::entry) + val back = button(player, Material.ARROW, "gui.shop-editor.back", "gui.shop-editor.back-lore") + + if (held != null) { + return mapOf( + SLOT_HELD to heldButton(player, held), + SLOT_MOVE_FIRST to button( + player, Material.SPECTRAL_ARROW, + "gui.shop-editor.move-first", "gui.shop-editor.move-first-lore", + ), + SLOT_MOVE_LAST to button( + player, Material.TIPPED_ARROW, + "gui.shop-editor.move-last", "gui.shop-editor.move-last-lore", + ), + SLOT_LIST to back, + ) + } + + return buildMap { + put(SLOT_ADD, button(player, Material.PAPER, "gui.shop-editor.add", "gui.shop-editor.add-lore")) + put(SLOT_SETTINGS, settingsButton(player, shop)) + put( + SLOT_STATS, + button(player, Material.WRITABLE_BOOK, "gui.shop-editor.stats", "gui.shop-editor.stats-lore"), + ) + put(SLOT_LIST, back) + if (isSortable(shop)) { + put(SLOT_SORT, button(player, Material.HOPPER, "gui.shop-editor.sort", "gui.shop-editor.sort-lore")) + } + } + } + + override fun onNavClick(event: InventoryClickEvent, offset: Int) { + val player = event.whoClicked as? Player ?: return + val shop = shopOf(player) ?: return + val held = moving[player.uniqueId] + + if (held != null) { + when (offset) { + SLOT_HELD -> release(player, event.inventory) + SLOT_MOVE_FIRST -> place(player, shop, held, 0, event.inventory) + SLOT_MOVE_LAST -> place(player, shop, held, shop.entries.size, event.inventory) + SLOT_LIST -> ShopRender.navigate { ShopListGUI.show(player) } + } + return + } + + when (offset) { + SLOT_SETTINGS -> ShopRender.navigate { ShopSettingsGUI.show(player, shop) } + SLOT_STATS -> ShopRender.navigate { ShopStatsGUI.show(player, shop) } + SLOT_SORT -> if (isSortable(shop)) ShopRender.navigate { ShopSortGUI.show(player, shop) } + SLOT_LIST -> ShopRender.navigate { ShopListGUI.show(player) } + } + } + + /** + * The paged base class drops clicks outside its own inventory, but one in + * the administrator's own inventory is how an entry is added, so it is taken + * before the rest is handed on. + */ + override fun onClick(event: InventoryClickEvent) { + val player = event.whoClicked as? Player + if (player != null && event.clickedInventory === player.inventory) { + event.isCancelled = true + if (moving.containsKey(player.uniqueId)) return + val shop = shopOf(player) ?: return + event.currentItem?.let { add(player, shop, it) } + return + } + + super.onClick(event) + } + + override fun onContentClick(event: InventoryClickEvent, page: Int) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + val shop = shopOf(player) ?: return + + val index = contentIndex(page, event.rawSlot) ?: return + val held = moving[player.uniqueId] + if (held != null) { + place(player, shop, held, index, event.inventory) + return + } + + val entry = shop.entries.getOrNull(index) ?: return + click(event.click, player, shop, entry, event.inventory) + } + + override fun onDrag(event: InventoryDragEvent) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + if (moving.containsKey(player.uniqueId)) return + val shop = shopOf(player) ?: return + add(player, shop, event.oldCursor) + } + + override fun onClose(event: InventoryCloseEvent) { + super.onClose(event) + val player = event.player as? Player ?: return + editing.remove(player.uniqueId) + moving.remove(player.uniqueId) + } + + private fun click( + click: ClickType, + player: Player, + shop: ShopDefinition, + entry: ShopEntry, + inventory: Inventory, + ) { + when (click) { + ClickType.SHIFT_LEFT -> { + shop.entries.remove(entry) + ShopManager.save() + player.sendPrefixed(player.tr("shop.editor.entry-removed")) + refresh(player, inventory) + } + + ClickType.RIGHT, ClickType.SHIFT_RIGHT -> { + moving[player.uniqueId] = entry.id + refresh(player, inventory) + playClick(player) + } + + else -> ShopRender.navigate { ShopEntryGUI.show(player, shop, entry) } + } + } + + /** + * Moves the entry being held to [index], the position of the slot clicked. + * + * The entry always lands immediately before whatever was clicked, whether + * it came from earlier or later in the shop — which is why the target is a + * slot back when it is moving forwards: by then the list has closed up + * behind it. A click past the last entry appends, and a click on the entry + * itself puts it down where it already is. + */ + private fun place(player: Player, shop: ShopDefinition, entryId: String, index: Int, inventory: Inventory) { + moving.remove(player.uniqueId) + + val from = shop.entries.indexOfFirst { it.id == entryId } + if (from < 0 || from == index) { + refresh(player, inventory) + playClick(player) + return + } + + val entry = shop.entries.removeAt(from) + val target = (if (from < index) index - 1 else index).coerceIn(0, shop.entries.size) + shop.entries.add(target, entry) + ShopManager.save() + + refresh(player, inventory) + playClick(player) + } + + /** Puts the held entry back down without moving it. */ + private fun release(player: Player, inventory: Inventory) { + moving.remove(player.uniqueId) + refresh(player, inventory) + playClick(player) + } + + /** + * Adds [stack] as a new entry, priced at nothing until the administrator sets a price. + * + * The stack size clicked becomes the bundle, so putting a stack of 16 bread + * on the shelf sells sixteen loaves at a time without any further setting up. + */ + private fun add(player: Player, shop: ShopDefinition, stack: ItemStack) { + if (stack.type.isAir) return + + val entry = ShopEntry( + item = stack.clone().apply { amount = 1 }, + bundle = stack.amount.coerceAtLeast(1), + buy = ShopCost.FREE, + ) + shop.entries += entry + ShopManager.save() + + player.sendPrefixed(player.tr("shop.editor.entry-added", "item" to ShopRender.itemName(entry.item))) + ShopRender.navigate { ShopEntryGUI.show(player, shop, entry) } + } + + private fun icon(player: Player, entry: ShopEntry, heldId: String?): ItemStack { + val lore = buildList { + add(player.tr("gui.shop-editor.bundle", "amount" to entry.bundleSize)) + if (entry.isBuyable) addAll(buyLines(player, entry)) else add(player.tr("gui.shop-editor.not-buyable")) + if (entry.isSellable) addAll(sellLines(player, entry)) else add(player.tr("gui.shop-editor.not-sellable")) + + when { + heldId == entry.id -> { + add(player.tr("gui.shop-editor.being-moved")) + add(player.tr("gui.shop-editor.click-put-down")) + } + + heldId != null -> add(player.tr("gui.shop-editor.click-place-before")) + + else -> { + add(player.tr("gui.shop-editor.click-entry")) + add(player.tr("gui.shop-editor.right-click-entry")) + add(player.tr("gui.shop-editor.shift-click-entry")) + } + } + } + + val icon = ShopRender.withLore(entry.displayStack(), lore) + return if (heldId == entry.id) ShopRender.glowing(icon) else icon + } + + private fun buyLines(player: Player, entry: ShopEntry): List = + listOf(player.tr("gui.shop-editor.buy")) + ShopRender.costLines(player, entry.buy) + + private fun sellLines(player: Player, entry: ShopEntry): List = + listOf(player.tr("gui.shop-editor.sell")) + ShopRender.costLines(player, entry.sell) + + /** The entry being moved, in the navigation row so it is on screen whatever page is open. */ + private fun heldButton(player: Player, entry: ShopEntry): ItemStack = ShopRender.glowing( + ShopRender.withLore( + entry.displayStack(), + listOf(player.tr("gui.shop-editor.holding"), player.tr("gui.shop-editor.click-put-down")), + ) + ) + + private fun settingsButton(player: Player, shop: ShopDefinition): ItemStack = itemStack(Material.COMPARATOR) { + name(player.tr("gui.shop-editor.settings")) + meta { lore(LoreUtil.wrapLore(player.tr("gui.shop-editor.settings-lore", "id" to shop.id))) } + } + + 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 playClick(player: Player) = + player.playSound(Sound.sound(Key.key("minecraft:ui.button.click"), Sound.Source.UI, 1f, 1f)) + + /** Sorting a shop with one entry in it would only be a way to lose the arrangement of a shop with many. */ + private fun isSortable(shop: ShopDefinition): Boolean = shop.entries.size > 1 + + private fun shopOf(player: Player): ShopDefinition? = editing[player.uniqueId]?.let(ShopManager::get) + + companion object { + const val ID = "shop-editor" + + // Offsets in the navigation row; 0, 4 and 8 belong to the page controls. + private const val SLOT_ADD = 1 + private const val SLOT_SETTINGS = 2 + private const val SLOT_STATS = 3 + private const val SLOT_LIST = 5 + private const val SLOT_SORT = 6 + + // While an entry is being moved the first three carry the move's own + // controls instead, so the row an administrator is already looking at + // answers the question in front of them. + private const val SLOT_HELD = SLOT_ADD + private const val SLOT_MOVE_FIRST = SLOT_SETTINGS + private const val SLOT_MOVE_LAST = SLOT_STATS + + /** Opens the editor for [shop] through the registered instance. */ + fun show(player: Player, shop: ShopDefinition): Boolean { + val gui = GUIManager.getGUI(ID) as? ShopEditorGUI ?: return false + gui.open(player, shop) + return true + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEntryGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEntryGUI.kt new file mode 100644 index 0000000..9f43d9c --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEntryGUI.kt @@ -0,0 +1,449 @@ +package net.trilleo.mc.plugins.tritown.guis.shop + +import net.trilleo.mc.plugins.tritown.config.ShopSettings +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.enums.LimitPeriod +import net.trilleo.mc.plugins.tritown.enums.MatchMode +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PluginGUI +import net.trilleo.mc.plugins.tritown.shops.* +import net.trilleo.mc.plugins.tritown.utils.* +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.inventory.ClickType +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +/** + * Everything about one entry: what it costs, what it pays, who may have it and + * how much of it. + * + * Anything that is a number or a name is typed in chat rather than clicked up + * one at a time — a price of 12500 is not something to reach by clicking. A + * left click asks for the value, a right click clears it, and settings with only + * a few states cycle on a click instead. + */ +class ShopEntryGUI : PluginGUI( + id = ID, + titleKey = "gui.shop-entry.title", + rows = 6, + fillMode = FillMode.DARK, +) { + + private data class Target(val shopId: String, val entryId: String) + + private val editing = ConcurrentHashMap() + + /** Opens the editor for [entry] of [shop]. */ + fun open(player: Player, shop: ShopDefinition, entry: ShopEntry) { + editing[player.uniqueId] = Target(shop.id, entry.id) + GUIManager.open(player, ID) + } + + override fun setup(player: Player, inventory: Inventory) { + val (_, entry) = resolve(player) ?: return + + inventory.setItem(SLOT_GOODS, goods(player, entry)) + inventory.setItem(SLOT_BUNDLE, bundle(player, entry)) + + inventory.setItem(SLOT_BUY_TOGGLE, toggle(player, entry.isBuyable, "gui.shop-entry.buyable")) + inventory.setItem(SLOT_BUY_MONEY, money(player, entry.buy, "gui.shop-entry.buy-price")) + inventory.setItem(SLOT_BUY_ITEMS, items(player, entry.buy, "gui.shop-entry.buy-items")) + + inventory.setItem(SLOT_SELL_TOGGLE, toggle(player, entry.isSellable, "gui.shop-entry.sellable")) + inventory.setItem(SLOT_SELL_MONEY, money(player, entry.sell, "gui.shop-entry.sell-price")) + inventory.setItem(SLOT_SELL_ITEMS, items(player, entry.sell, "gui.shop-entry.sell-items")) + + inventory.setItem(SLOT_LIMIT, limit(player, entry)) + inventory.setItem(SLOT_STOCK, stock(player, entry)) + inventory.setItem(SLOT_PERMISSION, permission(player, entry)) + inventory.setItem(SLOT_TOWNY, towny(player, entry)) + inventory.setItem(SLOT_HIDDEN, toggle(player, entry.gate.hideWhenLocked, "gui.shop-entry.hidden")) + inventory.setItem(SLOT_DISCOUNT, toggle(player, entry.discountable, "gui.shop-entry.discountable")) + inventory.setItem(SLOT_MATCH, match(player, entry)) + + inventory.setItem(SLOT_BACK, button(player, Material.ARROW, "gui.shop-entry.back", "gui.shop-entry.back-lore")) + } + + override fun onClick(event: InventoryClickEvent) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + val (shop, entry) = resolve(player) ?: return + val clear = event.click == ClickType.RIGHT || event.click == ClickType.SHIFT_RIGHT + + when (event.rawSlot) { + SLOT_BUY_TOGGLE -> entry.buy = if (entry.isBuyable) null else ShopCost.FREE + SLOT_SELL_TOGGLE -> entry.sell = if (entry.isSellable) null else suggestedSell(entry) + SLOT_BUY_MONEY -> return askMoney(player, shop, entry, buying = true, clear = clear) + SLOT_SELL_MONEY -> return askMoney(player, shop, entry, buying = false, clear = clear) + SLOT_BUY_ITEMS -> return ShopRender.navigate { ShopCostGUI.show(player, shop, entry, buying = true) } + SLOT_SELL_ITEMS -> return ShopRender.navigate { ShopCostGUI.show(player, shop, entry, buying = false) } + SLOT_BUNDLE -> return editBundle(player, shop, entry) + SLOT_LIMIT -> return editLimit(player, shop, entry, event.click) + SLOT_STOCK -> return editStock(player, shop, entry, clear) + SLOT_PERMISSION -> return askPermission(player, shop, entry, clear) + SLOT_TOWNY -> entry.gate = entry.gate.copy(towny = cycle(entry.gate.towny, event.click)) + SLOT_HIDDEN -> entry.gate = entry.gate.copy(hideWhenLocked = !entry.gate.hideWhenLocked) + SLOT_DISCOUNT -> entry.discountable = !entry.discountable + SLOT_MATCH -> entry.matchMode = + if (entry.matchMode == MatchMode.EXACT) MatchMode.MATERIAL else MatchMode.EXACT + + SLOT_BACK -> return ShopRender.navigate { ShopEditorGUI.show(player, shop) } + else -> return + } + + ShopManager.save() + setup(player, event.inventory) + } + + override fun onClose(event: InventoryCloseEvent) { + editing.remove((event.player as? Player)?.uniqueId ?: return) + } + + // ── Editing ───────────────────────────────────────────────────────── + + private fun askMoney(player: Player, shop: ShopDefinition, entry: ShopEntry, buying: Boolean, clear: Boolean) { + if (clear) { + applyMoney(entry, buying, 0.0) + ShopManager.save() + ShopRender.navigate { show(player, shop, entry) } + return + } + + prompt(player, shop, entry, player.tr("gui.shop-entry.prompt-price")) { input -> + val amount = input.toDoubleOrNull() + if (amount == null || amount < 0.0) { + player.sendPrefixed(player.tr("common.error", "message" to player.tr("common.invalid-amount"))) + } else { + applyMoney(entry, buying, amount) + ShopManager.save() + } + } + } + + private fun applyMoney(entry: ShopEntry, buying: Boolean, amount: Double) { + if (buying) { + entry.buy = (entry.buy ?: ShopCost.FREE).copy(money = amount) + } else { + entry.sell = (entry.sell ?: ShopCost.FREE).copy(money = amount) + } + } + + private fun askPermission(player: Player, shop: ShopDefinition, entry: ShopEntry, clear: Boolean) { + if (clear) { + entry.gate = entry.gate.copy(permission = null) + ShopManager.save() + ShopRender.navigate { show(player, shop, entry) } + return + } + + prompt(player, shop, entry, player.tr("gui.shop-entry.prompt-permission")) { input -> + entry.gate = entry.gate.copy(permission = input.ifBlank { null }) + ShopManager.save() + } + } + + /** + * Asks how many items one purchase should hand over. + * + * Not capped at a stack: a bundle of 128 bread is handed over as two stacks, + * and refusing it would only make an administrator create two entries for + * what is one thing being sold. + */ + private fun editBundle(player: Player, shop: ShopDefinition, entry: ShopEntry) { + prompt(player, shop, entry, player.tr("gui.shop-entry.prompt-bundle")) { input -> + val amount = input.toIntOrNull() + if (amount == null || amount < 1) { + player.sendPrefixed(player.tr("common.error", "message" to player.tr("common.invalid-amount"))) + } else { + entry.bundle = amount + ShopManager.save() + } + } + } + + /** Left sets the amount, right clears the limit, and a middle click steps the window. */ + private fun editLimit(player: Player, shop: ShopDefinition, entry: ShopEntry, click: ClickType) { + when (click) { + ClickType.RIGHT, ClickType.SHIFT_RIGHT -> { + entry.limit = null + ShopManager.save() + ShopRender.navigate { show(player, shop, entry) } + } + + ClickType.MIDDLE -> { + val current = entry.limit ?: ShopLimit(1, LimitPeriod.NONE) + entry.limit = current.copy(period = cycle(current.period, ClickType.LEFT)) + ShopManager.save() + ShopRender.navigate { show(player, shop, entry) } + } + + else -> prompt(player, shop, entry, player.tr("gui.shop-entry.prompt-limit")) { input -> + val amount = input.toIntOrNull() + if (amount == null || amount < 0) { + player.sendPrefixed(player.tr("common.error", "message" to player.tr("common.invalid-amount"))) + } else { + entry.limit = if (amount == 0) null else { + (entry.limit ?: ShopLimit(amount, LimitPeriod.DAILY)).copy(amount = amount) + } + ShopManager.save() + } + } + } + } + + /** Asks for `max` and `restock seconds` on one line, because they only make sense together. */ + private fun editStock(player: Player, shop: ShopDefinition, entry: ShopEntry, clear: Boolean) { + if (clear) { + entry.stock = null + ShopManager.save() + ShopRender.navigate { show(player, shop, entry) } + return + } + + prompt(player, shop, entry, player.tr("gui.shop-entry.prompt-stock")) { input -> + val parts = input.split(' ', limit = 2) + val max = parts.getOrNull(0)?.toIntOrNull() + val seconds = parts.getOrNull(1)?.toLongOrNull() ?: 0L + + if (max == null || max < 0 || seconds < 0L) { + player.sendPrefixed(player.tr("common.error", "message" to player.tr("common.invalid-amount"))) + } else { + entry.stock = if (max == 0) null else ShopStock(max, seconds, max, System.currentTimeMillis()) + ShopManager.save() + } + } + } + + /** + * Closes the menu, asks [question], and reopens on the answer. + * + * Reopened either way, so a mistyped price does not leave the administrator + * standing in the world wondering where the editor went. + */ + private fun prompt( + player: Player, + shop: ShopDefinition, + entry: ShopEntry, + question: String, + onInput: (String) -> Unit, + ) { + player.closeInventory() + ChatPrompt.ask(player, question) { input -> + onInput(input) + show(player, shop, entry) + } + } + + private fun suggestedSell(entry: ShopEntry): ShopCost { + val rate = if (ShopSettings.isLoaded) ShopSettings.snapshot.sellRate else 0.5 + val buy = entry.buy?.money ?: 0.0 + return ShopCost(ShopPricing.round(buy * rate, MONEY_SCALE)) + } + + private inline fun > cycle(value: T, click: ClickType): T { + val values = enumValues() + val step = if (click == ClickType.RIGHT || click == ClickType.SHIFT_RIGHT) -1 else 1 + return values[(value.ordinal + step + values.size) % values.size] + } + + // ── Drawing ───────────────────────────────────────────────────────── + + private fun goods(player: Player, entry: ShopEntry): ItemStack = ShopRender.withLore( + entry.displayStack(), + listOf(player.tr("gui.shop-entry.bundle", "amount" to entry.bundleSize)), + ) + + private fun bundle(player: Player, entry: ShopEntry): ItemStack = itemStack(Material.PAPER) { + name(player.tr("gui.shop-entry.bundle-size")) + meta { + lore( + LoreUtil.wrapLore( + player.tr("gui.shop-entry.amount", "amount" to entry.bundleSize) + + "" + player.tr("gui.shop-entry.bundle-lore") + + "" + player.tr("gui.shop-entry.click-set") + ) + ) + } + } + + private fun toggle(player: Player, on: Boolean, nameKey: String): ItemStack = + itemStack(if (on) Material.LIME_DYE else Material.GRAY_DYE) { + name(player.tr(nameKey)) + meta { + lore( + LoreUtil.wrapLore( + player.tr("gui.shop-entry.state", "state" to player.tr(if (on) "common.on" else "common.off")) + + "" + player.tr("gui.shop-entry.click-toggle") + ) + ) + } + } + + private fun money(player: Player, cost: ShopCost?, nameKey: String): ItemStack = itemStack(Material.GOLD_INGOT) { + name(player.tr(nameKey)) + meta { + lore( + LoreUtil.wrapLore( + player.tr("gui.shop-entry.amount", "amount" to ShopRender.money(cost?.money ?: 0.0)) + + "" + player.tr("gui.shop-entry.click-set") + + "" + player.tr("gui.shop-entry.right-click-clear") + ) + ) + } + } + + private fun items(player: Player, cost: ShopCost?, nameKey: String): ItemStack = itemStack(Material.CHEST) { + name(player.tr(nameKey)) + meta { + lore( + LoreUtil.wrapLore( + player.tr("gui.shop-entry.item-count", "amount" to (cost?.items?.size ?: 0)) + + "" + player.tr("gui.shop-entry.click-open") + ) + ) + } + } + + private fun limit(player: Player, entry: ShopEntry): ItemStack = itemStack(Material.CLOCK) { + name(player.tr("gui.shop-entry.limit")) + meta { + val current = entry.limit + val value = if (current == null) { + player.tr("common.none") + } else { + player.tr( + "gui.shop-entry.limit-value", + "amount" to current.amount, + "period" to ShopRender.periodName(player, current.period), + ) + } + + lore( + LoreUtil.wrapLore( + player.tr("gui.shop-entry.amount", "amount" to value) + + "" + player.tr("gui.shop-entry.click-set") + + "" + player.tr("gui.shop-entry.middle-click-period") + + "" + player.tr("gui.shop-entry.right-click-clear") + ) + ) + } + } + + private fun stock(player: Player, entry: ShopEntry): ItemStack = itemStack(Material.BARREL) { + name(player.tr("gui.shop-entry.stock")) + meta { + val current = entry.stock + val value = if (current == null) { + player.tr("gui.shop-entry.stock-unlimited") + } else { + player.tr( + "gui.shop-entry.stock-value", + "amount" to current.remaining, + "max" to current.max, + "seconds" to current.restockSeconds, + ) + } + + lore( + LoreUtil.wrapLore( + player.tr("gui.shop-entry.amount", "amount" to value) + + "" + player.tr("gui.shop-entry.click-set") + + "" + player.tr("gui.shop-entry.right-click-clear") + ) + ) + } + } + + private fun permission(player: Player, entry: ShopEntry): ItemStack = itemStack(Material.NAME_TAG) { + name(player.tr("gui.shop-entry.permission")) + meta { + lore( + LoreUtil.wrapLore( + player.tr( + "gui.shop-entry.amount", + "amount" to (entry.gate.permission ?: player.tr("common.none")), + ) + + "" + player.tr("gui.shop-entry.click-set") + + "" + player.tr("gui.shop-entry.right-click-clear") + ) + ) + } + } + + private fun towny(player: Player, entry: ShopEntry): ItemStack = itemStack(Material.OAK_SIGN) { + name(player.tr("gui.shop-entry.towny")) + meta { + lore( + LoreUtil.wrapLore( + player.tr( + "gui.shop-entry.amount", + "amount" to ShopRender.requirementName(player, entry.gate.towny), + ) + "" + player.tr("gui.shop-entry.click-cycle") + ) + ) + } + } + + private fun match(player: Player, entry: ShopEntry): ItemStack = itemStack(Material.COMPARATOR) { + name(player.tr("gui.shop-entry.match")) + meta { + lore( + LoreUtil.wrapLore( + player.tr("gui.shop-entry.amount", "amount" to ShopRender.matchName(player, entry.matchMode)) + + "" + player.tr("gui.shop-entry.match-lore") + + "" + player.tr("gui.shop-entry.click-toggle") + ) + ) + } + } + + private fun button(player: Player, material: Material, nameKey: String, loreKey: String): ItemStack = + itemStack(material) { + name(player.tr(nameKey)) + meta { lore(LoreUtil.wrapLore(player.tr(loreKey))) } + } + + private fun resolve(player: Player): Pair? { + val target = editing[player.uniqueId] ?: return null + val shop = ShopManager.get(target.shopId) ?: return null + val entry = shop.entry(target.entryId) ?: return null + return shop to entry + } + + companion object { + const val ID = "shop-entry" + + /** Money is only ever shown here, so the currency's own scale is not worth reaching for. */ + private const val MONEY_SCALE = 2 + + private const val SLOT_GOODS = 4 + private const val SLOT_BUNDLE = 13 + private const val SLOT_BUY_TOGGLE = 19 + private const val SLOT_BUY_MONEY = 20 + private const val SLOT_BUY_ITEMS = 21 + private const val SLOT_SELL_TOGGLE = 23 + private const val SLOT_SELL_MONEY = 24 + private const val SLOT_SELL_ITEMS = 25 + private const val SLOT_LIMIT = 29 + private const val SLOT_STOCK = 31 + private const val SLOT_PERMISSION = 33 + private const val SLOT_TOWNY = 38 + private const val SLOT_HIDDEN = 40 + private const val SLOT_DISCOUNT = 42 + private const val SLOT_MATCH = 44 + private const val SLOT_BACK = 49 + + /** Opens the entry editor through the registered instance. */ + fun show(player: Player, shop: ShopDefinition, entry: ShopEntry): Boolean { + val gui = GUIManager.getGUI(ID) as? ShopEntryGUI ?: return false + gui.open(player, shop, entry) + return true + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt new file mode 100644 index 0000000..13a401c --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopGUI.kt @@ -0,0 +1,265 @@ +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.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 org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.inventory.ClickType +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.inventory.ItemStack +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +/** + * What a player sees when they click a shopkeeper. + * + * GUIs are singletons, so which shop is open and what was drawn for it are held + * per viewer. The drawing is done once when the menu opens rather than in + * [getItems], which the paging code calls on every render and again for every + * page count. + * + * A trade redraws only the entry that was traded. Rebuilding the whole menu + * would throw the viewer back to the first page, and nothing else on the page + * changed. + */ +class ShopGUI : PagedPluginGUI( + id = ID, + titleKey = "gui.shop.title", + rows = 6, + fillMode = FillMode.NONE, + layout = PagedLayout.FRAMED, +) { + + /** + * @param entryIds the entry drawn in each content slot, in order, with `null` where nothing is + */ + private data class View( + val shopId: String, + val entryIds: MutableList, + val items: MutableList, + ) + + private val views = ConcurrentHashMap() + + /** Opens [shop] for [viewer], drawing only what they are allowed to see. */ + fun open(viewer: Player, shop: ShopDefinition) { + views[viewer.uniqueId] = render(viewer, shop) + GUIManager.open(viewer, ID) + } + + override fun title(player: Player): Component { + val shop = shopOf(player) + val name = shop?.displayName ?: player.tr("gui.shop.unknown") + return ComponentUtil.parse(player.tr("gui.shop.title", "name" to name)) + } + + override fun getItems(player: Player): List = views[player.uniqueId]?.items ?: emptyList() + + override fun onContentClick(event: InventoryClickEvent, page: Int) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + val view = views[player.uniqueId] ?: return + val shop = ShopManager.get(view.shopId) ?: return + + val index = contentIndex(page, event.rawSlot) ?: return + val entry = view.entryIds.getOrNull(index)?.let(shop::entry) ?: return + + val result = when (event.click) { + ClickType.LEFT -> tradeBuy(player, shop, entry, 1) + ClickType.SHIFT_LEFT -> tradeBuy(player, shop, entry, ShopTrade.maxBuyable(player, shop, entry)) + ClickType.RIGHT -> ShopTrade.sell(player, shop, entry, 1) + ClickType.SHIFT_RIGHT -> sellMax(player, shop, entry) + else -> return + } ?: return + + when (result) { + is ShopTrade.Result.Success -> { + announce(player, entry, result) + redraw(event, view, index, player, shop, entry) + } + + is ShopTrade.Result.Failure -> { + val reason = player.tr(result.key, *result.args.toTypedArray()) + player.sendPrefixed(player.tr("common.error", "message" to reason)) + player.playSound(Sound.sound(Key.key("minecraft:entity.villager.no"), Sound.Source.UI, 1f, 1f)) + } + } + } + + override fun onClose(event: InventoryCloseEvent) { + super.onClose(event) + views.remove((event.player as? Player)?.uniqueId ?: return) + } + + // ── Trading ───────────────────────────────────────────────────────── + + /** + * Buys, unless the bill is large enough to be worth a second look — then the + * confirmation menu takes over and this reports nothing. + */ + private fun tradeBuy(player: Player, shop: ShopDefinition, entry: ShopEntry, bundles: Int): ShopTrade.Result? { + if (bundles <= 0) { + return ShopTrade.Result.Failure("shop.error.cannot-afford", listOf("price" to unitPrice(player, entry))) + } + + val threshold = if (ShopSettings.isLoaded) ShopSettings.snapshot.confirmAbove else 0.0 + val quote = ShopTrade.quoteBuy(player, entry, bundles) + val confirm = confirmGUI() + + if (threshold > 0.0 && confirm != null && quote != null && quote.money > threshold) { + ShopRender.navigate { confirm.open(player, shop, entry, bundles) } + return null + } + + return ShopTrade.buy(player, shop, entry, bundles) + } + + private fun sellMax(player: Player, shop: ShopDefinition, entry: ShopEntry): ShopTrade.Result { + val bundles = ShopTrade.maxSellable(player, entry) + if (bundles <= 0) return ShopTrade.Result.Failure("shop.error.missing-goods") + return ShopTrade.sell(player, shop, entry, bundles) + } + + private fun announce(player: Player, entry: ShopEntry, result: ShopTrade.Result.Success) { + val key = if (result.money > 0.0 || entry.buy?.hasMoney == true) "shop.traded" else "shop.traded-items" + player.sendPrefixed( + player.tr( + key, + "amount" to entry.bundleSize * result.bundles, + "item" to ShopRender.itemName(entry.item), + "price" to ShopRender.money(result.money), + ) + ) + player.playSound(Sound.sound(Key.key("minecraft:entity.villager.yes"), Sound.Source.UI, 1f, 1f)) + } + + private fun redraw( + event: InventoryClickEvent, + view: View, + index: Int, + player: Player, + shop: ShopDefinition, + entry: ShopEntry, + ) { + val refreshed = draw(player, shop, entry, ShopAccess.standing(player)) + view.items[index] = refreshed + event.inventory.setItem(event.rawSlot, refreshed) + } + + // ── Drawing ───────────────────────────────────────────────────────── + + private fun render(viewer: Player, shop: ShopDefinition): View { + val standing = ShopAccess.standing(viewer) + val entryIds = mutableListOf() + val items = mutableListOf() + + for (entry in shop.entries) { + val refusal = ShopAccess.refusalKey(viewer, entry.gate, standing) + if (refusal != null && entry.gate.hideWhenLocked) continue + + entryIds += entry.id + items += draw(viewer, shop, entry, standing) + } + + if (items.isEmpty()) { + entryIds += null + items += itemStack(Material.BARRIER) { + name(viewer.tr("gui.shop.empty")) + meta { lore(LoreUtil.wrapLore(viewer.tr("gui.shop.empty-lore"))) } + } + } + + return View(shop.id, entryIds, items) + } + + private fun draw( + viewer: Player, + shop: ShopDefinition, + entry: ShopEntry, + standing: Set, + ): ItemStack { + val lines = mutableListOf() + val refusal = ShopAccess.refusalKey(viewer, entry.gate, standing) + + if (entry.isBuyable) { + val quote = ShopTrade.quoteBuy(viewer, entry, 1, standing) + if (quote != null && quote.isDiscounted) { + lines += viewer.tr( + "gui.shop.buy-discounted", + "price" to ShopRender.money(quote.money), + "full" to ShopRender.money(quote.fullMoney), + ) + } else if (entry.buy?.hasMoney == true) { + lines += viewer.tr("gui.shop.buy", "price" to ShopRender.money(quote?.money ?: 0.0)) + } + entry.buy?.items?.forEach { lines += ShopRender.itemLine(viewer, it) } + } + + if (entry.isSellable) { + entry.sell?.let { payout -> + if (payout.hasMoney) lines += viewer.tr("gui.shop.sell", "price" to ShopRender.money(payout.money)) + payout.items.forEach { lines += ShopRender.itemLine(viewer, it) } + } + } + + entry.stock?.let { stock -> + lines += viewer.tr( + "gui.shop.stock", + "amount" to stock.available(System.currentTimeMillis()), + "max" to stock.max, + ) + } + + entry.limit?.let { limit -> + lines += viewer.tr( + "gui.shop.limit", + "amount" to (ShopLimits.remaining(viewer, shop, entry) ?: limit.amount), + "period" to ShopRender.periodName(viewer, limit.period), + ) + } + + if (refusal != null) { + lines += viewer.tr(refusal) + } else { + if (entry.isBuyable) { + lines += viewer.tr("gui.shop.click-buy") + lines += viewer.tr("gui.shop.click-buy-max") + } + if (entry.isSellable) { + lines += viewer.tr("gui.shop.click-sell") + lines += viewer.tr("gui.shop.click-sell-max") + } + } + + return ShopRender.withLore(entry.displayStack(), lines) + } + + private fun shopOf(player: Player): ShopDefinition? = views[player.uniqueId]?.let { ShopManager.get(it.shopId) } + + private fun unitPrice(player: Player, entry: ShopEntry): String = + ShopRender.money(ShopTrade.quoteBuy(player, entry, 1)?.money ?: 0.0) + + private fun confirmGUI(): ShopConfirmGUI? = GUIManager.getGUI(ShopConfirmGUI.ID) as? ShopConfirmGUI + + companion object { + const val ID = "shop" + + /** Opens [shop] for [viewer] through the registered instance. */ + fun show(viewer: Player, shop: ShopDefinition): Boolean { + val gui = GUIManager.getGUI(ID) as? ShopGUI ?: return false + gui.open(viewer, shop) + return true + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopListGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopListGUI.kt new file mode 100644 index 0000000..0df430b --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopListGUI.kt @@ -0,0 +1,79 @@ +package net.trilleo.mc.plugins.tritown.guis.shop + +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.enums.PagedLayout +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PagedPluginGUI +import net.trilleo.mc.plugins.tritown.shops.ShopDefinition +import net.trilleo.mc.plugins.tritown.shops.ShopManager +import net.trilleo.mc.plugins.tritown.utils.LoreUtil +import net.trilleo.mc.plugins.tritown.utils.itemStack +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.inventory.ClickType +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.inventory.ItemStack + +/** + * Every shop on the server, for an administrator to pick one to work on. + * + * Read straight out of [ShopManager] on each render, because the list is short + * and an administrator who has just created a shop expects to see it. + */ +class ShopListGUI : PagedPluginGUI( + id = ID, + titleKey = "gui.shop-list.title", + rows = 6, + fillMode = FillMode.NONE, + layout = PagedLayout.FRAMED, +) { + + override fun getItems(player: Player): List { + val shops = ShopManager.all() + if (shops.isEmpty()) { + return listOf( + itemStack(Material.BARRIER) { + name(player.tr("gui.shop-list.empty")) + meta { lore(LoreUtil.wrapLore(player.tr("gui.shop-list.empty-lore"))) } + } + ) + } + + return shops.map { shop -> icon(player, shop) } + } + + override fun onContentClick(event: InventoryClickEvent, page: Int) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + val index = contentIndex(page, event.rawSlot) ?: return + val shop = ShopManager.all().getOrNull(index) ?: return + + ShopRender.navigate { + if (event.click == ClickType.SHIFT_LEFT) ShopGUI.show(player, shop) else ShopEditorGUI.show(player, shop) + } + } + + private fun icon(player: Player, shop: ShopDefinition): ItemStack { + val lore = buildList { + add(player.tr("gui.shop-list.id", "id" to shop.id)) + add(player.tr("gui.shop-list.entries", "amount" to shop.entries.size)) + add(player.tr("gui.shop-list.npcs", "amount" to shop.npcIds.size)) + add(player.tr("gui.shop-list.click-edit")) + add(player.tr("gui.shop-list.click-preview")) + } + + return itemStack(Material.CHEST) { + name(shop.displayName) + meta { lore(LoreUtil.wrapLore(lore.joinToString(""))) } + } + } + + companion object { + const val ID = "shop-list" + + /** Opens the list for [player] through the registered instance. */ + fun show(player: Player): Boolean = GUIManager.open(player, ID) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt new file mode 100644 index 0000000..0dd35fb --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt @@ -0,0 +1,129 @@ +package net.trilleo.mc.plugins.tritown.guis.shop + +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import net.trilleo.mc.plugins.tritown.Main +import net.trilleo.mc.plugins.tritown.enums.LimitPeriod +import net.trilleo.mc.plugins.tritown.enums.MatchMode +import net.trilleo.mc.plugins.tritown.enums.TownyRequirement +import net.trilleo.mc.plugins.tritown.shops.ShopCost +import net.trilleo.mc.plugins.tritown.utils.ComponentUtil +import net.trilleo.mc.plugins.tritown.utils.EconomyUtil +import net.trilleo.mc.plugins.tritown.utils.LoreUtil +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import org.bukkit.inventory.ItemStack + +/** + * The pieces every shop menu draws with. + * + * Kept in one place because the player's menu, the editor and the statistics + * view all describe the same things — a price, a stock level, a requirement — + * and they should read identically wherever they appear. + */ +object ShopRender { + + private val plain = PlainTextComponentSerializer.plainText() + + /** + * Opens the next menu on the following tick. + * + * A click is still being delivered while its handler runs, and opening an + * inventory from inside that delivery leaves the server and the client + * disagreeing about what is on screen. + */ + fun navigate(open: () -> Unit) { + Bukkit.getScheduler().runTask(Main.instance, Runnable { open() }) + } + + /** + * What to call [item] in a line of text. + * + * A renamed item is called by the name it was given, escaped because an + * administrator wrote it. Anything else uses the client's own translation, + * so a Chinese player reads Chinese item names without TriTown shipping a + * copy of Minecraft's dictionary. + */ + fun itemName(item: ItemStack): String { + val meta = item.itemMeta + val custom: Component? = if (meta != null && meta.hasDisplayName()) meta.displayName() else null + return custom?.let { ComponentUtil.escape(plain.serialize(it)) } ?: "" + } + + /** `3× Diamond`, for one line of a price or payout. */ + fun itemLine(player: Player, item: ItemStack, multiplier: Int = 1): String = player.tr( + "gui.shop.cost-item", + "amount" to item.amount * multiplier, + "item" to itemName(item), + ) + + /** Every line a [cost] needs, money first, or an empty list when it asks for nothing. */ + fun costLines(player: Player, cost: ShopCost?, multiplier: Int = 1): List { + if (cost == null || cost.isFree) return emptyList() + + val lines = mutableListOf() + if (cost.hasMoney) lines += money(cost.money * multiplier) + cost.items.forEach { lines += itemLine(player, it, multiplier) } + return lines + } + + /** [amount] as the server's currency, or a bare number when no economy is available. */ + fun money(amount: Double): String = + if (EconomyUtil.isAvailable) ComponentUtil.escape(EconomyUtil.format(amount)) else amount.toString() + + /** A Towny requirement's name in [player]'s language. */ + fun requirementName(player: Player, requirement: TownyRequirement): String = when (requirement) { + TownyRequirement.NONE -> player.tr("gui.shop.requirement-none") + TownyRequirement.HAS_TOWN -> player.tr("gui.shop.requirement-has-town") + TownyRequirement.NO_TOWN -> player.tr("gui.shop.requirement-no-town") + TownyRequirement.HAS_NATION -> player.tr("gui.shop.requirement-has-nation") + TownyRequirement.IS_MAYOR -> player.tr("gui.shop.requirement-is-mayor") + TownyRequirement.IS_KING -> player.tr("gui.shop.requirement-is-king") + } + + /** A limit period's name in [player]'s language. */ + fun periodName(player: Player, period: LimitPeriod): String = when (period) { + LimitPeriod.NONE -> player.tr("gui.shop.period-none") + LimitPeriod.DAILY -> player.tr("gui.shop.period-daily") + LimitPeriod.WEEKLY -> player.tr("gui.shop.period-weekly") + } + + /** A match mode's name in [player]'s language. */ + fun matchName(player: Player, mode: MatchMode): String = when (mode) { + MatchMode.EXACT -> player.tr("gui.shop.match-exact") + MatchMode.MATERIAL -> player.tr("gui.shop.match-material") + } + + /** + * A copy of [item] that glints, for the one thing a menu is asking about. + * + * The glint is overridden rather than an enchantment added, because the + * goods are drawn exactly as they are sold and an enchantment on the icon + * would misdescribe what is on the shelf. + */ + fun glowing(item: ItemStack): ItemStack = item.clone().apply { + val meta = itemMeta ?: return@apply + meta.setEnchantmentGlintOverride(true) + itemMeta = meta + } + + /** + * A copy of [item] with [lines] added under whatever lore it already has. + * + * The goods keep their own description, because an item that says what it + * does should still say it on the shelf. + */ + fun withLore(item: ItemStack, lines: List): ItemStack { + if (lines.isEmpty()) return item.clone() + + val copy = item.clone() + val meta = copy.itemMeta ?: return copy + val existing = meta.lore().orEmpty() + val added = LoreUtil.wrapLore(lines.joinToString("")) + + meta.lore(if (existing.isEmpty()) added else existing + Component.empty() + added) + copy.itemMeta = meta + return copy + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopSettingsGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopSettingsGUI.kt new file mode 100644 index 0000000..6a03ce6 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopSettingsGUI.kt @@ -0,0 +1,199 @@ +package net.trilleo.mc.plugins.tritown.guis.shop + +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.enums.TownyRequirement +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PluginGUI +import net.trilleo.mc.plugins.tritown.shops.ShopDefinition +import net.trilleo.mc.plugins.tritown.shops.ShopManager +import net.trilleo.mc.plugins.tritown.utils.ChatPrompt +import net.trilleo.mc.plugins.tritown.utils.LoreUtil +import net.trilleo.mc.plugins.tritown.utils.itemStack +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.inventory.ClickType +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +/** + * A shop as a whole: what it is called, who may open it, and which NPCs stand + * behind the counter. + * + * The NPC list is shown but not edited here — binding needs an NPC's name, which + * is a command's job, and the command tab-completes those names. + */ +class ShopSettingsGUI : PluginGUI( + id = ID, + titleKey = "gui.shop-settings.title", + rows = 3, + fillMode = FillMode.DARK, +) { + + private val editing = ConcurrentHashMap() + + /** Opens the settings of [shop]. */ + fun open(player: Player, shop: ShopDefinition) { + editing[player.uniqueId] = shop.id + GUIManager.open(player, ID) + } + + override fun setup(player: Player, inventory: Inventory) { + val shop = shopOf(player) ?: return + + inventory.setItem(SLOT_NAME, name(player, shop)) + inventory.setItem(SLOT_PERMISSION, permission(player, shop)) + inventory.setItem(SLOT_TOWNY, towny(player, shop)) + inventory.setItem(SLOT_NPCS, npcs(player, shop)) + inventory.setItem( + SLOT_STATS, + button(player, Material.WRITABLE_BOOK, "gui.shop-settings.stats", "gui.shop-settings.stats-lore"), + ) + inventory.setItem( + SLOT_BACK, + button(player, Material.ARROW, "gui.shop-settings.back", "gui.shop-settings.back-lore"), + ) + } + + override fun onClick(event: InventoryClickEvent) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + val shop = shopOf(player) ?: return + val clear = event.click == ClickType.RIGHT || event.click == ClickType.SHIFT_RIGHT + + when (event.rawSlot) { + SLOT_NAME -> return prompt(player, shop, player.tr("gui.shop-settings.prompt-name")) { input -> + if (input.isNotBlank()) { + shop.displayName = input + ShopManager.save() + } + } + + SLOT_PERMISSION -> { + if (clear) { + shop.gate = shop.gate.copy(permission = null) + ShopManager.save() + } else { + return prompt(player, shop, player.tr("gui.shop-settings.prompt-permission")) { input -> + shop.gate = shop.gate.copy(permission = input.ifBlank { null }) + ShopManager.save() + } + } + } + + SLOT_TOWNY -> { + shop.gate = shop.gate.copy(towny = cycle(shop.gate.towny, clear)) + ShopManager.save() + } + + SLOT_STATS -> return ShopRender.navigate { ShopStatsGUI.show(player, shop) } + SLOT_BACK -> return ShopRender.navigate { ShopEditorGUI.show(player, shop) } + else -> return + } + + setup(player, event.inventory) + } + + override fun onClose(event: InventoryCloseEvent) { + editing.remove((event.player as? Player)?.uniqueId ?: return) + } + + private fun prompt(player: Player, shop: ShopDefinition, question: String, onInput: (String) -> Unit) { + player.closeInventory() + ChatPrompt.ask(player, question) { input -> + onInput(input) + show(player, shop) + } + } + + private fun cycle(value: TownyRequirement, backwards: Boolean): TownyRequirement { + val values = TownyRequirement.entries + val step = if (backwards) -1 else 1 + return values[(value.ordinal + step + values.size) % values.size] + } + + private fun name(player: Player, shop: ShopDefinition): ItemStack = itemStack(Material.NAME_TAG) { + name(shop.displayName) + meta { + lore( + LoreUtil.wrapLore( + player.tr("gui.shop-settings.id", "id" to shop.id) + + "" + player.tr("gui.shop-settings.click-rename") + ) + ) + } + } + + private fun permission(player: Player, shop: ShopDefinition): ItemStack = itemStack(Material.PAPER) { + name(player.tr("gui.shop-settings.permission")) + meta { + lore( + LoreUtil.wrapLore( + player.tr( + "gui.shop-settings.value", + "value" to (shop.gate.permission ?: player.tr("common.none")), + ) + + "" + player.tr("gui.shop-settings.click-set") + + "" + player.tr("gui.shop-settings.right-click-clear") + ) + ) + } + } + + private fun towny(player: Player, shop: ShopDefinition): ItemStack = itemStack(Material.OAK_SIGN) { + name(player.tr("gui.shop-settings.towny")) + meta { + lore( + LoreUtil.wrapLore( + player.tr( + "gui.shop-settings.value", + "value" to ShopRender.requirementName(player, shop.gate.towny), + ) + "" + player.tr("gui.shop-settings.click-cycle") + ) + ) + } + } + + private fun npcs(player: Player, shop: ShopDefinition): ItemStack = itemStack(Material.PLAYER_HEAD) { + name(player.tr("gui.shop-settings.npcs")) + meta { + lore( + LoreUtil.wrapLore( + player.tr("gui.shop-settings.npc-count", "amount" to shop.npcIds.size) + + "" + player.tr("gui.shop-settings.npc-lore", "id" to shop.id) + ) + ) + } + } + + private fun button(player: Player, material: Material, nameKey: String, loreKey: String): ItemStack = + itemStack(material) { + name(player.tr(nameKey)) + meta { lore(LoreUtil.wrapLore(player.tr(loreKey))) } + } + + private fun shopOf(player: Player): ShopDefinition? = editing[player.uniqueId]?.let(ShopManager::get) + + companion object { + const val ID = "shop-settings" + + private const val SLOT_NAME = 10 + private const val SLOT_PERMISSION = 11 + private const val SLOT_TOWNY = 12 + private const val SLOT_NPCS = 14 + private const val SLOT_STATS = 16 + private const val SLOT_BACK = 22 + + /** Opens a shop's settings through the registered instance. */ + fun show(player: Player, shop: ShopDefinition): Boolean { + val gui = GUIManager.getGUI(ID) as? ShopSettingsGUI ?: return false + gui.open(player, shop) + return true + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopSortGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopSortGUI.kt new file mode 100644 index 0000000..305766f --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopSortGUI.kt @@ -0,0 +1,178 @@ +package net.trilleo.mc.plugins.tritown.guis.shop + +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.enums.ShopSortMode +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PluginGUI +import net.trilleo.mc.plugins.tritown.shops.ShopDefinition +import net.trilleo.mc.plugins.tritown.shops.ShopManager +import net.trilleo.mc.plugins.tritown.shops.ShopSorting +import net.trilleo.mc.plugins.tritown.utils.LoreUtil +import net.trilleo.mc.plugins.tritown.utils.itemStack +import net.trilleo.mc.plugins.tritown.utils.sendPrefixed +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +/** + * Puts a whole shop in order at once. + * + * An order is chosen first and applied second, because sorting overwrites an + * arrangement that may have taken a while to make by hand and there is nothing + * to undo it with: the shop's order is what is saved, not how it was reached. + * That is also why the accept button only appears once an order is chosen — + * there is no single click here that rearranges a shop. + */ +class ShopSortGUI : PluginGUI( + id = ID, + titleKey = "gui.shop-sort.title", + rows = 3, + fillMode = FillMode.DARK, +) { + + private data class Pending(val shopId: String, val mode: ShopSortMode? = null) + + private val pending = ConcurrentHashMap() + + /** Asks [player] how [shop] should be ordered. */ + fun open(player: Player, shop: ShopDefinition) { + pending[player.uniqueId] = Pending(shop.id) + GUIManager.open(player, ID) + } + + override fun setup(player: Player, inventory: Inventory) { + val chosen = pending[player.uniqueId]?.mode + + inventory.setItem(SLOT_INFO, button(player, Material.BOOK, "gui.shop-sort.info", "gui.shop-sort.info-lore")) + + inventory.setItem(SLOT_NAME, choice(player, ShopSortMode.NAME, chosen)) + inventory.setItem(SLOT_NAME_REVERSED, choice(player, ShopSortMode.NAME_REVERSED, chosen)) + inventory.setItem(SLOT_PRICE, choice(player, ShopSortMode.PRICE, chosen)) + inventory.setItem(SLOT_PRICE_REVERSED, choice(player, ShopSortMode.PRICE_REVERSED, chosen)) + + if (chosen != null) { + inventory.setItem( + SLOT_CONFIRM, + button(player, Material.LIME_CONCRETE, "gui.shop-sort.confirm", "gui.shop-sort.confirm-lore"), + ) + } + inventory.setItem( + SLOT_CANCEL, + button(player, Material.RED_CONCRETE, "gui.shop-sort.cancel", "gui.shop-sort.cancel-lore"), + ) + } + + override fun onClick(event: InventoryClickEvent) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + val held = pending[player.uniqueId] ?: return + + val mode = MODE_SLOTS[event.rawSlot] + if (mode != null) { + pending[player.uniqueId] = held.copy(mode = mode) + setup(player, event.inventory) + return + } + + when (event.rawSlot) { + SLOT_CONFIRM -> apply(player) + SLOT_CANCEL -> back(player) + } + } + + override fun onClose(event: InventoryCloseEvent) { + pending.remove((event.player as? Player)?.uniqueId ?: return) + } + + private fun apply(player: Player) { + val held = pending[player.uniqueId] ?: return + val mode = held.mode ?: return + val shop = ShopManager.get(held.shopId) ?: return + + ShopSorting.sort(shop, mode) + ShopManager.save() + + player.sendPrefixed(player.tr("shop.editor.sorted", "amount" to shop.entries.size)) + ShopRender.navigate { ShopEditorGUI.show(player, shop) } + } + + /** Back to the editor the sort was asked for from, rather than out into the world. */ + private fun back(player: Player) { + val shop = pending[player.uniqueId]?.let { ShopManager.get(it.shopId) } + if (shop == null) { + player.closeInventory() + return + } + ShopRender.navigate { ShopEditorGUI.show(player, shop) } + } + + private fun choice(player: Player, mode: ShopSortMode, chosen: ShopSortMode?): ItemStack { + val lines = buildList { + add(player.tr(loreKey(mode))) + if (mode == chosen) add(player.tr("gui.shop-sort.chosen")) else add(player.tr("gui.shop-sort.click-choose")) + } + + val item = itemStack(material(mode)) { + name(player.tr(nameKey(mode))) + meta { lore(LoreUtil.wrapLore(lines.joinToString(""))) } + } + + return if (mode == chosen) ShopRender.glowing(item) else item + } + + private fun nameKey(mode: ShopSortMode): String = when (mode) { + ShopSortMode.NAME -> "gui.shop-sort.name" + ShopSortMode.NAME_REVERSED -> "gui.shop-sort.name-reversed" + ShopSortMode.PRICE -> "gui.shop-sort.price" + ShopSortMode.PRICE_REVERSED -> "gui.shop-sort.price-reversed" + } + + private fun loreKey(mode: ShopSortMode): String = when (mode) { + ShopSortMode.NAME, ShopSortMode.NAME_REVERSED -> "gui.shop-sort.name-lore" + ShopSortMode.PRICE, ShopSortMode.PRICE_REVERSED -> "gui.shop-sort.price-lore" + } + + private fun material(mode: ShopSortMode): Material = when (mode) { + ShopSortMode.NAME, ShopSortMode.NAME_REVERSED -> Material.NAME_TAG + ShopSortMode.PRICE, ShopSortMode.PRICE_REVERSED -> Material.GOLD_INGOT + } + + private fun button(player: Player, material: Material, nameKey: String, loreKey: String): ItemStack = + itemStack(material) { + name(player.tr(nameKey)) + meta { lore(LoreUtil.wrapLore(player.tr(loreKey))) } + } + + companion object { + const val ID = "shop-sort" + + private const val SLOT_INFO = 4 + private const val SLOT_NAME = 10 + private const val SLOT_NAME_REVERSED = 12 + private const val SLOT_PRICE = 14 + private const val SLOT_PRICE_REVERSED = 16 + private const val SLOT_CONFIRM = 21 + private const val SLOT_CANCEL = 23 + + private val MODE_SLOTS = mapOf( + SLOT_NAME to ShopSortMode.NAME, + SLOT_NAME_REVERSED to ShopSortMode.NAME_REVERSED, + SLOT_PRICE to ShopSortMode.PRICE, + SLOT_PRICE_REVERSED to ShopSortMode.PRICE_REVERSED, + ) + + /** Opens the sort menu for [shop] through the registered instance. */ + fun show(player: Player, shop: ShopDefinition): Boolean { + val gui = GUIManager.getGUI(ID) as? ShopSortGUI ?: return false + gui.open(player, shop) + return true + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopStatsGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopStatsGUI.kt new file mode 100644 index 0000000..016b38e --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopStatsGUI.kt @@ -0,0 +1,115 @@ +package net.trilleo.mc.plugins.tritown.guis.shop + +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.enums.PagedLayout +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PagedPluginGUI +import net.trilleo.mc.plugins.tritown.shops.ShopDefinition +import net.trilleo.mc.plugins.tritown.shops.ShopEntry +import net.trilleo.mc.plugins.tritown.shops.ShopManager +import net.trilleo.mc.plugins.tritown.utils.LoreUtil +import net.trilleo.mc.plugins.tritown.utils.itemStack +import net.trilleo.mc.plugins.tritown.utils.sendPrefixed +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.inventory.ClickType +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.inventory.ItemStack +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +/** + * What a shop has actually traded. + * + * The header totals the whole shop, so an owner can see at a glance whether it + * is draining currency out of the economy or feeding it in, and each entry below + * shows which line is responsible. + */ +class ShopStatsGUI : PagedPluginGUI( + id = ID, + titleKey = "gui.shop-stats.title", + rows = 6, + fillMode = FillMode.NONE, + layout = PagedLayout.FRAMED, +) { + + private val viewing = ConcurrentHashMap() + + /** Opens the figures for [shop]. */ + fun open(player: Player, shop: ShopDefinition) { + viewing[player.uniqueId] = shop.id + GUIManager.open(player, ID) + } + + override fun getItems(player: Player): List { + val shop = shopOf(player) ?: return emptyList() + return listOf(header(player, shop)) + shop.entries.map { entry -> row(player, entry) } + } + + override fun onContentClick(event: InventoryClickEvent, page: Int) { + event.isCancelled = true + + val player = event.whoClicked as? Player ?: return + val shop = shopOf(player) ?: return + + val index = contentIndex(page, event.rawSlot) ?: return + if (index != 0 || event.click != ClickType.SHIFT_LEFT) return + + shop.entries.forEach { it.stats.reset() } + ShopManager.save() + player.sendPrefixed(player.tr("shop.stats-reset", "shop" to shop.displayName)) + ShopRender.navigate { show(player, shop) } + } + + override fun onClose(event: InventoryCloseEvent) { + super.onClose(event) + viewing.remove((event.player as? Player)?.uniqueId ?: return) + } + + private fun header(player: Player, shop: ShopDefinition): ItemStack { + val bought = shop.entries.sumOf { it.stats.bought } + val sold = shop.entries.sumOf { it.stats.sold } + val moneyIn = shop.entries.sumOf { it.stats.moneyIn } + val moneyOut = shop.entries.sumOf { it.stats.moneyOut } + + val lore = listOf( + player.tr("gui.shop-stats.bought", "amount" to bought), + player.tr("gui.shop-stats.sold", "amount" to sold), + player.tr("gui.shop-stats.money-in", "amount" to ShopRender.money(moneyIn)), + player.tr("gui.shop-stats.money-out", "amount" to ShopRender.money(moneyOut)), + player.tr("gui.shop-stats.net", "amount" to ShopRender.money(moneyIn - moneyOut)), + player.tr("gui.shop-stats.reset"), + ) + + return itemStack(Material.WRITABLE_BOOK) { + name(shop.displayName) + meta { lore(LoreUtil.wrapLore(lore.joinToString(""))) } + } + } + + private fun row(player: Player, entry: ShopEntry): ItemStack { + val lore = listOf( + player.tr("gui.shop-stats.bought", "amount" to entry.stats.bought), + player.tr("gui.shop-stats.sold", "amount" to entry.stats.sold), + player.tr("gui.shop-stats.money-in", "amount" to ShopRender.money(entry.stats.moneyIn)), + player.tr("gui.shop-stats.money-out", "amount" to ShopRender.money(entry.stats.moneyOut)), + ) + + return ShopRender.withLore(entry.displayStack(), lore) + } + + private fun shopOf(player: Player): ShopDefinition? = viewing[player.uniqueId]?.let(ShopManager::get) + + companion object { + const val ID = "shop-stats" + + /** Opens a shop's figures through the registered instance. */ + fun show(player: Player, shop: ShopDefinition): Boolean { + val gui = GUIManager.getGUI(ID) as? ShopStatsGUI ?: return false + gui.open(player, shop) + return true + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/ChatPromptListener.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/ChatPromptListener.kt new file mode 100644 index 0000000..3f38fe8 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/ChatPromptListener.kt @@ -0,0 +1,34 @@ +package net.trilleo.mc.plugins.tritown.listeners + +import io.papermc.paper.event.player.AsyncChatEvent +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import net.trilleo.mc.plugins.tritown.utils.ChatPrompt +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerQuitEvent + +/** + * Feeds chat into [ChatPrompt] when a player is answering a question. + * + * Runs at the lowest priority so an answer is taken before any chat plugin + * formats or broadcasts it, and the event is cancelled so the answer — which + * may be a permission node or a price — never reaches the channel. + */ +class ChatPromptListener : Listener { + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + fun onChat(event: AsyncChatEvent) { + if (!ChatPrompt.isWaiting(event.player)) return + + val message = PlainTextComponentSerializer.plainText().serialize(event.message()) + if (ChatPrompt.consume(event.player, message)) { + event.isCancelled = true + } + } + + @EventHandler + fun onQuit(event: PlayerQuitEvent) { + ChatPrompt.cancel(event.player) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/admin/PanelStateListener.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/admin/PanelStateListener.kt new file mode 100644 index 0000000..a250561 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/admin/PanelStateListener.kt @@ -0,0 +1,21 @@ +package net.trilleo.mc.plugins.tritown.listeners.admin + +import net.trilleo.mc.plugins.tritown.guis.admin.PanelState +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerQuitEvent + +/** + * Drops what an administrator was looking at when they leave. + * + * The panel's menus hand each other the chosen window, so it cannot be cleared + * when one of them closes — opening the next one closes the last. Quitting is + * the one moment where nothing is going to be opened next. + */ +class PanelStateListener : Listener { + + @EventHandler + fun onQuit(event: PlayerQuitEvent) { + PanelState.forget(event.player) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/shop/ShopNpcListener.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/shop/ShopNpcListener.kt new file mode 100644 index 0000000..f5847c2 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/shop/ShopNpcListener.kt @@ -0,0 +1,52 @@ +package net.trilleo.mc.plugins.tritown.listeners.shop + +import de.oliver.fancynpcs.api.actions.ActionTrigger +import de.oliver.fancynpcs.api.events.NpcInteractEvent +import net.trilleo.mc.plugins.tritown.config.ShopSettings +import net.trilleo.mc.plugins.tritown.guis.shop.ShopGUI +import net.trilleo.mc.plugins.tritown.shops.ShopAccess +import net.trilleo.mc.plugins.tritown.shops.ShopManager +import net.trilleo.mc.plugins.tritown.utils.sendPrefixed +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener + +/** + * Opens a shop when a player clicks the NPC standing behind its counter. + * + * This is the only class in the plugin that names a FancyNpcs type in a + * signature, and that is deliberate: the package scanner skips a class it cannot + * load, so on a server without FancyNpcs this listener quietly never registers + * and the rest of the feature is unaffected. + */ +class ShopNpcListener : Listener { + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + fun onNpcInteract(event: NpcInteractEvent) { + if (!ShopManager.isReady) return + if (!ShopSettings.isLoaded || !ShopSettings.snapshot.enabled) return + + // Only a click opens a shop; anything else an NPC reacts to is left alone. + if (event.interactionType != ActionTrigger.ANY_CLICK && + event.interactionType != ActionTrigger.LEFT_CLICK && + event.interactionType != ActionTrigger.RIGHT_CLICK + ) { + return + } + + val shop = ShopManager.byNpc(event.npc.data.id) ?: return + val player = event.player + + // Cancelled either way, so the NPC's own actions do not fire alongside + // the shop, or on top of the refusal. + event.isCancelled = true + + if (!ShopAccess.canOpen(player, shop)) { + player.sendPrefixed(player.tr("common.error", "message" to player.tr("shop.error.locked"))) + return + } + + ShopGUI.show(player, shop) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIFrame.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIFrame.kt new file mode 100644 index 0000000..dad67c1 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIFrame.kt @@ -0,0 +1,56 @@ +package net.trilleo.mc.plugins.tritown.registration + +import net.trilleo.mc.plugins.tritown.utils.itemStack +import org.bukkit.Material +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack + +/** + * The border drawn around a menu's content, and the slots it leaves free. + * + * Shared rather than living in [PagedPluginGUI], because a plain [PluginGUI] + * that lays out a grid of its own wants exactly the same border and the same + * idea of which slots are inside it. + */ +object GUIFrame { + + private const val ROW_SIZE = 9 + + /** A single border slot: black glass with no name and no tooltip. */ + fun pane(): ItemStack = itemStack(Material.BLACK_STAINED_GLASS_PANE) { + name(" ") + hideTooltip(true) + } + + /** + * The slots inside the border, in reading order. + * + * The top and bottom rows and the first and last column are the border, so + * content is everything in between. A menu that wants the bottom row for + * navigation or buttons of its own simply draws over it: that row is the + * bottom edge either way, and is never content. + */ + fun contentSlots(rows: Int): List { + if (rows < 3) return emptyList() + + return buildList { + for (row in 1..rows - 2) { + for (column in 1 until ROW_SIZE - 1) add(row * ROW_SIZE + column) + } + } + } + + /** + * Fills every slot of [inventory] that is not in [contentSlots] with the border. + * + * Callers draw their own buttons over it afterwards, so a reserved row can be + * bordered first and then written into. + */ + fun draw(inventory: Inventory, contentSlots: Collection) { + val inside = contentSlots.toSet() + val pane = pane() + for (slot in 0 until inventory.size) { + if (slot !in inside) inventory.setItem(slot, pane.clone()) + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIManager.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIManager.kt index 78699f1..2e6c735 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIManager.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIManager.kt @@ -9,6 +9,8 @@ import org.bukkit.event.EventHandler import org.bukkit.event.Listener import org.bukkit.event.inventory.InventoryClickEvent import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.event.inventory.InventoryDragEvent +import org.bukkit.event.player.PlayerQuitEvent import org.bukkit.inventory.Inventory import org.bukkit.plugin.java.JavaPlugin @@ -31,11 +33,14 @@ object GUIManager : Listener { private val guis = mutableMapOf() private val openGUIs = mutableMapOf>() + private lateinit var plugin: JavaPlugin + /** * Scans the GUIs package, instantiates every [PluginGUI] found, * stores them by id, and registers this manager as an event listener. */ fun registerAll(plugin: JavaPlugin) { + this.plugin = plugin val guiClasses = PackageScanner.findClasses( plugin, GUIS_PACKAGE, PluginGUI::class.java ) @@ -73,6 +78,21 @@ object GUIManager : Listener { return true } + /** + * Opens a registered GUI on the following tick. + * + * A click is still being delivered while its handler runs, and opening an + * inventory from inside that delivery leaves the server and the client + * disagreeing about what is on screen. Any menu reached by clicking inside + * another one is opened this way. + * + * @param player the player to open the GUI for + * @param id the unique identifier of the GUI to open + */ + fun openLater(player: Player, id: String) { + Bukkit.getScheduler().runTask(plugin, Runnable { open(player, id) }) + } + /** * Returns the [PluginGUI] registered under the given [id], * or `null` if no GUI with that id exists. @@ -92,6 +112,14 @@ object GUIManager : Listener { gui.onClick(event) } + @EventHandler + fun onInventoryDrag(event: InventoryDragEvent) { + val player = event.whoClicked as? Player ?: return + val (gui, inventory) = openGUIs[player] ?: return + if (event.inventory !== inventory) return + gui.onDrag(event) + } + @EventHandler fun onInventoryClose(event: InventoryCloseEvent) { val player = event.player as? Player ?: return @@ -101,6 +129,15 @@ object GUIManager : Listener { gui.onClose(event) } + /** + * Quitting does not always close the inventory first, and the map is keyed + * by the player object, so the entry would outlive the session. + */ + @EventHandler + fun onPlayerQuit(event: PlayerQuitEvent) { + openGUIs.remove(event.player) + } + /** * Tries to create an instance of [clazz] using a constructor that accepts * a [JavaPlugin]; falls back to a no-arg constructor. diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PagedPluginGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PagedPluginGUI.kt index 322110d..62513ff 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PagedPluginGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PagedPluginGUI.kt @@ -4,6 +4,7 @@ import net.kyori.adventure.key.Key import net.kyori.adventure.sound.Sound import net.trilleo.mc.plugins.tritown.enums.FillMode import net.trilleo.mc.plugins.tritown.enums.PagedGUIMode +import net.trilleo.mc.plugins.tritown.enums.PagedLayout import net.trilleo.mc.plugins.tritown.utils.itemStack import net.trilleo.mc.plugins.tritown.utils.tr import org.bukkit.Material @@ -22,9 +23,11 @@ import java.util.* * `net.trilleo.mc.plugins.tritown.guis` package (or any subpackage) to * have it automatically discovered and registered at startup. * - * The bottom row of the inventory is reserved for navigation controls. - * Content slots are every slot **except** the last row. For example, a - * 6-row GUI provides 45 content slots per page (rows 1–5). + * The bottom row of the inventory is reserved for navigation controls, and + * [layout] decides what the rest of it does. [PagedLayout.FULL] fills every slot + * above that row with content — a 6-row GUI gives 45 content slots per page. + * [PagedLayout.FRAMED] insets the content by one slot on every side, giving 28 + * instead and a border around them. * * The class must have either: * - A no-arg constructor, **or** @@ -89,7 +92,8 @@ abstract class PagedPluginGUI( titleKey: String, rows: Int = 6, fillMode: FillMode = FillMode.NONE, - val mode: PagedGUIMode = PagedGUIMode.LIST + val mode: PagedGUIMode = PagedGUIMode.LIST, + val layout: PagedLayout = PagedLayout.FULL, ) : PluginGUI(id, titleKey, rows, fillMode) { /** Tracks the current page for each player viewing this GUI. */ @@ -98,7 +102,7 @@ abstract class PagedPluginGUI( /** * Returns all items that should be distributed across pages for the * given player. The list may be of any size; items are automatically - * split into pages of [contentSlots] each. + * split into pages of [pageSize] each. * * Used when [mode] is [PagedGUIMode.LIST]. Override this method to supply * the items to paginate. @@ -112,21 +116,27 @@ abstract class PagedPluginGUI( * Returns a map of items to place at specific pages and slots. * * The outer map key is the **zero-based page index**; the inner map key is - * the **zero-based slot index** within the content area of that page (slots - * 0 to [contentSlots]`- 1`). Pages that are missing from the map are - * rendered empty. + * the **zero-based position** within the content area of that page (0 to + * [pageSize]`- 1`), not an inventory slot — a framed layout maps those + * positions onto the slots inside its border. Pages that are missing from + * the map are rendered empty. * * Used when [mode] is [PagedGUIMode.SET]. Override this method to supply * manually positioned items. * * @param player the player the GUI is being opened for - * @return a map of `page → (slot → item)` describing the full contents + * @return a map of `page → (position → item)` describing the full contents */ open fun getSetItems(player: Player): Map> = emptyMap() /** * Called when a player clicks a **content slot** (not a navigation - * button). Override to add custom click handling. + * button or a button in the navigation row). Override to add custom click + * handling. + * + * Use [contentIndex] to turn the clicked slot into a position in the list + * returned by [getItems]; the raw slot is an inventory slot and does not + * match that list once a border is in the way. * * Clicks are cancelled by default to prevent item theft. * @@ -135,13 +145,69 @@ abstract class PagedPluginGUI( */ open fun onContentClick(event: InventoryClickEvent, page: Int) {} - /** The number of usable content slots per page (all rows except the last). */ - private val contentSlots: Int - get() = (rows - 1) * ROW_SIZE + /** + * Buttons to place in the navigation row, keyed by their offset in that row + * (0 to 8). + * + * Offsets 0, 4 and 8 belong to Previous, the page indicator and Next, and + * anything placed there is ignored. Because these sit in a row that never + * moves, a button here stays where it is however the content grows — which + * is the point of putting an action here rather than among the items. + * + * @param player the player the GUI is being drawn for + */ + open fun navButtons(player: Player): Map = emptyMap() + + /** + * Called when a player clicks one of this GUI's own [navButtons]. + * + * @param event the inventory click event + * @param offset the offset in the navigation row that was clicked (0 to 8) + */ + open fun onNavClick(event: InventoryClickEvent, offset: Int) {} + + /** The inventory slots that hold content, in reading order. */ + private val contentSlots: List by lazy { + when (layout) { + PagedLayout.FULL -> (0 until (rows - 1) * ROW_SIZE).toList() + PagedLayout.FRAMED -> GUIFrame.contentSlots(rows) + } + } + + private val contentPositions: Map by lazy { + contentSlots.withIndex().associate { (position, slot) -> slot to position } + } + + /** How many items fit on one page. */ + protected val pageSize: Int + get() = contentSlots.size.coerceAtLeast(1) /** The first slot index of the navigation row (the last row). */ private val navRowStart: Int - get() = contentSlots + get() = (rows - 1) * ROW_SIZE + + /** + * The position in [getItems]'s list that [rawSlot] shows on [page], or + * `null` when that slot holds no content. + */ + protected fun contentIndex(page: Int, rawSlot: Int): Int? = + contentPositions[rawSlot]?.let { position -> page * pageSize + position } + + /** + * Redraws the page [player] is looking at, into the inventory they have open. + * + * A menu whose contents change under a click — an entry removed, an item + * moved — would otherwise have to reopen itself to show the change, which + * drops the viewer back onto the first page of whatever they were part-way + * through. The page is clamped, so the last item leaving a page steps back + * rather than showing an empty one. + */ + protected fun refresh(player: Player, inventory: Inventory) { + val total = totalPages(player) + val page = (playerPages[player.uniqueId] ?: 0).coerceIn(0, total - 1) + playerPages[player.uniqueId] = page + renderPage(player, inventory, page) + } // ----- PluginGUI overrides ------------------------------------------------ @@ -159,15 +225,16 @@ abstract class PagedPluginGUI( // Ignore clicks outside the GUI inventory if (slot < 0 || slot >= rows * ROW_SIZE) return - when (slot) { - navRowStart + PREVIOUS_OFFSET -> { + val navOffset = slot - navRowStart + when { + navOffset == PREVIOUS_OFFSET -> { if (page > 0) { openPage(player, event.inventory, page - 1) player.playSound(Sound.sound(Key.key("minecraft:ui.button.click"), Sound.Source.UI, 1f, 1f)) } } - navRowStart + NEXT_OFFSET -> { + navOffset == NEXT_OFFSET -> { val totalPages = totalPages(player) if (page < totalPages - 1) { openPage(player, event.inventory, page + 1) @@ -175,9 +242,9 @@ abstract class PagedPluginGUI( } } - else -> { - if (slot < contentSlots) onContentClick(event, page) - } + navOffset == PAGE_INDICATOR_OFFSET -> return + navOffset in 0 until ROW_SIZE -> onNavClick(event, navOffset) + slot in contentPositions -> onContentClick(event, page) } } @@ -195,7 +262,7 @@ abstract class PagedPluginGUI( private fun totalPages(player: Player): Int = when (mode) { PagedGUIMode.LIST -> { val itemCount = getItems(player).size - if (itemCount == 0) 1 else (itemCount + contentSlots - 1) / contentSlots + if (itemCount == 0) 1 else (itemCount + pageSize - 1) / pageSize } PagedGUIMode.SET -> { @@ -215,38 +282,54 @@ abstract class PagedPluginGUI( inventory.clear() fillInventory(this, inventory) + if (layout == PagedLayout.FRAMED) GUIFrame.draw(inventory, contentSlots) val totalPages = totalPages(player) when (mode) { PagedGUIMode.LIST -> { val items = getItems(player) - val start = page * contentSlots - val end = minOf(start + contentSlots, items.size) - for (i in start until end) { - inventory.setItem(i - start, items[i]) + val start = page * pageSize + val end = minOf(start + pageSize, items.size) + for (index in start until end) { + inventory.setItem(contentSlots[index - start], items[index]) } } PagedGUIMode.SET -> { val pageItems = getSetItems(player)[page] ?: emptyMap() - for ((slot, item) in pageItems) { - if (slot in 0 until contentSlots) { - inventory.setItem(slot, item) - } + for ((position, item) in pageItems) { + contentSlots.getOrNull(position)?.let { inventory.setItem(it, item) } } } } - // Navigation row – fill all slots with gray stained glass panes first - for (offset in 0 until ROW_SIZE) { - inventory.setItem(navRowStart + offset, itemStack(Material.GRAY_STAINED_GLASS_PANE) { + renderNavRow(player, inventory, page, totalPages) + } + + /** Draws the navigation row: filler, then this GUI's own buttons, then the page controls. */ + private fun renderNavRow(player: Player, inventory: Inventory, page: Int, totalPages: Int) { + // A framed menu carries its border all the way round, so the row below the + // content matches the rest of it rather than changing colour. + val filler = if (layout == PagedLayout.FRAMED) { + GUIFrame.pane() + } else { + itemStack(Material.GRAY_STAINED_GLASS_PANE) { name(" ") hideTooltip(true) - }) + } + } + + for (offset in 0 until ROW_SIZE) { + inventory.setItem(navRowStart + offset, filler.clone()) + } + + for ((offset, button) in navButtons(player)) { + if (offset in 0 until ROW_SIZE && offset !in RESERVED_OFFSETS) { + inventory.setItem(navRowStart + offset, button) + } } - // Place navigation items on top of the filler if (page > 0) { inventory.setItem( navRowStart + PREVIOUS_OFFSET, createNavItem( @@ -286,6 +369,9 @@ abstract class PagedPluginGUI( private const val PREVIOUS_OFFSET = 0 private const val PAGE_INDICATOR_OFFSET = 4 private const val NEXT_OFFSET = 8 + + /** Navigation-row offsets the page controls own, which [navButtons] may not use. */ + private val RESERVED_OFFSETS = setOf(PREVIOUS_OFFSET, PAGE_INDICATOR_OFFSET, NEXT_OFFSET) } /** diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PluginGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PluginGUI.kt index e8a7a0e..799ed1f 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PluginGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PluginGUI.kt @@ -7,6 +7,7 @@ import net.trilleo.mc.plugins.tritown.utils.tr import org.bukkit.entity.Player import org.bukkit.event.inventory.InventoryClickEvent import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.event.inventory.InventoryDragEvent import org.bukkit.inventory.Inventory /** @@ -84,6 +85,19 @@ abstract class PluginGUI( event.isCancelled = true } + /** + * Called when a player drags a stack across this GUI. + * + * By default, all drags are cancelled, for the same reason clicks are. + * Override when the GUI reads what was dragged, and cancel the event there + * too unless the slot is genuinely meant to accept the stack. + * + * @param event the inventory drag event + */ + open fun onDrag(event: InventoryDragEvent) { + event.isCancelled = true + } + /** * Called when a player closes this GUI. * diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ItemCodec.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ItemCodec.kt new file mode 100644 index 0000000..9195b61 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ItemCodec.kt @@ -0,0 +1,34 @@ +package net.trilleo.mc.plugins.tritown.shops + +import org.bukkit.inventory.ItemStack +import java.util.* + +/** + * Turns an [ItemStack] into a string a shop file can hold, and back again. + * + * Paper's own byte form is used rather than a field-by-field description, + * because it is the only round-trip that keeps every data component: a renamed, + * enchanted, custom-model stack, a TriTown custom item, or an item another + * plugin invented all come back exactly as they went in. Paper stamps the game + * version into those bytes and upgrades them on the way out, so a shop survives + * a Minecraft update. + * + * Decoding never throws. One unreadable entry must not take a whole shop with + * it, so a failure is reported as `null` and left for the caller to log and skip. + */ +object ItemCodec { + + /** [item] as Base64 text. */ + fun encode(item: ItemStack): String = Base64.getEncoder().encodeToString(item.serializeAsBytes()) + + /** The stack [encoded] holds, or `null` when it cannot be read. */ + fun decode(encoded: String): ItemStack? = runCatching { + ItemStack.deserializeBytes(Base64.getDecoder().decode(encoded)) + }.getOrNull() + + /** [items] as Base64 text, in order. */ + fun encodeAll(items: List): List = items.map(::encode) + + /** The stacks [encoded] holds, silently dropping any that cannot be read. */ + fun decodeAll(encoded: List): List = encoded.mapNotNull(::decode) +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopAccess.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopAccess.kt new file mode 100644 index 0000000..397de0f --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopAccess.kt @@ -0,0 +1,72 @@ +package net.trilleo.mc.plugins.tritown.shops + +import com.palmergames.bukkit.towny.TownyAPI +import net.trilleo.mc.plugins.tritown.enums.TownyRequirement +import org.bukkit.entity.Player + +/** + * Decides whether a player may use a shop or one of its entries. + * + * Towny is read here and nowhere else in the feature, and it is read fresh on + * every check: a player who joins a town mid-session sees the town's prices + * without relogging, and nothing TriTown holds can fall out of step with Towny. + */ +object ShopAccess { + + /** + * Every Towny requirement [player] currently satisfies. + * + * Read once per menu render rather than once per entry, because a shop can + * hold dozens of entries and they all ask the same questions. + */ + fun standing(player: Player): Set { + val standing = linkedSetOf(TownyRequirement.NONE) + val resident = TownyAPI.getInstance().getResident(player) + + if (resident == null || !resident.hasTown()) { + standing += TownyRequirement.NO_TOWN + return standing + } + + standing += TownyRequirement.HAS_TOWN + if (resident.isMayor) standing += TownyRequirement.IS_MAYOR + if (resident.hasNation()) { + standing += TownyRequirement.HAS_NATION + if (resident.isKing) standing += TownyRequirement.IS_KING + } + + return standing + } + + /** Whether [gate] lets [player] through, given the [standing] already read for them. */ + fun allows(player: Player, gate: ShopGate, standing: Set): Boolean { + val permission = gate.permission + if (!permission.isNullOrBlank() && !player.hasPermission(permission)) return false + return gate.towny in standing + } + + /** Whether [player] may open [shop] at all. */ + fun canOpen(player: Player, shop: ShopDefinition): Boolean = + allows(player, shop.gate, standing(player)) + + /** + * Why [gate] refused, as a translation key, or `null` when it did not. + * + * The permission side is deliberately vague: a player has no use for the + * node's name, and printing it advertises the server's permission layout. + */ + fun refusalKey(player: Player, gate: ShopGate, standing: Set): String? { + val permission = gate.permission + if (!permission.isNullOrBlank() && !player.hasPermission(permission)) return "gui.shop.locked-permission" + if (gate.towny in standing) return null + + return when (gate.towny) { + TownyRequirement.NONE -> null + TownyRequirement.HAS_TOWN -> "gui.shop.locked-has-town" + TownyRequirement.NO_TOWN -> "gui.shop.locked-no-town" + TownyRequirement.HAS_NATION -> "gui.shop.locked-has-nation" + TownyRequirement.IS_MAYOR -> "gui.shop.locked-is-mayor" + TownyRequirement.IS_KING -> "gui.shop.locked-is-king" + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopCost.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopCost.kt new file mode 100644 index 0000000..b2c2607 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopCost.kt @@ -0,0 +1,29 @@ +package net.trilleo.mc.plugins.tritown.shops + +import org.bukkit.inventory.ItemStack + +/** + * What one bundle costs, or what one bundle pays out. + * + * Money and items are both optional and both may be set at once, so an entry can + * be sold for currency, bartered for items, or priced as a mix of the two. + * + * @param money the currency amount, before any discount + * @param items the stacks required alongside it; each stack's amount is its quantity + */ +data class ShopCost(val money: Double = 0.0, val items: List = emptyList()) { + + /** Whether this costs nothing at all. */ + val isFree: Boolean get() = money <= 0.0 && items.isEmpty() + + /** Whether any currency changes hands. */ + val hasMoney: Boolean get() = money > 0.0 + + /** A deep copy, so a stored cost cannot be mutated through a stack handed out of it. */ + fun copyDeep(): ShopCost = ShopCost(money, items.map { it.clone() }) + + companion object { + /** A cost of nothing, used for a giveaway or an item-only trade. */ + val FREE = ShopCost() + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopDefinition.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopDefinition.kt new file mode 100644 index 0000000..38b41a9 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopDefinition.kt @@ -0,0 +1,31 @@ +package net.trilleo.mc.plugins.tritown.shops + +/** + * One shop: a title, a gate, and the entries it offers. + * + * The [id] is what NPC bindings and purchase counters are keyed by, so it never + * changes once a shop exists; [displayName] is what players read and can be + * edited freely. That name is written by an administrator as MiniMessage rather + * than being a translation key, because a server's own shops are named in the + * server's own words. + * + * @param npcIds FancyNpcs ids, not names, so renaming an NPC does not break the binding + */ +data class ShopDefinition( + val id: String, + var displayName: String, + var gate: ShopGate = ShopGate.OPEN, + val entries: MutableList = mutableListOf(), + val npcIds: MutableSet = linkedSetOf(), +) { + + /** The entry with [entryId], or `null` when it has been removed since the menu was opened. */ + fun entry(entryId: String): ShopEntry? = entries.firstOrNull { it.id == entryId } + + companion object { + /** Whether [id] is usable: lowercase letters, digits, dashes and underscores only. */ + fun isValidId(id: String): Boolean = id.isNotEmpty() && id.length <= 32 && ID_PATTERN.matches(id) + + private val ID_PATTERN = Regex("[a-z0-9][a-z0-9_-]*") + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopEntry.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopEntry.kt new file mode 100644 index 0000000..b7e3c86 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopEntry.kt @@ -0,0 +1,76 @@ +package net.trilleo.mc.plugins.tritown.shops + +import net.trilleo.mc.plugins.tritown.enums.MatchMode +import org.bukkit.inventory.ItemStack +import java.util.* + +/** + * One line of goods in a shop. + * + * The [item] is stored verbatim, custom data and all, and [bundle] is how many + * of it one purchase moves: an entry of bread with a bundle of 16 sells sixteen + * loaves per click. The two are kept apart so a bundle can exceed what a stack + * holds — 128 bread is a valid bundle, handed over as two stacks. Buying and + * selling are independent, so an entry can do either, both, or — with both left + * null — act as a display piece. + * + * @param id stable across reordering and renaming, because purchase counters are keyed by it + * @param bundle how many items one purchase moves; at least 1, with no upper bound + */ +data class ShopEntry( + val id: String = UUID.randomUUID().toString(), + var item: ItemStack, + var bundle: Int = 1, + var buy: ShopCost? = null, + var sell: ShopCost? = null, + var gate: ShopGate = ShopGate.OPEN, + var limit: ShopLimit? = null, + var stock: ShopStock? = null, + var discountable: Boolean = true, + var matchMode: MatchMode = MatchMode.EXACT, + var stats: ShopStats = ShopStats(), +) { + + /** How many items one bundle is, never less than one. */ + val bundleSize: Int get() = bundle.coerceAtLeast(1) + + /** Whether a player can buy this. */ + val isBuyable: Boolean get() = buy != null + + /** Whether the shop buys this back. */ + val isSellable: Boolean get() = sell != null + + /** + * One bundle as a single stack, for a menu slot rather than for handing over. + * + * Capped at what a slot can show, because a bundle larger than a stack still + * has to be drawn in one square; the real count is written into the lore. + */ + fun displayStack(): ItemStack = item.clone().apply { amount = bundleSize.coerceAtMost(item.maxStackSize) } + + /** + * [bundles] bundles of the goods, split into stacks the game allows. + * + * Split here rather than left as one oversized stack, so that what is + * checked for room is exactly what is handed over. + */ + fun goodsStacks(bundles: Int = 1): List { + val total = bundleSize * bundles + val perStack = item.maxStackSize.coerceAtLeast(1) + + return buildList { + var outstanding = total + while (outstanding > 0) { + val size = minOf(outstanding, perStack) + add(item.clone().apply { amount = size }) + outstanding -= size + } + } + } + + /** Whether [stack] is close enough to the goods to count, under this entry's [matchMode]. */ + fun matches(stack: ItemStack): Boolean = when (matchMode) { + MatchMode.EXACT -> item.isSimilar(stack) + MatchMode.MATERIAL -> item.type == stack.type + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopGate.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopGate.kt new file mode 100644 index 0000000..1024c5a --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopGate.kt @@ -0,0 +1,29 @@ +package net.trilleo.mc.plugins.tritown.shops + +import net.trilleo.mc.plugins.tritown.enums.TownyRequirement + +/** + * Who may use a shop, or one entry inside it. + * + * [permission] is written by whoever set the shop up, so it cannot be registered + * at startup the way a command's nodes are — it is checked as it stands and + * defined in the server's permissions plugin. + * + * @param permission a node the player must hold, or `null` for no check + * @param towny what the player's standing in Towny must be + * @param hideWhenLocked whether a player who fails the check sees the entry greyed out or not at all + */ +data class ShopGate( + val permission: String? = null, + val towny: TownyRequirement = TownyRequirement.NONE, + val hideWhenLocked: Boolean = false, +) { + + /** Whether this gate checks anything at all. */ + val isOpen: Boolean get() = permission.isNullOrBlank() && towny == TownyRequirement.NONE + + companion object { + /** A gate that lets everybody through. */ + val OPEN = ShopGate() + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopInventory.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopInventory.kt new file mode 100644 index 0000000..cf7722a --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopInventory.kt @@ -0,0 +1,118 @@ +package net.trilleo.mc.plugins.tritown.shops + +import net.trilleo.mc.plugins.tritown.enums.MatchMode +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import org.bukkit.inventory.ItemStack + +/** + * The inventory side of a trade. + * + * Every method here runs on the server thread, because inventories may not be + * touched from anywhere else. Removals hand back exactly what they took so a + * trade that fails later can put it right back — a purchase must never be able + * to leave a player short of both the goods and the price. + */ +object ShopInventory { + + /** A player's storage, without armour or the off-hand, which a trade never touches. */ + private const val STORAGE_SLOTS = 36 + + /** How many of [template] the player holds, counting by [mode]. */ + fun count(player: Player, template: ItemStack, mode: MatchMode): Int = + player.inventory.storageContents.sumOf { stack -> + if (stack != null && matches(template, stack, mode)) stack.amount else 0 + } + + /** + * Whether [items] would all fit in the player's storage. + * + * Tested against a copy rather than by counting empty slots, so partial + * stacks, stack limits and items that do not stack are all accounted for by + * the same code that will do the real insertion. + */ + fun hasSpaceFor(player: Player, items: List): Boolean { + if (items.isEmpty()) return true + + val scratch = Bukkit.createInventory(null, STORAGE_SLOTS) + scratch.storageContents = player.inventory.storageContents.map { it?.clone() }.toTypedArray() + return scratch.addItem(*split(items).toTypedArray()).isEmpty() + } + + /** + * Takes [amount] items matching [template] out of the player's storage. + * + * @return the stacks that were removed, or `null` when the player did not + * have enough — in which case nothing is taken at all + */ + fun remove(player: Player, template: ItemStack, mode: MatchMode, amount: Int): List? { + if (amount <= 0) return emptyList() + if (count(player, template, mode) < amount) return null + + val removed = mutableListOf() + var outstanding = amount + val contents = player.inventory.storageContents + + for (index in contents.indices) { + if (outstanding == 0) break + val stack = contents[index] ?: continue + if (!matches(template, stack, mode)) continue + + val taken = minOf(outstanding, stack.amount) + removed += stack.clone().apply { this.amount = taken } + outstanding -= taken + + if (taken == stack.amount) { + contents[index] = null + } else { + stack.amount -= taken + } + } + + player.inventory.storageContents = contents + return removed + } + + /** + * Puts [items] into the player's storage, dropping at their feet whatever + * will not fit. + * + * Space is checked before a trade starts, so a drop only happens when + * something else filled the inventory in between. Dropping is still the + * right answer there: the player has already paid. + */ + fun give(player: Player, items: List) { + if (items.isEmpty()) return + + val leftover = player.inventory.addItem(*split(items).toTypedArray()) + for (stack in leftover.values) { + player.world.dropItemNaturally(player.location, stack) + } + } + + /** + * [items] broken down into stacks the game allows. + * + * A price multiplied by a shift-click can ask for more than one stack holds, + * and an oversized stack is accepted in memory but cannot be stored, so it + * is split before anything is measured against an inventory. + */ + fun split(items: List): List = items.flatMap { item -> + val perStack = item.maxStackSize.coerceAtLeast(1) + if (item.amount <= perStack) return@flatMap listOf(item.clone()) + + buildList { + var outstanding = item.amount + while (outstanding > 0) { + val size = minOf(outstanding, perStack) + add(item.clone().apply { amount = size }) + outstanding -= size + } + } + } + + private fun matches(template: ItemStack, stack: ItemStack, mode: MatchMode): Boolean = when (mode) { + MatchMode.EXACT -> template.isSimilar(stack) + MatchMode.MATERIAL -> template.type == stack.type + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimit.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimit.kt new file mode 100644 index 0000000..1d2df95 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimit.kt @@ -0,0 +1,37 @@ +package net.trilleo.mc.plugins.tritown.shops + +import net.trilleo.mc.plugins.tritown.enums.LimitPeriod + +/** + * A cap on how much of one entry a single player may buy. + * + * Windows are counted from the epoch rather than from each player's first + * purchase, so everyone's daily limit rolls over at the same moment and a + * player cannot stagger their buying to get more than the cap allows. + * + * @param amount how many bundles a player may buy per window + * @param period how often the window starts over + */ +data class ShopLimit(val amount: Int, val period: LimitPeriod) { + + /** The window [epochMillis] falls in. A lifetime limit has only one window. */ + fun windowAt(epochMillis: Long): Long = when (period) { + LimitPeriod.NONE -> 0L + LimitPeriod.DAILY -> Math.floorDiv(epochMillis, DAY_MILLIS) + LimitPeriod.WEEKLY -> Math.floorDiv(epochMillis, 7L * DAY_MILLIS) + } + + /** + * How many bundles are still available to a player who has bought [used] of + * them in window [usedWindow]. + * + * A count from an earlier window is spent, so it is ignored rather than + * having to be cleared when the window turns over. + */ + fun remaining(used: Int, usedWindow: Long, epochMillis: Long): Int = + if (usedWindow != windowAt(epochMillis)) amount else (amount - used).coerceAtLeast(0) + + private companion object { + const val DAY_MILLIS = 24L * 60L * 60L * 1000L + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimits.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimits.kt new file mode 100644 index 0000000..56e5a11 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimits.kt @@ -0,0 +1,74 @@ +package net.trilleo.mc.plugins.tritown.shops + +import com.google.gson.JsonObject +import net.trilleo.mc.plugins.tritown.data.PlayerDataManager +import org.bukkit.entity.Player + +/** + * How much of a limited entry each player has already bought. + * + * Counters live in the buyer's own player data rather than with the shop: they + * are read and written only while that player is online, and keeping them there + * means deleting a shop cannot leave a growing table of dead counters behind. + * + * A count is stored with the window it belongs to, so a window that has turned + * over is simply ignored — nothing has to sweep old counters at midnight. + */ +object ShopLimits { + + private const val ROOT_KEY = "shop-limits" + private const val COUNT = "n" + private const val WINDOW = "w" + + /** How many more bundles of [entry] in [shop] the player may buy, or `null` when it is unlimited. */ + fun remaining( + player: Player, + shop: ShopDefinition, + entry: ShopEntry, + now: Long = System.currentTimeMillis(), + ): Int? { + val limit = entry.limit ?: return null + val record = record(player, shop, entry) ?: return limit.amount + return limit.remaining(record.first, record.second, now) + } + + /** Records [bundles] bought, resetting the count first when the window has turned over. */ + fun record( + player: Player, + shop: ShopDefinition, + entry: ShopEntry, + bundles: Int, + now: Long = System.currentTimeMillis(), + ) { + val limit = entry.limit ?: return + val window = limit.windowAt(now) + val previous = record(player, shop, entry) + val count = if (previous != null && previous.second == window) previous.first + bundles else bundles + + val root = root(player) + root.add(key(shop, entry), JsonObject().apply { + addProperty(COUNT, count) + addProperty(WINDOW, window) + }) + PlayerDataManager.get(player).set(ROOT_KEY, root) + } + + /** Forgets every counter [player] holds for [shop], for a shop that has been deleted. */ + fun forget(player: Player, shop: ShopDefinition) { + val root = root(player) + val prefix = "${shop.id}/" + root.keySet().filter { it.startsWith(prefix) }.forEach(root::remove) + PlayerDataManager.get(player).set(ROOT_KEY, root) + } + + private fun record(player: Player, shop: ShopDefinition, entry: ShopEntry): Pair? { + val stored = root(player).getAsJsonObject(key(shop, entry)) ?: return null + val count = stored.get(COUNT)?.asInt ?: return null + val window = stored.get(WINDOW)?.asLong ?: return null + return count to window + } + + private fun root(player: Player): JsonObject = PlayerDataManager.get(player).getJsonObject(ROOT_KEY) + + private fun key(shop: ShopDefinition, entry: ShopEntry): String = "${shop.id}/${entry.id}" +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopManager.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopManager.kt new file mode 100644 index 0000000..ba4fc9b --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopManager.kt @@ -0,0 +1,230 @@ +package net.trilleo.mc.plugins.tritown.shops + +import net.trilleo.mc.plugins.tritown.enums.LimitPeriod +import net.trilleo.mc.plugins.tritown.enums.MatchMode +import net.trilleo.mc.plugins.tritown.enums.TownyRequirement +import net.trilleo.mc.plugins.tritown.shops.storage.* +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.logging.Logger + +/** + * Every shop the server has, and the only thing that reads or writes them. + * + * Definitions are edited rarely and must never be lost, so an edit is written + * out the moment it is made. Stock and statistics change on every purchase, so + * they only mark the shops dirty and are flushed by + * [net.trilleo.mc.plugins.tritown.tasks.shop.ShopSaveTask] — a crash costs at + * most one flush interval of counters, never a definition. + * + * Shops are read from the server thread when a menu opens and written from the + * flush task, so the registry is concurrent and a write takes a snapshot. + */ +object ShopManager { + + private val shops = ConcurrentHashMap() + private val dirty = AtomicBoolean(false) + + private lateinit var storage: ShopStorage + private lateinit var logger: Logger + + @Volatile + var isReady: Boolean = false + private set + + /** + * Loads every stored shop. + * + * A store that cannot be read at all leaves the feature off rather than + * starting empty, because an empty start would be written back over the + * real shops at the next save. + */ + fun start(store: ShopStorage, pluginLogger: Logger) { + storage = store + logger = pluginLogger + + val loaded = try { + store.loadAll() + } catch (e: ShopStorageException) { + logger.severe("Shops are unavailable: ${e.message}") + isReady = false + return + } + + shops.clear() + for (stored in loaded) { + val shop = toDefinition(stored) + shops[shop.id] = shop + } + + dirty.set(false) + isReady = true + logger.info("Loaded ${shops.size} shop(s)") + } + + /** Writes anything outstanding and stops serving shops. */ + fun shutdown() { + if (!isReady) return + flush() + isReady = false + shops.clear() + } + + // ── Reading ───────────────────────────────────────────────────────── + + /** The shop with [id], or `null` when there is none. */ + fun get(id: String): ShopDefinition? = shops[id.lowercase()] + + /** Every shop, ordered by id so a listing does not shuffle between restarts. */ + fun all(): List = shops.values.sortedBy { it.id } + + /** Every shop id, ordered. */ + fun ids(): List = shops.keys.sorted() + + /** The shop bound to the FancyNpcs NPC with [npcId], or `null` when that NPC opens nothing. */ + fun byNpc(npcId: String): ShopDefinition? = shops.values.firstOrNull { npcId in it.npcIds } + + // ── Editing ───────────────────────────────────────────────────────── + + /** + * Creates an empty shop called [id]. + * + * @return the new shop, or `null` when [id] is malformed or already taken + */ + fun create(id: String, displayName: String): ShopDefinition? { + val key = id.lowercase() + if (!ShopDefinition.isValidId(key) || shops.containsKey(key)) return null + + val shop = ShopDefinition(id = key, displayName = displayName) + shops[key] = shop + save() + return shop + } + + /** Removes the shop with [id]. Returns `false` when there was none. */ + fun delete(id: String): Boolean { + val removed = shops.remove(id.lowercase()) != null + if (removed) save() + return removed + } + + /** Writes every shop out now, for a change to a definition that must not be lost. */ + fun save() { + if (!isReady) return + storage.saveAll(snapshot()) + dirty.set(false) + } + + /** Records that stock or statistics changed, to be written by the next flush. */ + fun markDirty() { + dirty.set(true) + } + + /** Writes the shops out when anything has changed since the last write. */ + fun flush() { + if (!isReady) return + if (!dirty.compareAndSet(true, false)) return + storage.saveAll(snapshot()) + } + + private fun snapshot(): List = shops.values.sortedBy { it.id }.map(::toStored) + + // ── Conversion ────────────────────────────────────────────────────── + + private fun toDefinition(stored: StoredShop): ShopDefinition { + val entries = stored.entries.mapNotNull { entry -> toEntry(stored.id, entry) } + + return ShopDefinition( + id = stored.id.lowercase(), + displayName = stored.displayName.ifBlank { stored.id }, + gate = ShopGate(stored.permission, requirement(stored.towny), stored.hideWhenLocked), + entries = entries.toMutableList(), + npcIds = stored.npcIds.toCollection(linkedSetOf()), + ) + } + + private fun toEntry(shopId: String, entry: StoredEntry): ShopEntry? { + val item = ItemCodec.decode(entry.item) ?: run { + logger.warning("Dropped an unreadable item from shop $shopId") + return null + } + + val limit = if (entry.limitAmount > 0) { + ShopLimit(entry.limitAmount, enumOrDefault(entry.limitPeriod, LimitPeriod.NONE)) + } else { + null + } + + val stock = if (entry.stockMax > 0) { + ShopStock( + max = entry.stockMax, + restockSeconds = entry.stockRestockSeconds, + remaining = entry.stockRemaining.coerceIn(0, entry.stockMax), + lastRestock = entry.stockLastRestock, + ) + } else { + null + } + + return ShopEntry( + id = entry.id.ifBlank { UUID.randomUUID().toString() }, + item = item, + // A shop written before the bundle was its own field kept it as the item's stack size. + bundle = if (entry.bundle > 0) entry.bundle else item.amount, + buy = toCost(entry.buy), + sell = toCost(entry.sell), + gate = ShopGate(entry.permission, requirement(entry.towny), entry.hideWhenLocked), + limit = limit, + stock = stock, + discountable = entry.discountable, + matchMode = enumOrDefault(entry.matchMode, MatchMode.EXACT), + stats = ShopStats(entry.bought, entry.sold, entry.moneyIn, entry.moneyOut), + ) + } + + private fun toStored(shop: ShopDefinition): StoredShop = StoredShop( + id = shop.id, + displayName = shop.displayName, + permission = shop.gate.permission, + towny = shop.gate.towny.name, + hideWhenLocked = shop.gate.hideWhenLocked, + npcIds = shop.npcIds.toList(), + entries = shop.entries.map { entry -> + StoredEntry( + id = entry.id, + item = ItemCodec.encode(entry.item), + bundle = entry.bundleSize, + buy = toStoredCost(entry.buy), + sell = toStoredCost(entry.sell), + permission = entry.gate.permission, + towny = entry.gate.towny.name, + hideWhenLocked = entry.gate.hideWhenLocked, + limitAmount = entry.limit?.amount ?: 0, + limitPeriod = (entry.limit?.period ?: LimitPeriod.NONE).name, + stockMax = entry.stock?.max ?: 0, + stockRestockSeconds = entry.stock?.restockSeconds ?: 0L, + stockRemaining = entry.stock?.remaining ?: 0, + stockLastRestock = entry.stock?.lastRestock ?: 0L, + discountable = entry.discountable, + matchMode = entry.matchMode.name, + bought = entry.stats.bought, + sold = entry.stats.sold, + moneyIn = entry.stats.moneyIn, + moneyOut = entry.stats.moneyOut, + ) + }, + ) + + private fun toCost(stored: StoredCost?): ShopCost? = + stored?.let { ShopCost(it.money, ItemCodec.decodeAll(it.items)) } + + private fun toStoredCost(cost: ShopCost?): StoredCost? = + cost?.let { StoredCost(it.money, ItemCodec.encodeAll(it.items)) } + + private fun requirement(name: String): TownyRequirement = enumOrDefault(name, TownyRequirement.NONE) + + /** A stored name that no longer exists falls back rather than failing the whole load. */ + private inline fun > enumOrDefault(name: String, default: T): T = + enumValues().firstOrNull { it.name.equals(name, ignoreCase = true) } ?: default +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopPricing.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopPricing.kt new file mode 100644 index 0000000..6469a71 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopPricing.kt @@ -0,0 +1,41 @@ +package net.trilleo.mc.plugins.tritown.shops + +import net.trilleo.mc.plugins.tritown.enums.TownyRequirement +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * Works out what a buyer actually pays. + * + * Kept free of Bukkit and Towny so the arithmetic can be tested on its own; the + * caller reads the player's standing out of Towny and passes it in. + */ +object ShopPricing { + + /** + * The single best discount [standing] earns, as a fraction between 0 and 1. + * + * Discounts do not stack: a mayor whose nation also gets one pays the better + * of the two, not both. Stacking them would make a small change to one rate + * move prices no owner intended to touch. + */ + fun discount(rates: Map, standing: Set): Double = + standing.mapNotNull { rates[it] }.maxOrNull()?.coerceIn(0.0, 1.0) ?: 0.0 + + /** + * [money] with [discount] taken off, rounded to [scale] digits. + * + * Rounded down, so a discount is never worth less than it says. + */ + fun apply(money: Double, discount: Double, scale: Int): Double { + if (money <= 0.0 || discount <= 0.0) return money + return BigDecimal.valueOf(money) + .multiply(BigDecimal.ONE.subtract(BigDecimal.valueOf(discount))) + .setScale(scale, RoundingMode.DOWN) + .toDouble() + } + + /** [money] rounded to [scale] digits, for a price the owner typed or a total built from one. */ + fun round(money: Double, scale: Int): Double = + BigDecimal.valueOf(money).setScale(scale, RoundingMode.HALF_UP).toDouble() +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopQuote.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopQuote.kt new file mode 100644 index 0000000..87bff4d --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopQuote.kt @@ -0,0 +1,33 @@ +package net.trilleo.mc.plugins.tritown.shops + +import org.bukkit.inventory.ItemStack + +/** + * What a given number of bundles would actually cost, or pay out, for one + * player at this moment. + * + * Prices are worked out once and then both shown and charged from the same + * quote, so a discount can never appear in the menu without being applied at + * the till. + * + * @param money the total actually charged or paid, after any discount + * @param fullMoney the total before the discount, for showing what was saved + * @param items the stacks required or paid out in total, already multiplied by [bundles] + */ +data class ShopQuote( + val bundles: Int, + val money: Double, + val fullMoney: Double, + val items: List, + val discount: Double, +) { + + /** Whether a discount was applied. */ + val isDiscounted: Boolean get() = discount > 0.0 && money < fullMoney + + /** Whether any currency changes hands. */ + val hasMoney: Boolean get() = money > 0.0 + + /** Whether this quote asks for nothing at all. */ + val isFree: Boolean get() = money <= 0.0 && items.isEmpty() +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopSorting.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopSorting.kt new file mode 100644 index 0000000..9f66edc --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopSorting.kt @@ -0,0 +1,52 @@ +package net.trilleo.mc.plugins.tritown.shops + +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import net.trilleo.mc.plugins.tritown.enums.ShopSortMode +import org.bukkit.inventory.ItemStack + +/** + * Puts a shop's entries in order. + * + * Sorting is done here rather than in the menu that asks for it, because the + * order of a shop is the shop's own business and a sorted list has to be + * written back into the same [ShopDefinition.entries] list the rest of the + * plugin holds on to. + */ +object ShopSorting { + + private val plain = PlainTextComponentSerializer.plainText() + + /** Rearranges [shop]'s entries into [mode]'s order, in place. */ + fun sort(shop: ShopDefinition, mode: ShopSortMode) { + val sorted = shop.entries.sortedWith(comparator(mode)) + shop.entries.clear() + shop.entries.addAll(sorted) + } + + /** + * An entry with no price sorts last whichever way the prices run, because + * a display piece has no price to rank it by and reversing the order is + * not a reason to put it at the top. + */ + private fun comparator(mode: ShopSortMode): Comparator = when (mode) { + ShopSortMode.NAME -> compareBy { name(it.item) } + ShopSortMode.NAME_REVERSED -> compareByDescending { name(it.item) } + ShopSortMode.PRICE -> compareBy { it.buy == null }.thenBy { it.buy?.money ?: 0.0 } + ShopSortMode.PRICE_REVERSED -> + compareBy { it.buy == null }.thenByDescending { it.buy?.money ?: 0.0 } + } + + /** + * What an item is called for the purpose of ordering it. + * + * A renamed item sorts under the name it was given; anything else sorts + * under its material, which is the server's own language rather than each + * viewer's — one shop has one order, and it cannot depend on who is + * looking at it. + */ + private fun name(item: ItemStack): String { + val meta = item.itemMeta + val custom = if (meta != null && meta.hasDisplayName()) meta.displayName()?.let(plain::serialize) else null + return (custom ?: item.type.name.replace('_', ' ')).lowercase() + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStats.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStats.kt new file mode 100644 index 0000000..7a297ff --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStats.kt @@ -0,0 +1,39 @@ +package net.trilleo.mc.plugins.tritown.shops + +/** + * What one entry has traded since the counters were last reset. + * + * Counts bundles rather than items, matching what a player clicks, and keeps + * the two currency directions apart so an owner can see at a glance whether a + * shop is draining the economy or feeding it. + */ +data class ShopStats( + var bought: Long = 0L, + var sold: Long = 0L, + var moneyIn: Double = 0.0, + var moneyOut: Double = 0.0, +) { + + /** Records a player buying [bundles] for [money]. */ + fun recordBuy(bundles: Int, money: Double) { + bought += bundles + moneyIn += money + } + + /** Records a player selling [bundles] for [money]. */ + fun recordSell(bundles: Int, money: Double) { + sold += bundles + moneyOut += money + } + + /** Whether anything has been traded at all. */ + val isEmpty: Boolean get() = bought == 0L && sold == 0L + + /** Clears every counter. */ + fun reset() { + bought = 0L + sold = 0L + moneyIn = 0.0 + moneyOut = 0.0 + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStock.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStock.kt new file mode 100644 index 0000000..1109d3c --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStock.kt @@ -0,0 +1,57 @@ +package net.trilleo.mc.plugins.tritown.shops + +/** + * A finite supply of one entry, shared by everybody, that refills on a timer. + * + * Restocking is lazy: nothing counts down in the background, and the supply is + * brought up to date the moment somebody looks at it. A shop nobody visits for + * a week therefore costs nothing and is still correct when they do. + * + * A restock fills back to [max] rather than adding one unit per period, so a + * long absence cannot bank an unbounded supply. + * + * @param max the supply a restock fills back to + * @param restockSeconds how long a restock takes; 0 never restocks, making the supply one-off + */ +data class ShopStock( + val max: Int, + val restockSeconds: Long, + var remaining: Int = max, + var lastRestock: Long = 0L, +) { + + /** How many bundles can be bought right now, restocking first if one is due. */ + fun available(now: Long): Int { + restock(now) + return remaining + } + + /** Brings the supply up to date, filling it when at least one restock period has passed. */ + fun restock(now: Long) { + if (restockSeconds <= 0L) return + if (lastRestock == 0L) { + lastRestock = now + return + } + + val period = restockSeconds * 1000L + val elapsed = now - lastRestock + if (elapsed < period) return + + remaining = max + // Advanced by whole periods so the refill time does not drift later on every restock. + lastRestock += elapsed / period * period + } + + /** Takes [count] bundles, or returns `false` and takes nothing when the supply is short. */ + fun take(count: Int, now: Long): Boolean { + if (count <= 0 || available(now) < count) return false + remaining -= count + return true + } + + /** Puts [count] bundles back, for a purchase that was rolled back or an item sold to the shop. */ + fun restore(count: Int) { + remaining = (remaining + count).coerceAtMost(max) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopTrade.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopTrade.kt new file mode 100644 index 0000000..53f1396 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopTrade.kt @@ -0,0 +1,234 @@ +package net.trilleo.mc.plugins.tritown.shops + +import net.trilleo.mc.plugins.tritown.config.ShopSettings +import net.trilleo.mc.plugins.tritown.economy.CurrencyRegistry +import net.trilleo.mc.plugins.tritown.economy.EconomyContext +import net.trilleo.mc.plugins.tritown.economy.TransactionReason +import net.trilleo.mc.plugins.tritown.enums.MatchMode +import net.trilleo.mc.plugins.tritown.enums.TownyRequirement +import net.trilleo.mc.plugins.tritown.utils.EconomyUtil +import org.bukkit.entity.Player +import org.bukkit.inventory.ItemStack +import kotlin.math.floor + +/** + * Buying and selling, and the only place either happens. + * + * Every trade runs on the server thread, because it touches an inventory. The + * ordering is what makes it safe: everything that can refuse is asked before + * anything is taken, and anything that is taken is remembered so it can be put + * back if a later step still fails. A player can end a failed trade poorer in + * neither money nor items. + * + * The goods a shop sells are created and the money paid for them leaves the + * economy, which is what an admin shop is for — there is no shop account behind + * it holding either. + */ +object ShopTrade { + + /** The most bundles one click may move, so a shift-click cannot try to buy a chest-load at once. */ + const val MAX_BUNDLES = 64 + + /** The outcome of a trade. */ + sealed interface Result { + + /** The trade went through. */ + data class Success(val bundles: Int, val money: Double) : Result + + /** The trade was refused, with a `shop.error.*` key and any arguments the message needs. */ + data class Failure(val key: String, val args: List> = emptyList()) : Result + } + + // ── Quoting ───────────────────────────────────────────────────────── + + /** What [bundles] of [entry] would cost [player], with any discount applied. */ + fun quoteBuy( + player: Player, + entry: ShopEntry, + bundles: Int, + standing: Set = ShopAccess.standing(player), + ): ShopQuote? { + val cost = entry.buy ?: return null + val discount = if (entry.discountable) discountFor(standing) else 0.0 + val full = ShopPricing.round(cost.money * bundles, scale()) + + return ShopQuote( + bundles = bundles, + money = ShopPricing.apply(full, discount, scale()), + fullMoney = full, + items = multiply(cost.items, bundles), + discount = discount, + ) + } + + /** What the shop would pay [player] for [bundles] of [entry]. Discounts never apply to a payout. */ + fun quoteSell(entry: ShopEntry, bundles: Int): ShopQuote? { + val payout = entry.sell ?: return null + val total = ShopPricing.round(payout.money * bundles, scale()) + return ShopQuote(bundles, total, total, multiply(payout.items, bundles), 0.0) + } + + /** + * The most bundles of [entry] [player] could buy right now. + * + * Bounded by their money, the items the price asks for, the room they have, + * the supply left, their own limit and [MAX_BUNDLES] — whichever runs out + * first. Used by a shift-click, which buys as many as it can rather than + * refusing outright. + */ + fun maxBuyable( + player: Player, + shop: ShopDefinition, + entry: ShopEntry, + standing: Set = ShopAccess.standing(player), + ): Int { + val cost = entry.buy ?: return 0 + var max = MAX_BUNDLES + + entry.stock?.let { max = minOf(max, it.available(System.currentTimeMillis())) } + ShopLimits.remaining(player, shop, entry)?.let { max = minOf(max, it) } + + for (item in cost.items) { + val held = ShopInventory.count(player, item, entry.matchMode) + max = minOf(max, held / item.amount.coerceAtLeast(1)) + } + + if (cost.money > 0.0) { + if (!EconomyUtil.isAvailable) return 0 + val discount = if (entry.discountable) discountFor(standing) else 0.0 + val unit = ShopPricing.apply(ShopPricing.round(cost.money, scale()), discount, scale()) + max = if (unit <= 0.0) max else minOf(max, floor(EconomyUtil.balance(player) / unit).toInt()) + } + + // Room is the slowest check, so it narrows an already-bounded number rather than searching from the top. + while (max > 0 && !ShopInventory.hasSpaceFor(player, entry.goodsStacks(max))) max-- + + return max.coerceAtLeast(0) + } + + /** How many bundles of [entry] [player] is holding, for a shift-click that sells the lot. */ + fun maxSellable(player: Player, entry: ShopEntry): Int { + if (!entry.isSellable) return 0 + val held = ShopInventory.count(player, entry.item, entry.matchMode) + return minOf(MAX_BUNDLES, held / entry.bundleSize) + } + + // ── Buying ────────────────────────────────────────────────────────── + + /** Sells [bundles] of [entry] to [player]. */ + fun buy(player: Player, shop: ShopDefinition, entry: ShopEntry, bundles: Int): Result { + if (!ShopManager.isReady) return Result.Failure("shop.error.unavailable") + if (bundles !in 1..MAX_BUNDLES) return Result.Failure("shop.error.bad-amount") + + val cost = entry.buy ?: return Result.Failure("shop.error.not-for-sale") + + val standing = ShopAccess.standing(player) + ShopAccess.refusalKey(player, shop.gate, standing)?.let { return Result.Failure(it) } + ShopAccess.refusalKey(player, entry.gate, standing)?.let { return Result.Failure(it) } + + ShopLimits.remaining(player, shop, entry)?.let { left -> + if (left < bundles) return Result.Failure("shop.error.limit-reached", listOf("amount" to left)) + } + + val stock = entry.stock + val now = System.currentTimeMillis() + if (stock != null && stock.available(now) < bundles) { + return Result.Failure("shop.error.out-of-stock", listOf("amount" to stock.remaining)) + } + + val quote = quoteBuy(player, entry, bundles, standing) ?: return Result.Failure("shop.error.not-for-sale") + val goods = entry.goodsStacks(bundles) + if (!ShopInventory.hasSpaceFor(player, goods)) return Result.Failure("shop.error.no-space") + + if (quote.hasMoney && !EconomyUtil.isAvailable) return Result.Failure("shop.error.economy-unavailable") + + // Taken before any money moves, and handed back below if the money then refuses. + val taken = takeItems(player, quote.items, entry.matchMode) + ?: return Result.Failure("shop.error.missing-items") + + if (stock != null && !stock.take(bundles, now)) { + ShopInventory.give(player, taken) + return Result.Failure("shop.error.out-of-stock", listOf("amount" to stock.remaining)) + } + + if (quote.hasMoney) { + val reason = TransactionReason.of(TransactionReason.SHOP_BUY, "shop" to shop.displayName) + if (!EconomyUtil.withdraw(player, quote.money, EconomyContext.SOURCE_SHOP, reason)) { + stock?.restore(bundles) + ShopInventory.give(player, taken) + return Result.Failure("shop.error.cannot-afford", listOf("price" to format(quote.money))) + } + } + + ShopInventory.give(player, goods) + ShopLimits.record(player, shop, entry, bundles, now) + entry.stats.recordBuy(bundles, quote.money) + ShopManager.markDirty() + + return Result.Success(bundles, quote.money) + } + + // ── Selling ───────────────────────────────────────────────────────── + + /** Buys [bundles] of [entry] back from [player]. */ + fun sell(player: Player, shop: ShopDefinition, entry: ShopEntry, bundles: Int): Result { + if (!ShopManager.isReady) return Result.Failure("shop.error.unavailable") + if (bundles !in 1..MAX_BUNDLES) return Result.Failure("shop.error.bad-amount") + if (!entry.isSellable) return Result.Failure("shop.error.not-bought") + + val standing = ShopAccess.standing(player) + ShopAccess.refusalKey(player, shop.gate, standing)?.let { return Result.Failure(it) } + ShopAccess.refusalKey(player, entry.gate, standing)?.let { return Result.Failure(it) } + + val quote = quoteSell(entry, bundles) ?: return Result.Failure("shop.error.not-bought") + if (!ShopInventory.hasSpaceFor(player, quote.items)) return Result.Failure("shop.error.no-space") + if (quote.hasMoney && !EconomyUtil.isAvailable) return Result.Failure("shop.error.economy-unavailable") + + val handedOver = ShopInventory.remove(player, entry.item, entry.matchMode, entry.bundleSize * bundles) + ?: return Result.Failure("shop.error.missing-goods") + + if (quote.hasMoney) { + val reason = TransactionReason.of(TransactionReason.SHOP_SELL, "shop" to shop.displayName) + if (!EconomyUtil.deposit(player, quote.money, EconomyContext.SOURCE_SHOP, reason)) { + ShopInventory.give(player, handedOver) + return Result.Failure("shop.error.payout-refused") + } + } + + ShopInventory.give(player, quote.items) + entry.stats.recordSell(bundles, quote.money) + ShopManager.markDirty() + + return Result.Success(bundles, quote.money) + } + + // ── Helpers ───────────────────────────────────────────────────────── + + /** + * Takes every stack in [required], putting back anything already taken if + * one of them turns out to be short. + */ + private fun takeItems(player: Player, required: List, mode: MatchMode): List? { + val taken = mutableListOf() + for (item in required) { + val removed = ShopInventory.remove(player, item, mode, item.amount) + if (removed == null) { + ShopInventory.give(player, taken) + return null + } + taken += removed + } + return taken + } + + private fun multiply(items: List, bundles: Int): List = + items.map { it.clone().apply { amount = it.amount * bundles } } + + private fun discountFor(standing: Set): Double = + if (ShopSettings.isLoaded) ShopPricing.discount(ShopSettings.snapshot.discounts, standing) else 0.0 + + private fun scale(): Int = if (CurrencyRegistry.isLoaded) CurrencyRegistry.primary.fractionalDigits else 2 + + private fun format(money: Double): String = + if (EconomyUtil.isAvailable) EconomyUtil.format(money) else money.toString() +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/npc/FancyNpcsAdapter.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/npc/FancyNpcsAdapter.kt new file mode 100644 index 0000000..c47b912 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/npc/FancyNpcsAdapter.kt @@ -0,0 +1,24 @@ +package net.trilleo.mc.plugins.tritown.shops.npc + +import de.oliver.fancynpcs.api.FancyNpcsPlugin + +/** + * The only class in TriTown that names a FancyNpcs type outside the listener. + * + * Kept separate from [ShopNpcBridge] on purpose: the JVM resolves a class the + * first time a method actually reaches it, so as long as nothing here appears in + * the bridge's own signatures, this class is never loaded on a server without + * FancyNpcs and the missing plugin is simply invisible. + */ +internal object FancyNpcsAdapter { + + /** Every NPC's name, for tab completion. */ + fun names(): List = + FancyNpcsPlugin.get().npcManager.allNpcs.mapNotNull { it.data?.name }.sorted() + + /** The stable id of the NPC called [name], or `null` when there is none. */ + fun idOf(name: String): String? = FancyNpcsPlugin.get().npcManager.getNpc(name)?.data?.id + + /** What the NPC with [id] is currently called, or `null` when it no longer exists. */ + fun nameOf(id: String): String? = FancyNpcsPlugin.get().npcManager.getNpcById(id)?.data?.name +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/npc/ShopNpcBridge.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/npc/ShopNpcBridge.kt new file mode 100644 index 0000000..cb5e167 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/npc/ShopNpcBridge.kt @@ -0,0 +1,41 @@ +package net.trilleo.mc.plugins.tritown.shops.npc + +import org.bukkit.Bukkit + +/** + * Looks NPCs up for the shop command, without needing FancyNpcs to be installed. + * + * Nothing here exposes a FancyNpcs type, so a command or menu that calls it + * loads normally on a server that does not have the plugin — every method just + * reports that there are no NPCs. + */ +object ShopNpcBridge { + + /** The plugin shops bind their NPCs from. */ + const val PLUGIN_NAME = "FancyNpcs" + + /** Whether FancyNpcs is installed and running. */ + val isAvailable: Boolean + get() = Bukkit.getPluginManager().isPluginEnabled(PLUGIN_NAME) + + /** Every NPC name on the server, or an empty list when FancyNpcs is absent. */ + fun names(): List = guard { FancyNpcsAdapter.names() } ?: emptyList() + + /** The stable id of the NPC called [name], which is what a binding stores. */ + fun idOf(name: String): String? = guard { FancyNpcsAdapter.idOf(name) } + + /** What the NPC with [id] is called now, or `null` when it has been deleted. */ + fun nameOf(id: String): String? = guard { FancyNpcsAdapter.nameOf(id) } + + /** + * Runs [block] only when FancyNpcs is there. + * + * The throwable is caught as well as the plugin being checked, because a + * FancyNpcs whose API has moved on should leave shops working rather than + * spilling an error into an administrator's command. + */ + private fun guard(block: () -> T): T? { + if (!isAvailable) return null + return runCatching(block).getOrNull() + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/JsonShopStorage.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/JsonShopStorage.kt new file mode 100644 index 0000000..aef564e --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/JsonShopStorage.kt @@ -0,0 +1,109 @@ +package net.trilleo.mc.plugins.tritown.shops.storage + +import com.google.gson.GsonBuilder +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import java.io.File +import java.nio.file.* +import java.util.logging.Logger + +/** + * Keeps every shop in one JSON file under `/shops/`. + * + * Writes go to a temporary file which is then moved into place, with the + * previous copy kept as `.bak`, so a crash mid-write leaves either the old file + * or the new one and never a truncated one. A file that will not parse falls + * back to the backup rather than starting empty — an empty start would be + * written back over the real data on the next save. + * + * A single entry that will not parse is skipped with a warning. Losing one line + * of one shop is recoverable; refusing to open the server is not. + */ +class JsonShopStorage(directory: File, private val logger: Logger) : ShopStorage { + + private val root = File(directory, DIRECTORY) + private val shopsFile = File(root, SHOPS_FILE) + private val backupFile = File(root, "$SHOPS_FILE.bak") + private val gson = GsonBuilder().setPrettyPrinting().create() + private val ioLock = Any() + + override val schemaVersion: Int = ShopSchema.CURRENT + + override fun loadAll(): List = synchronized(ioLock) { + root.mkdirs() + + val document = read(shopsFile) ?: run { + if (shopsFile.exists()) { + logger.warning("${shopsFile.name} could not be read; falling back to the backup") + } + read(backupFile) ?: run { + if (backupFile.exists()) { + throw ShopStorageException("Neither ${shopsFile.name} nor its backup could be read") + } + return emptyList() + } + } + + val version = document.get(KEY_VERSION)?.asInt ?: ShopSchema.CURRENT + ShopSchema.checkReadable(version, shopsFile.name) + + val array = document.getAsJsonArray(KEY_SHOPS) ?: return emptyList() + array.mapNotNull { element -> + runCatching { gson.fromJson(element, StoredShop::class.java) } + .getOrNull() + ?.takeIf { it.id.isNotBlank() } + ?: run { + logger.warning("Skipped an unreadable shop in ${shopsFile.name}") + null + } + } + } + + override fun saveAll(shops: List) = synchronized(ioLock) { + root.mkdirs() + + val document = JsonObject().apply { + addProperty(KEY_VERSION, ShopSchema.CURRENT) + add(KEY_SHOPS, gson.toJsonTree(shops)) + } + + try { + writeAtomically(shopsFile.toPath(), gson.toJson(document)) + } catch (e: Exception) { + logger.severe("Failed to write ${shopsFile.name}: [${e.javaClass.simpleName}] ${e.message}") + } + } + + private fun read(file: File): JsonObject? { + if (!file.exists()) return null + return runCatching { JsonParser.parseString(file.readText()).asJsonObject }.getOrNull() + } + + private fun writeAtomically(target: Path, content: String) { + val temporary = target.resolveSibling("${target.fileName}.tmp") + Files.writeString( + temporary, + content, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE, + ) + + if (Files.exists(target)) { + Files.move(target, backupFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING) + } + } + + private companion object { + const val DIRECTORY = "shops" + const val SHOPS_FILE = "shops.json" + const val KEY_VERSION = "schemaVersion" + const val KEY_SHOPS = "shops" + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopSchema.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopSchema.kt new file mode 100644 index 0000000..7614e95 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopSchema.kt @@ -0,0 +1,34 @@ +package net.trilleo.mc.plugins.tritown.shops.storage + +/** + * Versioning for the shop file's layout. + * + * The file records the version it was written with, so a later change to the + * shape is detected rather than silently misread. A newer file is refused: an + * older build would drop what it did not understand, and the next save would + * write that loss back over the real data. + */ +object ShopSchema { + + /** The version this build writes. */ + const val CURRENT: Int = 1 + + /** The oldest version this build can still read. */ + const val OLDEST_SUPPORTED: Int = 1 + + /** @throws ShopStorageException when data written at [version] cannot be read by this build */ + fun checkReadable(version: Int, source: String) { + if (version > CURRENT) { + throw ShopStorageException( + "$source was written by a newer version of TriTown (schema $version, this build reads up to " + + "$CURRENT). Update TriTown rather than letting an older build overwrite it." + ) + } + if (version < OLDEST_SUPPORTED) { + throw ShopStorageException( + "$source uses schema $version, which this build can no longer read (oldest supported is " + + "$OLDEST_SUPPORTED)." + ) + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopStorage.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopStorage.kt new file mode 100644 index 0000000..33c3c9e --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/ShopStorage.kt @@ -0,0 +1,27 @@ +package net.trilleo.mc.plugins.tritown.shops.storage + +/** + * Where shop definitions are kept between restarts. + * + * Only JSON ships today, but the shops themselves never name a file, so a + * database-backed store can be dropped in without touching the feature. + */ +interface ShopStorage { + + /** The on-disk layout version this store writes. */ + val schemaVersion: Int + + /** + * Every stored shop. + * + * @throws ShopStorageException when the data cannot be read at all, which + * stops the feature rather than letting an empty list overwrite it + */ + fun loadAll(): List + + /** Replaces everything on disk with [shops]. */ + fun saveAll(shops: List) +} + +/** Raised when stored shops cannot be read, and must not be silently replaced. */ +class ShopStorageException(message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/StoredShop.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/StoredShop.kt new file mode 100644 index 0000000..c5badb1 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/StoredShop.kt @@ -0,0 +1,52 @@ +package net.trilleo.mc.plugins.tritown.shops.storage + +/** + * A shop as it sits on disk. + * + * Deliberately separate from the live model: items are Base64 here rather than + * `ItemStack`s, which keeps the whole storage layer free of Bukkit and therefore + * testable without a server. [net.trilleo.mc.plugins.tritown.shops.ShopManager] + * converts between the two. + * + * Gson builds these by reflection, so every field needs a default — an older + * file that predates a field must still load. + */ +data class StoredShop( + val id: String = "", + val displayName: String = "", + val permission: String? = null, + val towny: String = "NONE", + val hideWhenLocked: Boolean = false, + val npcIds: List = emptyList(), + val entries: List = emptyList(), +) + +/** One entry of a [StoredShop]. */ +data class StoredEntry( + val id: String = "", + val item: String = "", + val bundle: Int = 0, + val buy: StoredCost? = null, + val sell: StoredCost? = null, + val permission: String? = null, + val towny: String = "NONE", + val hideWhenLocked: Boolean = false, + val limitAmount: Int = 0, + val limitPeriod: String = "NONE", + val stockMax: Int = 0, + val stockRestockSeconds: Long = 0L, + val stockRemaining: Int = 0, + val stockLastRestock: Long = 0L, + val discountable: Boolean = true, + val matchMode: String = "EXACT", + val bought: Long = 0L, + val sold: Long = 0L, + val moneyIn: Double = 0.0, + val moneyOut: Double = 0.0, +) + +/** A price or a payout of a [StoredEntry]. */ +data class StoredCost( + val money: Double = 0.0, + val items: List = emptyList(), +) diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/economy/EconomyFlushTask.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/economy/EconomyFlushTask.kt index b4faa2b..5b8f4a9 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/economy/EconomyFlushTask.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/economy/EconomyFlushTask.kt @@ -3,19 +3,21 @@ package net.trilleo.mc.plugins.tritown.tasks.economy import net.trilleo.mc.plugins.tritown.config.EconomySettings import net.trilleo.mc.plugins.tritown.economy.BaltopCache import net.trilleo.mc.plugins.tritown.economy.CurrencyRegistry +import net.trilleo.mc.plugins.tritown.economy.EconomyPulse import net.trilleo.mc.plugins.tritown.economy.EconomyService import net.trilleo.mc.plugins.tritown.registration.PluginTask /** - * Writes changed accounts to disk on an interval and rebuilds the balance - * leaderboard, both off the main thread. + * Writes changed accounts to disk on an interval, measures the economy and + * rebuilds the balance leaderboard, all off the main thread. * * The interval bounds how much a hard crash can cost, since `onDisable` does * not run when a server is killed. Balances are still written immediately for * anything that should not wait, such as an administrator changing one. * - * The leaderboard is rebuilt here because this task is already walking every - * account, which keeps sorting off the main thread entirely. + * The measurement and the leaderboard are done here because this task is + * already walking every account, which keeps both the sorting and the + * statistics off the main thread entirely. */ class EconomyFlushTask : PluginTask( delay = flushIntervalTicks(), @@ -25,6 +27,10 @@ class EconomyFlushTask : PluginTask( override fun run() { if (!EconomyService.isReady) return + // Measured before the flush, so the sample this round takes is written + // in the same pass rather than waiting for the next one. + if (CurrencyRegistry.isLoaded) EconomyPulse.sample(EconomyService.ledger, CurrencyRegistry.primary) + EconomyService.flush() if (CurrencyRegistry.isLoaded) { diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/shop/ShopSaveTask.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/shop/ShopSaveTask.kt new file mode 100644 index 0000000..f074f63 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/shop/ShopSaveTask.kt @@ -0,0 +1,34 @@ +package net.trilleo.mc.plugins.tritown.tasks.shop + +import net.trilleo.mc.plugins.tritown.config.ShopSettings +import net.trilleo.mc.plugins.tritown.registration.PluginTask +import net.trilleo.mc.plugins.tritown.shops.ShopManager + +/** + * Writes shop stock and statistics to disk on an interval, off the main thread. + * + * Definitions do not wait for this — an edit is written the moment it is made — + * so the interval only bounds how many purchase counters a hard crash can cost. + * Rewriting the file on every purchase would be the alternative, and a busy + * shop would then write it hundreds of times a minute for no gain. + */ +class ShopSaveTask : PluginTask( + delay = saveIntervalTicks(), + period = saveIntervalTicks(), + async = true, +) { + override fun run() { + ShopManager.flush() + } +} + +/** + * The configured save interval in ticks. + * + * Read at construction, which is safe because the shop settings are loaded in + * `onEnable` before the task registrar runs. + */ +private fun saveIntervalTicks(): Long { + val seconds = if (ShopSettings.isLoaded) ShopSettings.snapshot.saveIntervalSeconds else 60L + return seconds * 20L +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/ChatPrompt.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/ChatPrompt.kt new file mode 100644 index 0000000..e792848 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/ChatPrompt.kt @@ -0,0 +1,77 @@ +package net.trilleo.mc.plugins.tritown.utils + +import net.trilleo.mc.plugins.tritown.Main +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +/** + * Asks a player to type something in chat and hands the answer back. + * + * A chest menu has nowhere to type, so anything free-form an editor needs — a + * name, an exact price, a permission node — is asked for in chat instead. The + * menu closes, the question is sent, and the answer reopens whatever comes next. + * + * ### Usage + * + * ```kotlin + * player.closeInventory() + * ChatPrompt.ask(player, player.tr("gui.shop-editor.prompt-price")) { input -> + * val price = input.toDoubleOrNull() ?: return@ask + * entry.buy = ShopCost(price) + * } + * ``` + * + * The answer arrives on the server thread, so a callback may touch Bukkit + * freely. Typing the cancel word, quitting, or being asked something else + * instead drops the pending question without running the callback. + */ +object ChatPrompt { + + private val pending = ConcurrentHashMap Unit>() + + /** + * Asks [player] the question [message], then runs [onInput] with what they type. + * + * Any question already waiting for them is dropped, so two menus cannot + * both be listening at once. + */ + fun ask(player: Player, message: String, onInput: (String) -> Unit) { + pending[player.uniqueId] = onInput + player.sendPrefixed(message) + player.sendPrefixed(player.tr("common.prompt-cancel")) + } + + /** Whether [player] is being asked something. */ + fun isWaiting(player: Player): Boolean = pending.containsKey(player.uniqueId) + + /** Drops any question waiting for [player] without running its callback. */ + fun cancel(player: Player) { + pending.remove(player.uniqueId) + } + + /** + * Feeds [message] to the question [player] was asked. + * + * Called from the chat listener, which runs off the main thread, so the + * callback is handed to the scheduler rather than run where it arrives. + * + * @return `true` when the message was an answer and should not reach chat + */ + fun consume(player: Player, message: String): Boolean { + val callback = pending.remove(player.uniqueId) ?: return false + + val answer = message.trim() + if (answer.equals(CANCEL_WORD, ignoreCase = true)) { + player.sendPrefixed(player.tr("common.prompt-cancelled")) + return true + } + + Bukkit.getScheduler().runTask(Main.instance, Runnable { callback(answer) }) + return true + } + + /** The word a player types to back out of a question. */ + const val CANCEL_WORD = "cancel" +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/EconomyUtil.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/EconomyUtil.kt index f18c723..66e6802 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/EconomyUtil.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/EconomyUtil.kt @@ -3,6 +3,7 @@ package net.trilleo.mc.plugins.tritown.utils import net.kyori.adventure.text.Component import net.milkbowl.vault.economy.Economy import net.trilleo.mc.plugins.tritown.economy.CurrencyRegistry +import net.trilleo.mc.plugins.tritown.economy.EconomyContext import net.trilleo.mc.plugins.tritown.economy.EconomyFormat import net.trilleo.mc.plugins.tritown.economy.EconomyService import net.trilleo.mc.plugins.tritown.economy.vault.TriTownVaultEconomy @@ -65,6 +66,20 @@ object EconomyUtil { return economy.depositPlayer(player, amount).transactionSuccess() } + /** + * Takes [amount] from [player], recorded as having come from [source] for [reason]. + * + * The attribution reaches the ledger through the thread the call is made on, + * so it lands on the record without this having to know which provider won. + * Another plugin's economy keeps no such record and simply ignores it. + */ + fun withdraw(player: OfflinePlayer, amount: Double, source: String, reason: String): Boolean = + EconomyContext.with(source, reason) { withdraw(player, amount) } + + /** Gives [amount] to [player], recorded as having come from [source] for [reason]. */ + fun deposit(player: OfflinePlayer, amount: Double, source: String, reason: String): Boolean = + EconomyContext.with(source, reason) { deposit(player, amount) } + /** * Moves [amount] from [from] to [to]. * diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 7c5c538..4f54eba 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -96,6 +96,49 @@ economy: roll-size-mb: 16 time-format: "yyyy-MM-dd HH:mm" + stats: + # Keep the figures the admin panel reads: how much currency is created and + # destroyed each hour, what for, and how the supply is spread across players. + # They live in /economy/statistics.json and are written on the + # same interval as balances. Turning this off empties /tritown admin. + # Both settings here are read at startup, so changing them needs a restart. + enabled: true + # How many days of hourly figures to keep. 0 keeps them forever; a year of + # them is well under a megabyte, so the cost is time to read at startup + # rather than space. + retention-days: 30 + +# ── Shops ─────────────────────────────────────────────────────────────────── +# Shops the server itself runs. Everything about a shop — what it sells, what it +# costs, who may use it — is set up in game with /tritown shop, not here; these +# are the rules that apply to all of them. +# +# The goods a shop sells are created and the money paid for them leaves the +# economy, so a shop is a sink, a faucet, or both, depending on how it is priced. +shops: + # Turn shops off entirely. Existing shops are left on disk untouched. + enabled: true + # Seconds between writing stock levels and sales figures to disk. A crash + # loses at most this long of them. Changes to a shop itself are always written + # immediately and are never at risk. + save-interval: 60 + # A purchase costing more than this asks the player to confirm first. + # Set to 0 to never ask. + confirm-above: 1000.0 + # What the editor suggests an entry should pay back, as a fraction of its buy + # price, when sell-back is switched on for it. Only a starting point — the + # price can be changed to anything afterwards. + sell-rate: 0.5 + + # Money taken off a purchase for players who have earned it, as a fraction: + # 0.1 is ten percent off. Discounts do not stack, so a player who matches more + # than one gets the largest. + discounts: + has-town: 0.0 + has-nation: 0.0 + is-mayor: 0.0 + is-king: 0.0 + # ── Sidebar ───────────────────────────────────────────────────────────────── # The scoreboard shown at the right of the screen. Each board names a condition # and a priority, and a player sees the highest-priority board whose condition diff --git a/src/main/resources/lang/en_US.yml b/src/main/resources/lang/en_US.yml index ad8b2c2..49dce8d 100644 --- a/src/main/resources/lang/en_US.yml +++ b/src/main/resources/lang/en_US.yml @@ -16,11 +16,20 @@ command: economy: "Economy" info: "Info" moderation: "Moderation" + shop: "Shops" + admin: "Admin" help: header: "TriTown Commands" description: "Show all available commands" + admin: + description: "Open the admin panel" + players-only: "The admin panel is a menu, so only players can open it." + unknown-section: "There is no {section} section of the admin panel." + no-permission-section: "You don't have permission to open the {section} section!" + unavailable: "The admin panel is not available right now." + reload: description: "Reload the plugin configuration and translations" done: "Configuration reloaded!" @@ -102,6 +111,35 @@ command: unpinned: "Sidebar back to matching where you stand." unknown-board: "No board named {board}." + shop: + description: "Administer the server's shops" + disabled: "Shops are switched off on this server." + unavailable: "Shops could not be loaded. Check the server log." + no-permission-action: "You don't have permission to use /tritown shop {action}." + players-only: "Only a player can open a menu." + unknown: "There is no shop with the id {id}." + created: "Created the shop {id}." + create-failed: "{id} is taken or is not a usable id. Use lowercase letters, digits, dashes and underscores." + delete-confirm: "Deleting a shop cannot be undone. Run /tritown shop delete {id} confirm to go ahead." + deleted: "Deleted the shop {id}." + opened: "Opened {id} for {player}." + bound: "{npc} now opens the shop {id}." + unbound: "{npc} no longer opens the shop {id}." + npc-unknown: "There is no NPC named {npc}." + npc-not-bound: "{npc} does not open a shop." + npcs-unavailable: "{plugin} is not installed, so NPCs cannot be bound." + usage: + header: "Shop administration" + list: "/tritown shop list - every shop on the server" + create: "/tritown shop create [name] - start a new shop" + delete: "/tritown shop delete confirm - remove a shop for good" + edit: "/tritown shop edit - change what a shop offers" + open: "/tritown shop open [player] - open a shop for somebody" + bind: "/tritown shop bind - put an NPC behind the counter" + unbind: "/tritown shop unbind - take an NPC off its shop" + stats: "/tritown shop stats - what a shop has traded" + + common: unknown: "unknown" invalid-amount: "That is not a valid amount." @@ -116,6 +154,9 @@ common: # player who has none. none: "-" + prompt-cancel: "Type cancel to leave it as it is." + prompt-cancelled: "Left unchanged." + money: # Plain text only: these also travel to other plugins as Vault's error message, which they print verbatim. error: @@ -142,12 +183,25 @@ money: town-deleted: "{name} was deleted" admin-set: "Set by {admin}" admin-reset: "Reset by {admin}" + shop-buy: "Bought at {shop}" + shop-sell: "Sold at {shop}" + + # Where money comes from and where it goes, as the admin panel groups it. + flow: + starting-balance: "New players" + shop: "Shops" + towny: "Towny" + admin: "Administration" + payment: "Player payments" + external: "Other plugins" + other: "Uncategorised" # What asked for the transaction. A source TriTown does not know is shown as it was recorded. source: vault: "Another plugin" command: "TriTown command" towny: "Towny" + shop: "Shop" account-type: player: "player" @@ -170,6 +224,205 @@ gui: next-page: "Next Page" page: "Page {page}/{pages}" + # The admin panel. Amounts, counts and percentages are filled in by TriTown. + admin: + title: "Admin panel" + economy: "Economy" + economy-lore: "Supply, faucets, sinks and who holds what." + economy-supply: "In existence: {amount}" + economy-net: "Net over {window}: {amount}" + economy-accounts: "Accounts: {amount}" + economy-stats-off: "Statistics are switched off in config.yml." + shops: "Shops" + shops-lore: "What every shop has traded." + shops-count: "Shops: {amount}" + shops-taken: "Taken in: {amount}" + shops-paid: "Paid out: {amount}" + shops-off: "Shops are switched off." + server: "Server" + server-version: "TriTown {version}" + server-provider: "Economy: {provider}" + server-no-provider: "none" + server-towns: "Towns: {amount}" + server-nations: "Nations: {amount}" + server-online: "Online: {amount}" + server-newday: "New day in {time}" + click-open: "Click to open" + + admin-economy: + title: "Economy" + disabled: "Statistics are switched off" + disabled-lore: "Set economy.stats.enabled to true in config.yml and restart to collect them." + measuring: "The ledger has not been measured yet. Open this again in a moment." + measured: "Measured {time}" + + window: "Window: {window}" + window-lore: "Click for the next, right-click for the previous" + window-range: "{from} to {to}" + window-day: "the last day" + window-week: "the last week" + window-month: "the last month" + window-all: "all time" + + delta-up: "+{amount}" + delta-down: "-{amount}" + delta-flat: "no change" + + holder-players: "Players" + holder-towns: "Town banks" + holder-nations: "Nation banks" + holder-npcs: "NPCs" + holder-server: "Server account" + holder-unknown: "Unidentified" + + supply: "Money supply" + supply-total: "In existence: {amount}" + supply-wallets: "Player wallets: {amount} ({percent})" + supply-banks: "Town and nation banks: {amount} ({percent})" + supply-elsewhere: "Server and NPCs: {amount} ({percent})" + supply-change: "Change over {window}: {amount}" + supply-average: "Per wallet: {amount}" + + accounts: "Accounts" + accounts-total: "Total: {amount}" + accounts-wallets: "Player wallets: {amount}" + accounts-towns: "Town banks: {amount}" + accounts-nations: "Nation banks: {amount}" + accounts-other: "Server and NPCs: {amount}" + accounts-active: "Active this week: {amount} ({percent})" + + distribution: "Wealth distribution" + distribution-median: "Median wallet: {amount}" + distribution-mean: "Mean wallet: {amount}" + distribution-richest: "Richest wallet: {amount}" + distribution-top: "The richest tenth hold {percent}" + distribution-gini: "Inequality: {value} ({label})" + distribution-lore: "Measured across player wallets only." + gini-even: "even" + gini-fair: "fair" + gini-uneven: "uneven" + gini-extreme: "extreme" + + leaders: "Richest accounts" + leaders-line: "{rank}. {name} - {amount}" + leaders-empty: "Nobody holds anything yet." + leaders-updated: "Updated {time}" + + circulation: "Circulation" + circulation-volume: "Moved between accounts: {amount}" + circulation-count: "Payments: {amount}" + circulation-average: "Average payment: {amount}" + circulation-velocity: "Velocity: {percent} of the supply per day" + circulation-lore: "A payment moves money; it never creates or destroys any." + + shops: "Shops" + shops-count: "Shops: {amount}" + shops-created: "Paid out to players: {amount}" + shops-destroyed: "Taken from players: {amount}" + shops-net: "Effect on the supply: {amount}" + shops-lifetime: "All time: {taken} in, {paid} out" + shops-off: "Shops are switched off." + click-shops: "Click to see every shop" + + health: "Ledger" + health-provider: "Provider: {provider}" + health-currency: "Currency: {currency} ({symbol})" + health-storage: "Storage: {storage}, written every {seconds}s" + health-history: "History: {state}, kept {days} day(s)" + health-stats: "Figures kept since {time}" + click-flush: "Click to write everything to disk now" + + generation: "Money created" + generation-total: "Created: {amount}" + sinks: "Money removed" + sinks-total: "Removed: {amount}" + flow-rate: "Per day: {amount}" + flow-line: "- {name}: {amount} ({percent})" + flow-empty: "Nothing in this window." + + net: "Net change" + net-total: "Over the window: {amount}" + net-rate: "Per day: {amount}" + net-drift: "Supply drift: {percent} per day" + net-doubling: "At this rate the supply doubles in {days} day(s)." + net-emptying: "At this rate the supply empties in {days} day(s)." + net-stable: "The supply is holding steady." + net-lore: "Towny moves a bank deposit as a withdrawal and a deposit, so it lands on both sides above and cancels here." + + towny: "Towny" + towny-created: "Created: {amount}" + towny-destroyed: "Removed: {amount}" + towny-net: "Net: {amount}" + towny-in: "Into town and nation banks: {amount}" + towny-out: "Out of them: {amount}" + towny-lore: "Upkeep, taxes, plot sales and bank deposits." + + admin: "Administration" + admin-given: "Given out: {amount}" + admin-taken: "Taken back: {amount}" + admin-net: "Net: {amount}" + admin-set: "Moved by setting balances: {amount}" + admin-lore: "A balance that was set outright is counted apart, because the record does not say which way it went." + + newcomers: "New players" + newcomers-total: "Starting balances paid: {amount}" + newcomers-count: "That is about {amount} new player(s)." + newcomers-each: "Each new player receives {amount}." + + breakdown: "Full breakdown" + breakdown-lore: "Every source and every kind of account, in full." + click-open: "Click to open" + + chart: "{from} to {to}" + chart-created: "Created: {amount}" + chart-destroyed: "Removed: {amount}" + chart-net: "Net: {amount}" + chart-lore: "The stack size is the bar. Green grew the supply, red shrank it." + + back: "Back" + back-lore: "Return to the admin panel." + refresh: "Refresh" + refresh-lore: "Read every figure again." + + admin-flow: + title: "Money flow" + header: "Flow over {window}" + movements: "Movements: {amount}" + category: "{name}" + created: "Created: {amount} ({percent})" + destroyed: "Removed: {amount} ({percent})" + net: "Net: {amount}" + holders: "Who the money moved through" + holders-lore: "The same window, by the kind of account that gained or lost it." + holder: "{name}" + received: "Received: {amount}" + paid: "Paid: {amount}" + about-starting-balance: "Paid once to each new player." + about-shop: "The server's own shops selling and buying back." + about-towny: "Upkeep, taxes, plot sales and bank movements." + about-admin: "Balances given, taken, set or reset by an administrator." + about-payment: "Players paying each other." + about-external: "Another plugin, through Vault, with nothing to identify it." + about-other: "Recorded with a reason TriTown has no category for." + back: "Back" + back-lore: "Return to the economy panel." + + admin-shops: + title: "Shop figures" + header: "Every shop" + header-lore: "Counted since the figures were last reset." + count: "Shops: {amount}" + entries: "Entries: {amount}" + id: "Id: {id}" + empty: "No shops yet" + empty-lore: "Create one with /tritown shop create ." + disabled: "Shops are switched off" + disabled-lore: "Set shops.enabled to true in config.yml." + click-stats: "Click for this shop's own figures" + click-edit: "Shift-click to edit the shop" + back: "Back" + back-lore: "Return to the admin panel." + history: title: "Transaction History" header: "{name}" @@ -185,6 +438,207 @@ gui: source: "Source: {source}" time: "{time}" + shop: + title: "{name}" + unknown: "Shop" + empty: "Nothing for sale" + empty-lore: "This shop has no entries yet." + buy: "Buy: {price}" + buy-discounted: "Buy: {price} {full}" + sell: "Sell: {price}" + cost-item: "- {amount}x {item}" + stock: "In stock: {amount}/{max}" + limit: "Left for you: {amount} ({period})" + click-buy: "Left-click to buy one" + click-buy-max: "Shift-left-click to buy as many as you can" + click-sell: "Right-click to sell one" + click-sell-max: "Shift-right-click to sell all you carry" + locked-permission: "You cannot buy this." + locked-has-town: "Only residents of a town may buy this." + locked-no-town: "Only players without a town may buy this." + locked-has-nation: "Only residents of a nation may buy this." + locked-is-mayor: "Only a mayor may buy this." + locked-is-king: "Only a king may buy this." + requirement-none: "Anyone" + requirement-has-town: "In a town" + requirement-no-town: "Without a town" + requirement-has-nation: "In a nation" + requirement-is-mayor: "Mayor" + requirement-is-king: "King" + period-none: "ever" + period-daily: "per day" + period-weekly: "per week" + match-exact: "Exactly this item" + match-material: "Any item of this kind" + + shop-confirm: + title: "Confirm purchase" + amount: "You will receive {amount}" + price: "You will pay {price}" + accept: "Buy" + accept-lore: "Go ahead with the purchase." + cancel: "Cancel" + cancel-lore: "Back to the shop, nothing paid." + + shop-list: + title: "Shops" + empty: "No shops yet" + empty-lore: "Create one with /tritown shop create ." + id: "Id: {id}" + entries: "Entries: {amount}" + npcs: "NPCs: {amount}" + click-edit: "Click to edit" + click-preview: "Shift-click to see it as a player does" + + shop-editor: + title: "Shop editor" + bundle: "Sells {amount} at a time" + buy: "Sold for:" + sell: "Bought back for:" + not-buyable: "Not for sale" + not-sellable: "Not bought back" + click-entry: "Click to edit this entry" + right-click-entry: "Right-click to move it somewhere else" + shift-click-entry: "Shift-click to remove it" + being-moved: "Being moved" + click-put-down: "Click to leave it where it is" + click-place-before: "Click to drop the held entry in front of this one" + holding: "You are moving this entry" + move-first: "To the front" + move-first-lore: "Put the entry you are moving at the very start of the shop." + move-last: "To the back" + move-last-lore: "Put the entry you are moving at the very end of the shop." + add: "Add an entry" + add-lore: "Click an item in your own inventory, or drag it here. Your item stays where it is." + settings: "Shop settings" + settings-lore: "Name, access and NPCs for {id}." + stats: "Sales figures" + stats-lore: "What this shop has traded." + back: "All shops" + back-lore: "Back to the list of shops." + sort: "Sort the whole shop" + sort-lore: "Put every entry in order at once, instead of moving them one at a time." + + shop-sort: + title: "Sort the shop" + info: "Choose an order" + info-lore: "This replaces the order the entries are in now, and there is no way back to it. Move entries one at a time to keep an order of your own." + name: "By name, A to Z" + name-reversed: "By name, Z to A" + name-lore: "A renamed item is ordered under the name you gave it." + price: "By price, cheapest first" + price-reversed: "By price, dearest first" + price-lore: "By what players pay for it. Entries that are not for sale go last." + click-choose: "Click to choose this order" + chosen: "Chosen" + confirm: "Sort the shop" + confirm-lore: "Put every entry in the order you chose." + cancel: "Cancel" + cancel-lore: "Back to the editor, nothing reordered." + + shop-entry: + title: "Entry" + bundle: "One purchase gives {amount}" + bundle-size: "Amount per purchase" + bundle-lore: "May be more than one stack; it is handed over as several." + state: "Now: {state}" + amount: "Now: {amount}" + buyable: "Players may buy this" + sellable: "The shop buys this back" + buy-price: "Price in money" + sell-price: "Payout in money" + buy-items: "Price in items" + sell-items: "Payout in items" + item-count: "Items: {amount}" + limit: "Limit per player" + limit-value: "{amount} {period}" + stock: "Stock" + stock-unlimited: "Unlimited" + stock-value: "{amount}/{max}, refills every {seconds}s" + permission: "Permission needed" + towny: "Standing needed" + hidden: "Hide when locked" + discountable: "Discounts apply" + match: "What counts as this item" + match-lore: "Used when buying the item back from a player." + click-toggle: "Click to switch it over" + click-cycle: "Click for the next, right-click for the previous" + click-set: "Click to type a new value" + click-open: "Click to open" + middle-click-period: "Middle-click to change how often it resets" + right-click-clear: "Right-click to clear it" + prompt-price: "Type the new price in chat." + prompt-permission: "Type the permission node in chat." + prompt-bundle: "Type how many items one purchase should give." + prompt-limit: "Type how many one player may buy. 0 removes the limit." + prompt-stock: "Type the stock and the refill time in seconds, like 64 3600. 0 removes the stock." + back: "Back" + back-lore: "Return to the shop editor." + + shop-cost: + title: "Items" + click-remove: "Click to remove this" + add: "Add an item" + add-lore: "Click an item in your own inventory, or drag it here. The stack size becomes the quantity." + back: "Back" + back-lore: "Return to the entry." + + shop-settings: + title: "Shop settings" + id: "Id: {id}" + value: "Now: {value}" + permission: "Permission needed" + towny: "Standing needed" + npcs: "Shopkeepers" + npc-count: "Bound NPCs: {amount}" + npc-lore: "Bind one with /tritown shop bind {id} ." + stats: "Sales figures" + stats-lore: "What this shop has traded." + click-rename: "Click to rename the shop" + click-set: "Click to type a new value" + click-cycle: "Click for the next, right-click for the previous" + right-click-clear: "Right-click to clear it" + prompt-name: "Type the shop's new name in chat. MiniMessage is allowed." + prompt-permission: "Type the permission node in chat." + back: "Back" + back-lore: "Return to the shop editor." + + shop-stats: + title: "Sales figures" + bought: "Bought by players: {amount}" + sold: "Sold to the shop: {amount}" + money-in: "Taken in: {amount}" + money-out: "Paid out: {amount}" + net: "Net: {amount}" + reset: "Shift-click to set every figure back to zero" + +# Messages a shop sends to a player, outside the menus themselves. +shop: + traded: "Traded {amount}x {item} for {price}." + traded-items: "Traded {amount}x {item}." + stats-reset: "Cleared the sales figures for {shop}." + + error: + unavailable: "Shops are not available right now." + locked: "This shop is not open to you." + bad-amount: "That is not an amount this shop can trade." + not-for-sale: "That is not for sale." + not-bought: "This shop does not buy that." + cannot-afford: "You cannot afford that. It costs {price}." + missing-items: "You do not have everything this costs." + missing-goods: "You are not carrying enough of that." + no-space: "You have no room for that." + out-of-stock: "Only {amount} left in stock." + limit-reached: "You may only buy {amount} more of this." + economy-unavailable: "The economy is not available, so this cannot be paid for." + payout-refused: "The payment could not be made, so nothing was sold." + + editor: + entry-added: "Added {item} to the shop. Set a price next." + entry-removed: "Removed that entry." + sorted: "Put all {amount} entries in order." + + # The sidebar. Which lines appear, and in what order, is set per board in # config.yml; the wording and colours are set here. Values are written as # %marker% and are listed in config.yml. diff --git a/src/main/resources/lang/zh_CN.yml b/src/main/resources/lang/zh_CN.yml index 51163d6..019028c 100644 --- a/src/main/resources/lang/zh_CN.yml +++ b/src/main/resources/lang/zh_CN.yml @@ -16,11 +16,20 @@ command: economy: "经济" info: "信息" moderation: "管理" + shop: "商店" + admin: "管理" help: header: "TriTown 命令" description: "显示所有可用命令" + admin: + description: "打开管理面板" + players-only: "管理面板是菜单,只有玩家能打开。" + unknown-section: "管理面板没有 {section} 这个分区。" + no-permission-section: "你没有权限打开 {section} 分区!" + unavailable: "管理面板暂时不可用。" + reload: description: "重新载入插件配置与语言文件" done: "配置已重新载入!" @@ -102,6 +111,35 @@ command: unpinned: "侧边栏已恢复为根据所在位置自动切换。" unknown-board: "不存在名为 {board} 的板面。" + shop: + description: "管理服务器商店" + disabled: "本服务器已关闭商店功能。" + unavailable: "商店加载失败,请查看服务器日志。" + no-permission-action: "你没有权限使用 /tritown shop {action}。" + players-only: "只有玩家能打开菜单。" + unknown: "没有 id 为 {id} 的商店。" + created: "已创建商店 {id}。" + create-failed: "{id} 已被占用或不是有效的 id。请使用小写字母、数字、短横线和下划线。" + delete-confirm: "删除商店无法撤销。请执行 /tritown shop delete {id} confirm 继续。" + deleted: "已删除商店 {id}。" + opened: "已为 {player} 打开商店 {id}。" + bound: "{npc} 现在会打开商店 {id}。" + unbound: "{npc} 不再打开商店 {id}。" + npc-unknown: "没有名为 {npc} 的 NPC。" + npc-not-bound: "{npc} 没有绑定任何商店。" + npcs-unavailable: "未安装 {plugin},无法绑定 NPC。" + usage: + header: "商店管理" + list: "/tritown shop list - 服务器上的所有商店" + create: "/tritown shop create [name] - 新建一个商店" + delete: "/tritown shop delete confirm - 永久删除一个商店" + edit: "/tritown shop edit - 修改商店的商品" + open: "/tritown shop open [player] - 为某人打开商店" + bind: "/tritown shop bind - 让 NPC 看店" + unbind: "/tritown shop unbind - 解除 NPC 的商店绑定" + stats: "/tritown shop stats - 商店的交易统计" + + common: unknown: "未知" invalid-amount: "这不是有效的金额。" @@ -115,6 +153,9 @@ common: # 当侧边栏变量没有对应的值时显示,例如没有城镇的玩家的城镇名。 none: "-" + prompt-cancel: "输入 cancel 可保持不变。" + prompt-cancelled: "已取消,未做修改。" + money: # 仅限纯文本:这些信息会作为 Vault 的错误信息传给其他插件,并被原样输出。 error: @@ -141,12 +182,25 @@ money: town-deleted: "{name} 已被删除" admin-set: "由 {admin} 设置" admin-reset: "由 {admin} 重置" + shop-buy: "在 {shop} 购买" + shop-sell: "在 {shop} 出售" + + # 资金的来源与去向,管理面板按此归类统计。 + flow: + starting-balance: "新玩家" + shop: "商店" + towny: "Towny" + admin: "管理操作" + payment: "玩家转账" + external: "其他插件" + other: "未归类" # 交易的发起方。TriTown 不认识的来源会按记录原样显示。 source: vault: "其他插件" command: "TriTown 命令" towny: "Towny" + shop: "商店" account-type: player: "玩家" @@ -169,6 +223,205 @@ gui: next-page: "下一页" page: "第 {page}/{pages} 页" + # 管理面板。金额、数量和百分比由 TriTown 填入。 + admin: + title: "管理面板" + economy: "经济" + economy-lore: "货币总量、产出、回收与持有情况。" + economy-supply: "流通总量:{amount}" + economy-net: "{window}净变化:{amount}" + economy-accounts: "账户数:{amount}" + economy-stats-off: "统计已在 config.yml 中关闭。" + shops: "商店" + shops-lore: "所有商店的交易情况。" + shops-count: "商店数:{amount}" + shops-taken: "收取:{amount}" + shops-paid: "支付:{amount}" + shops-off: "商店已关闭。" + server: "服务器" + server-version: "TriTown {version}" + server-provider: "经济提供方:{provider}" + server-no-provider: "无" + server-towns: "城镇:{amount}" + server-nations: "国家:{amount}" + server-online: "在线:{amount}" + server-newday: "距新的一天:{time}" + click-open: "点击 打开" + + admin-economy: + title: "经济" + disabled: "统计已关闭" + disabled-lore: "将 config.yml 中的 economy.stats.enabled 设为 true 并重启后开始记录。" + measuring: "账本还未被统计,稍后再打开。" + measured: "统计于 {time}" + + window: "时间范围:{window}" + window-lore: "点击 切换到下一个,右键 回到上一个" + window-range: "{from} 至 {to}" + window-day: "最近一天" + window-week: "最近一周" + window-month: "最近一个月" + window-all: "全部时间" + + delta-up: "+{amount}" + delta-down: "-{amount}" + delta-flat: "无变化" + + holder-players: "玩家" + holder-towns: "城镇银行" + holder-nations: "国家银行" + holder-npcs: "NPC" + holder-server: "服务器账户" + holder-unknown: "未识别" + + supply: "货币总量" + supply-total: "流通总量:{amount}" + supply-wallets: "玩家钱包:{amount} ({percent})" + supply-banks: "城镇与国家银行:{amount} ({percent})" + supply-elsewhere: "服务器与 NPC:{amount} ({percent})" + supply-change: "{window}变化:{amount}" + supply-average: "人均持有:{amount}" + + accounts: "账户" + accounts-total: "总计:{amount}" + accounts-wallets: "玩家钱包:{amount}" + accounts-towns: "城镇银行:{amount}" + accounts-nations: "国家银行:{amount}" + accounts-other: "服务器与 NPC:{amount}" + accounts-active: "本周活跃:{amount} ({percent})" + + distribution: "财富分布" + distribution-median: "钱包中位数:{amount}" + distribution-mean: "钱包平均值:{amount}" + distribution-richest: "最富钱包:{amount}" + distribution-top: "最富的十分之一持有 {percent}" + distribution-gini: "贫富差距:{value} ({label})" + distribution-lore: "仅统计玩家钱包。" + gini-even: "均衡" + gini-fair: "尚可" + gini-uneven: "失衡" + gini-extreme: "极端" + + leaders: "最富有的账户" + leaders-line: "{rank}. {name} - {amount}" + leaders-empty: "还没有人拥有财富。" + leaders-updated: "更新于 {time}" + + circulation: "资金流转" + circulation-volume: "账户间转移:{amount}" + circulation-count: "转账次数:{amount}" + circulation-average: "平均每笔:{amount}" + circulation-velocity: "流通速度:每天相当于总量的 {percent}" + circulation-lore: "转账只是移动资金,既不产生也不销毁。" + + shops: "商店" + shops-count: "商店数:{amount}" + shops-created: "向玩家支付:{amount}" + shops-destroyed: "从玩家收取:{amount}" + shops-net: "对总量的影响:{amount}" + shops-lifetime: "全部时间:收 {taken},支 {paid}" + shops-off: "商店已关闭。" + click-shops: "点击 查看所有商店" + + health: "账本" + health-provider: "提供方:{provider}" + health-currency: "货币:{currency} ({symbol})" + health-storage: "存储:{storage},每 {seconds} 秒写入一次" + health-history: "历史记录:{state},保留 {days} 天" + health-stats: "统计起自 {time}" + click-flush: "点击 立即写入磁盘" + + generation: "新增资金" + generation-total: "新增:{amount}" + sinks: "回收资金" + sinks-total: "回收:{amount}" + flow-rate: "每天:{amount}" + flow-line: "- {name}:{amount} ({percent})" + flow-empty: "这段时间内没有变动。" + + net: "净变化" + net-total: "这段时间内:{amount}" + net-rate: "每天:{amount}" + net-drift: "总量漂移:每天 {percent}" + net-doubling: "照此速度,货币总量将在 {days} 天后翻倍。" + net-emptying: "照此速度,货币总量将在 {days} 天后耗尽。" + net-stable: "货币总量保持稳定。" + net-lore: "Towny 把银行存款拆成一次支出和一次入账,因此两边都会计入,在净值中相互抵消。" + + towny: "Towny" + towny-created: "新增:{amount}" + towny-destroyed: "回收:{amount}" + towny-net: "净值:{amount}" + towny-in: "进入城镇与国家银行:{amount}" + towny-out: "从银行流出:{amount}" + towny-lore: "维护费、税收、地块交易与银行存取。" + + admin: "管理操作" + admin-given: "发放:{amount}" + admin-taken: "收回:{amount}" + admin-net: "净值:{amount}" + admin-set: "直接设定余额带来的变动:{amount}" + admin-lore: "直接设定余额单独统计,因为记录不会说明余额是增是减。" + + newcomers: "新玩家" + newcomers-total: "发放的初始余额:{amount}" + newcomers-count: "约合 {amount} 名新玩家。" + newcomers-each: "每名新玩家获得 {amount}。" + + breakdown: "完整明细" + breakdown-lore: "每一类来源与每一类账户的完整数据。" + click-open: "点击 打开" + + chart: "{from} 至 {to}" + chart-created: "新增:{amount}" + chart-destroyed: "回收:{amount}" + chart-net: "净值:{amount}" + chart-lore: "堆叠数量就是柱高。绿色表示总量上升,红色表示下降。" + + back: "返回" + back-lore: "回到管理面板。" + refresh: "刷新" + refresh-lore: "重新读取所有数据。" + + admin-flow: + title: "资金流向" + header: "{window}的资金流向" + movements: "变动笔数:{amount}" + category: "{name}" + created: "新增:{amount} ({percent})" + destroyed: "回收:{amount} ({percent})" + net: "净值:{amount}" + holders: "资金经过谁的账户" + holders-lore: "同一段时间,按账户类型划分。" + holder: "{name}" + received: "入账:{amount}" + paid: "支出:{amount}" + about-starting-balance: "每名新玩家只发放一次。" + about-shop: "服务器自己的商店卖出与回收。" + about-towny: "维护费、税收、地块交易与银行存取。" + about-admin: "管理员发放、收回、设定或重置余额。" + about-payment: "玩家之间的转账。" + about-external: "其他插件通过 Vault 发起,无法进一步识别。" + about-other: "记录的原因不属于 TriTown 已知的任何一类。" + back: "返回" + back-lore: "回到经济面板。" + + admin-shops: + title: "商店数据" + header: "全部商店" + header-lore: "从上次清零开始统计。" + count: "商店数:{amount}" + entries: "商品数:{amount}" + id: "ID:{id}" + empty: "还没有商店" + empty-lore: "使用 /tritown shop create 创建一个。" + disabled: "商店已关闭" + disabled-lore: "将 config.yml 中的 shops.enabled 设为 true。" + click-stats: "点击 查看这家商店的数据" + click-edit: "Shift+点击 编辑这家商店" + back: "返回" + back-lore: "回到管理面板。" + history: title: "交易记录" header: "{name}" @@ -184,8 +437,209 @@ gui: source: "来源:{source}" time: "{time}" -# 侧边栏。显示哪些行、按什么顺序显示由 config.yml 中的各个板面决定;措辞与颜色在此设置。 -# 变量写作 %marker%,完整列表见 config.yml。 + # 侧边栏。显示哪些行、按什么顺序显示由 config.yml 中的各个板面决定;措辞与颜色在此设置。 + # 变量写作 %marker%,完整列表见 config.yml。 + shop: + title: "{name}" + unknown: "商店" + empty: "暂无商品" + empty-lore: "这家商店还没有任何商品。" + buy: "购买:{price}" + buy-discounted: "购买:{price} {full}" + sell: "出售:{price}" + cost-item: "- {amount}个 {item}" + stock: "库存:{amount}/{max}" + limit: "你还能买:{amount} ({period})" + click-buy: "左键 购买一份" + click-buy-max: "Shift + 左键 买下能买的全部" + click-sell: "右键 出售一份" + click-sell-max: "Shift + 右键 卖出身上全部" + locked-permission: "你无法购买此商品。" + locked-has-town: "只有城镇居民才能购买。" + locked-no-town: "只有没有城镇的玩家才能购买。" + locked-has-nation: "只有国家成员才能购买。" + locked-is-mayor: "只有镇长才能购买。" + locked-is-king: "只有国王才能购买。" + requirement-none: "所有人" + requirement-has-town: "有城镇" + requirement-no-town: "无城镇" + requirement-has-nation: "有国家" + requirement-is-mayor: "镇长" + requirement-is-king: "国王" + period-none: "总计" + period-daily: "每日" + period-weekly: "每周" + match-exact: "完全相同的物品" + match-material: "同类的任意物品" + + shop-confirm: + title: "确认购买" + amount: "你将获得 {amount}" + price: "你将支付 {price}" + accept: "购买" + accept-lore: "确认并完成这笔交易。" + cancel: "取消" + cancel-lore: "返回商店,不扣任何费用。" + + shop-list: + title: "商店列表" + empty: "还没有商店" + empty-lore: "使用 /tritown shop create 新建一个。" + id: "Id:{id}" + entries: "商品数:{amount}" + npcs: "NPC 数:{amount}" + click-edit: "单击 编辑" + click-preview: "Shift + 单击 以玩家视角查看" + + shop-editor: + title: "商店编辑器" + bundle: "每次出售 {amount} 个" + buy: "售价:" + sell: "回收价:" + not-buyable: "不出售" + not-sellable: "不回收" + click-entry: "单击 编辑这件商品" + right-click-entry: "右键 将其挑起来换个位置" + shift-click-entry: "Shift + 单击 将其移除" + being-moved: "正在移动" + click-put-down: "单击 放回原位" + click-place-before: "单击 将手上的商品放到它前面" + holding: "你正在移动这件商品" + move-first: "放到最前" + move-first-lore: "将正在移动的商品放到商店的最前面。" + move-last: "放到最后" + move-last-lore: "将正在移动的商品放到商店的最后面。" + add: "添加商品" + add-lore: "单击你背包里的物品,或将其拖到这里。你的物品不会被拿走。" + settings: "商店设置" + settings-lore: "{id} 的名称、准入和 NPC。" + stats: "经营数据" + stats-lore: "这家商店的交易情况。" + back: "商店列表" + back-lore: "返回商店列表。" + sort: "整店排序" + sort-lore: "一次性排好所有商品,不用逐件移动。" + + shop-sort: + title: "商店排序" + info: "选择排序方式" + info-lore: "这会覆盖商品现在的顺序,且无法恢复。想保留自己排的顺序,就逐件移动。" + name: "按名称,A 到 Z" + name-reversed: "按名称,Z 到 A" + name-lore: "重命名过的物品按你取的名字排序。" + price: "按售价,从低到高" + price-reversed: "按售价,从高到低" + price-lore: "按玩家购买时支付的价格。不出售的商品排在最后。" + click-choose: "单击 选择这种顺序" + chosen: "已选择" + confirm: "开始排序" + confirm-lore: "按你选的顺序排列所有商品。" + cancel: "取消" + cancel-lore: "返回编辑器,不改变顺序。" + + shop-entry: + title: "商品设置" + bundle: "每次购买获得 {amount}" + bundle-size: "每次购买数量" + bundle-lore: "可以超过一组,将分成多组交付。" + state: "当前:{state}" + amount: "当前:{amount}" + buyable: "允许玩家购买" + sellable: "商店回收此物" + buy-price: "售价(货币)" + sell-price: "回收价(货币)" + buy-items: "售价(物品)" + sell-items: "回收价(物品)" + item-count: "物品种类:{amount}" + limit: "每人限购" + limit-value: "{amount}({period})" + stock: "库存" + stock-unlimited: "无限" + stock-value: "{amount}/{max},每 {seconds} 秒补货" + permission: "所需权限" + towny: "所需身份" + hidden: "无权限时隐藏" + discountable: "参与折扣" + match: "哪些物品算数" + match-lore: "从玩家手中回收时使用。" + click-toggle: "单击 切换" + click-cycle: "单击 下一个,右键 上一个" + click-set: "单击 输入新的值" + click-open: "单击 打开" + middle-click-period: "中键 切换重置周期" + right-click-clear: "右键 清除" + prompt-price: "请在聊天栏输入新的价格。" + prompt-permission: "请在聊天栏输入权限节点。" + prompt-bundle: "请输入每次购买应获得的数量。" + prompt-limit: "请输入每个玩家最多能买多少。0 表示不限。" + prompt-stock: "请输入库存和补货秒数,例如 64 3600。0 表示不限库存。" + back: "返回" + back-lore: "回到商店编辑器。" + + shop-cost: + title: "物品" + click-remove: "单击 移除" + add: "添加物品" + add-lore: "单击你背包里的物品,或将其拖到这里。堆叠数量就是所需数量。" + back: "返回" + back-lore: "回到商品设置。" + + shop-settings: + title: "商店设置" + id: "Id:{id}" + value: "当前:{value}" + permission: "所需权限" + towny: "所需身份" + npcs: "店员" + npc-count: "已绑定 NPC:{amount}" + npc-lore: "使用 /tritown shop bind {id} 绑定。" + stats: "经营数据" + stats-lore: "这家商店的交易情况。" + click-rename: "单击 重命名商店" + click-set: "单击 输入新的值" + click-cycle: "单击 下一个,右键 上一个" + right-click-clear: "右键 清除" + prompt-name: "请在聊天栏输入商店的新名称,可使用 MiniMessage。" + prompt-permission: "请在聊天栏输入权限节点。" + back: "返回" + back-lore: "回到商店编辑器。" + + shop-stats: + title: "经营数据" + bought: "玩家购入:{amount}" + sold: "卖给商店:{amount}" + money-in: "收入:{amount}" + money-out: "支出:{amount}" + net: "净额:{amount}" + reset: "Shift + 单击 将所有数据清零" + +# 商店在菜单之外发给玩家的消息。 +shop: + traded: "以 {price} 成交 {amount}个 {item}。" + traded-items: "成交 {amount}个 {item}。" + stats-reset: "已清空 {shop} 的经营数据。" + + error: + unavailable: "商店暂时不可用。" + locked: "你无法进入这家商店。" + bad-amount: "这家商店无法交易这个数量。" + not-for-sale: "这件商品不出售。" + not-bought: "这家商店不回收此物。" + cannot-afford: "你的钱不够,需要 {price}。" + missing-items: "你缺少所需的物品。" + missing-goods: "你身上的数量不够。" + no-space: "你的背包放不下。" + out-of-stock: "库存只剩 {amount} 份。" + limit-reached: "你最多还能买 {amount} 份。" + economy-unavailable: "经济系统不可用,无法付款。" + payout-refused: "付款失败,交易未完成。" + + editor: + entry-added: "已将 {item} 加入商店,接下来设置价格。" + entry-removed: "已移除该商品。" + sorted: "已将全部 {amount} 件商品重新排序。" + + scoreboard: # 服务器名称。各帧之间只有渐变相位不同,循环播放时高光会从名称上扫过。 title: diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 2f17b0b..0b8492d 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -7,6 +7,8 @@ description: Custom Towny features for the Trilleo server depend: - Towny - Vault +softdepend: + - FancyNpcs commands: tritown: diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyPulseTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyPulseTest.kt new file mode 100644 index 0000000..4fefd70 --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyPulseTest.kt @@ -0,0 +1,176 @@ +package net.trilleo.mc.plugins.tritown.economy + +import net.trilleo.mc.plugins.tritown.economy.storage.JsonPulseStorage +import net.trilleo.mc.plugins.tritown.enums.AccountType +import net.trilleo.mc.plugins.tritown.enums.FlowCategory +import net.trilleo.mc.plugins.tritown.enums.TransactionType +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.util.logging.Level +import java.util.logging.Logger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class EconomyPulseTest { + + @TempDir + lateinit var directory: File + + private val logger = Logger.getLogger("EconomyPulseTest").apply { level = Level.OFF } + private val currency = Currency("dollar", "Dollar", "Dollars", "$", 2, "%symbol%%amount%", "%symbol%%amount%") + + @BeforeEach + fun setUp() { + EconomyPulse.start(JsonPulseStorage(directory, logger), currency, retentionDays = 30) + } + + @AfterEach + fun tearDown() { + EconomyPulse.shutdown() + } + + private fun deposit(minor: Long, reason: String, holder: AccountType = AccountType.PLAYER) = + EconomyPulse.record( + TransactionType.DEPOSIT, currency, Money(minor), holder, EconomyContext.SOURCE_COMMAND, reason + ) + + private fun withdraw(minor: Long, reason: String, holder: AccountType = AccountType.PLAYER) = + EconomyPulse.record( + TransactionType.WITHDRAW, currency, Money(minor), holder, EconomyContext.SOURCE_COMMAND, reason + ) + + @Test + fun `totals deposits as created and withdrawals as destroyed`() { + deposit(10_000L, TransactionReason.STARTING_BALANCE) + withdraw(2_500L, TransactionReason.SHOP_BUY) + + val flow = EconomyPulse.window(hours = 24) + + assertEquals(10_000L, flow.created) + assertEquals(2_500L, flow.destroyed) + assertEquals(7_500L, flow.net) + assertEquals(2, flow.movements.toInt()) + } + + @Test + fun `files movements under the category their reason belongs to`() { + deposit(10_000L, TransactionReason.STARTING_BALANCE) + deposit(500L, TransactionReason.SHOP_SELL) + withdraw(300L, TransactionReason.SHOP_BUY) + withdraw(1_000L, TransactionReason.TOWNY, AccountType.TOWN) + + val flow = EconomyPulse.window(hours = 24) + + assertEquals(10_000L, flow.createdBy[FlowCategory.STARTING_BALANCE]) + assertEquals(500L, flow.createdBy[FlowCategory.SHOP]) + assertEquals(300L, flow.destroyedBy[FlowCategory.SHOP]) + assertEquals(200L, flow.netOf(FlowCategory.SHOP)) + assertEquals(-1_000L, flow.netOf(FlowCategory.TOWNY)) + } + + @Test + fun `counts a transfer once and leaves the supply alone`() { + EconomyPulse.record( + TransactionType.TRANSFER_OUT, currency, Money(4_000L), AccountType.PLAYER, + EconomyContext.SOURCE_COMMAND, TransactionReason.PAYMENT, + ) + EconomyPulse.record( + TransactionType.TRANSFER_IN, currency, Money(4_000L), AccountType.PLAYER, + EconomyContext.SOURCE_COMMAND, TransactionReason.PAYMENT, + ) + + val flow = EconomyPulse.window(hours = 24) + + assertEquals(4_000L, flow.circulated) + assertEquals(1L, flow.transfers) + assertEquals(0L, flow.created) + assertEquals(0L, flow.destroyed) + } + + @Test + fun `keeps balances that were set apart from money that was created`() { + EconomyPulse.record( + TransactionType.SET, currency, Money(9_000L), AccountType.PLAYER, + EconomyContext.SOURCE_COMMAND, TransactionReason.ADMIN_SET, + ) + + val flow = EconomyPulse.window(hours = 24) + + assertEquals(9_000L, flow.adjusted) + assertEquals(0L, flow.created) + assertEquals(0L, flow.destroyed) + } + + @Test + fun `ignores a currency it is not keeping figures in`() { + val other = currency.copy(id = "credit") + EconomyPulse.record( + TransactionType.DEPOSIT, other, Money(5_000L), AccountType.PLAYER, + EconomyContext.SOURCE_COMMAND, TransactionReason.EXTERNAL, + ) + + assertTrue(EconomyPulse.window(hours = 24).isEmpty) + } + + @Test + fun `splits the window into columns that add up to the whole`() { + deposit(1_000L, TransactionReason.EXTERNAL) + withdraw(400L, TransactionReason.EXTERNAL) + + val flow = EconomyPulse.window(hours = 24, slices = 7) + + assertEquals(7, flow.slices.size) + assertEquals(1_000L, flow.slices.sumOf { it.created }) + assertEquals(400L, flow.slices.sumOf { it.destroyed }) + // Everything just recorded belongs to the last column, which ends now. + assertEquals(1_000L, flow.slices.last().created) + } + + @Test + fun `measures the supply and how unevenly it is held`() { + val ledger = EconomyLedger() + ledger.deposit(ledger.getOrCreate(uuid(1), "Alex", AccountType.PLAYER), currency, Money(1_000L)) + ledger.deposit(ledger.getOrCreate(uuid(2), "Blair", AccountType.PLAYER), currency, Money(3_000L)) + ledger.deposit(ledger.getOrCreate(uuid(3), "Casey", AccountType.PLAYER), currency, Money(6_000L)) + ledger.deposit(ledger.getOrCreate(uuid(4), "town-Riverbend", AccountType.TOWN), currency, Money(5_000L)) + + EconomyPulse.sample(ledger, currency) + val supply = assertNotNull(EconomyPulse.latest()) + + assertEquals(15_000L, supply.total) + assertEquals(10_000L, supply.held) + assertEquals(5_000L, supply.banked) + assertEquals(3, supply.wallets) + assertEquals(3_000L, supply.median) + assertEquals(6_000L, supply.richest) + assertTrue(supply.gini > 0.0, "an uneven ledger should not read as perfectly equal") + } + + @Test + fun `reads back what it wrote`() { + deposit(7_000L, TransactionReason.STARTING_BALANCE) + EconomyPulse.flush() + + EconomyPulse.start(JsonPulseStorage(directory, logger), currency, retentionDays = 30) + val flow = EconomyPulse.window(hours = 24) + + assertEquals(7_000L, flow.created) + assertEquals(7_000L, flow.createdBy[FlowCategory.STARTING_BALANCE]) + } + + @Test + fun `drops figures written in another currency rather than reading them as its own`() { + deposit(7_000L, TransactionReason.STARTING_BALANCE) + EconomyPulse.flush() + + EconomyPulse.start(JsonPulseStorage(directory, logger), currency.copy(id = "credit"), retentionDays = 30) + + assertTrue(EconomyPulse.window(hours = 24).isEmpty) + } + + private fun uuid(seed: Long) = java.util.UUID(0L, seed) +} diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIFrameTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIFrameTest.kt new file mode 100644 index 0000000..2ddfee5 --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIFrameTest.kt @@ -0,0 +1,52 @@ +package net.trilleo.mc.plugins.tritown.registration + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The slot arithmetic behind every framed menu. + * + * Worth pinning down here rather than in game: an off-by-one puts an item under + * the border where nothing can click it, and that looks like a menu that + * ignores you rather than like a bug. + */ +class GUIFrameTest { + + @Test + fun `a six-row menu keeps the four inner rows`() { + val slots = GUIFrame.contentSlots(6) + + assertEquals(28, slots.size) + assertEquals(10, slots.first()) + assertEquals(43, slots.last()) + } + + @Test + fun `content is in reading order, so a list fills left to right`() { + assertEquals(listOf(10, 11, 12, 13, 14, 15, 16, 19), GUIFrame.contentSlots(6).take(8)) + } + + @Test + fun `the border rows and columns are never content`() { + val slots = GUIFrame.contentSlots(6).toSet() + + val topRow = 0..8 + val bottomRow = 45..53 + val leftColumn = (0 until 6).map { it * 9 } + val rightColumn = (0 until 6).map { it * 9 + 8 } + + assertTrue((topRow + bottomRow + leftColumn + rightColumn).none { it in slots }) + } + + @Test + fun `a three-row menu keeps its single inner row`() { + assertEquals((10..16).toList(), GUIFrame.contentSlots(3)) + } + + @Test + fun `a menu too short to have an inside holds nothing`() { + assertEquals(emptyList(), GUIFrame.contentSlots(2)) + assertEquals(emptyList(), GUIFrame.contentSlots(1)) + } +} diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimitTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimitTest.kt new file mode 100644 index 0000000..a7d78b6 --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopLimitTest.kt @@ -0,0 +1,69 @@ +package net.trilleo.mc.plugins.tritown.shops + +import net.trilleo.mc.plugins.tritown.enums.LimitPeriod +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +/** + * A per-player limit has to reset for everybody at once, and a lifetime limit + * must never reset at all. + */ +class ShopLimitTest { + + private val day = 24L * 60L * 60L * 1000L + + /** An arbitrary instant snapped to the start of a week, so both windows begin here. */ + private val start = 1_700_000_000_000L / (7L * day) * (7L * day) + + @Test + fun `a lifetime limit has a single window`() { + val limit = ShopLimit(5, LimitPeriod.NONE) + + assertEquals(limit.windowAt(0L), limit.windowAt(10_000L * day)) + } + + @Test + fun `a daily limit changes window at the day boundary`() { + val limit = ShopLimit(5, LimitPeriod.DAILY) + + assertEquals(limit.windowAt(start), limit.windowAt(start + day - 1)) + assertNotEquals(limit.windowAt(start), limit.windowAt(start + day)) + } + + @Test + fun `a weekly limit changes window at the week boundary`() { + val limit = ShopLimit(5, LimitPeriod.WEEKLY) + + assertEquals(limit.windowAt(start), limit.windowAt(start + 7 * day - 1)) + assertNotEquals(limit.windowAt(start), limit.windowAt(start + 7 * day)) + } + + @Test + fun `what is spent this window counts against the limit`() { + val limit = ShopLimit(5, LimitPeriod.DAILY) + + assertEquals(2, limit.remaining(used = 3, usedWindow = limit.windowAt(start), epochMillis = start)) + } + + @Test + fun `a count from an earlier window is spent and ignored`() { + val limit = ShopLimit(5, LimitPeriod.DAILY) + + assertEquals(5, limit.remaining(used = 5, usedWindow = limit.windowAt(start), epochMillis = start + day)) + } + + @Test + fun `a lifetime limit never gives the allowance back`() { + val limit = ShopLimit(5, LimitPeriod.NONE) + + assertEquals(0, limit.remaining(used = 5, usedWindow = 0L, epochMillis = start + 1000 * day)) + } + + @Test + fun `buying past the limit does not report a negative allowance`() { + val limit = ShopLimit(5, LimitPeriod.DAILY) + + assertEquals(0, limit.remaining(used = 9, usedWindow = limit.windowAt(start), epochMillis = start)) + } +} diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopPricingTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopPricingTest.kt new file mode 100644 index 0000000..b4f393e --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopPricingTest.kt @@ -0,0 +1,63 @@ +package net.trilleo.mc.plugins.tritown.shops + +import net.trilleo.mc.plugins.tritown.enums.TownyRequirement +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Discounts pick the best match rather than adding up, and never round a price up. */ +class ShopPricingTest { + + private val rates = mapOf( + TownyRequirement.HAS_TOWN to 0.05, + TownyRequirement.HAS_NATION to 0.10, + TownyRequirement.IS_MAYOR to 0.20, + ) + + @Test + fun `no standing earns no discount`() { + assertEquals(0.0, ShopPricing.discount(rates, setOf(TownyRequirement.NONE))) + } + + @Test + fun `a matching standing earns its discount`() { + assertEquals(0.05, ShopPricing.discount(rates, setOf(TownyRequirement.NONE, TownyRequirement.HAS_TOWN))) + } + + @Test + fun `several standings earn the best one, not the sum`() { + val standing = setOf(TownyRequirement.HAS_TOWN, TownyRequirement.HAS_NATION, TownyRequirement.IS_MAYOR) + + assertEquals(0.20, ShopPricing.discount(rates, standing)) + } + + @Test + fun `a standing with no rate set earns nothing`() { + assertEquals(0.0, ShopPricing.discount(rates, setOf(TownyRequirement.IS_KING))) + } + + @Test + fun `a discount comes off the price`() { + assertEquals(80.0, ShopPricing.apply(100.0, 0.20, 2)) + } + + @Test + fun `a discounted price rounds down, so the discount is never worth less than it says`() { + assertEquals(6.66, ShopPricing.apply(9.99, 1.0 / 3.0, 2)) + } + + @Test + fun `no discount leaves the price alone`() { + assertEquals(9.99, ShopPricing.apply(9.99, 0.0, 2)) + } + + @Test + fun `a free entry stays free`() { + assertEquals(0.0, ShopPricing.apply(0.0, 0.5, 2)) + } + + @Test + fun `a total is rounded to the currency's scale`() { + assertEquals(10.01, ShopPricing.round(10.005, 2)) + assertEquals(10.0, ShopPricing.round(10.004, 2)) + } +} diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStockTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStockTest.kt new file mode 100644 index 0000000..ac0be53 --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopStockTest.kt @@ -0,0 +1,89 @@ +package net.trilleo.mc.plugins.tritown.shops + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** A limited supply has to refill on time, and never past what it started with. */ +class ShopStockTest { + + private val minute = 60_000L + + @Test + fun `first look sets the clock rather than restocking`() { + val stock = ShopStock(max = 10, restockSeconds = 60L, remaining = 4, lastRestock = 0L) + + stock.restock(START) + + assertEquals(4, stock.remaining) + assertEquals(START, stock.lastRestock) + } + + @Test + fun `nothing refills before the period is up`() { + val stock = ShopStock(max = 10, restockSeconds = 60L, remaining = 2, lastRestock = START) + + assertEquals(2, stock.available(START + minute - 1)) + } + + @Test + fun `the period turning over fills it back up`() { + val stock = ShopStock(max = 10, restockSeconds = 60L, remaining = 2, lastRestock = START) + + assertEquals(10, stock.available(START + minute)) + } + + @Test + fun `a long absence fills it once, not once per period`() { + val stock = ShopStock(max = 10, restockSeconds = 60L, remaining = 0, lastRestock = START) + + assertEquals(10, stock.available(START + 100 * minute)) + } + + @Test + fun `the refill time advances by whole periods so it does not drift`() { + val stock = ShopStock(max = 10, restockSeconds = 60L, remaining = 0, lastRestock = START) + + stock.restock(START + 3 * minute + 30_000L) + + assertEquals(START + 3 * minute, stock.lastRestock) + } + + @Test + fun `a supply that never restocks stays where it is`() { + val stock = ShopStock(max = 10, restockSeconds = 0L, remaining = 1, lastRestock = START) + + assertEquals(1, stock.available(START + 1000 * minute)) + } + + @Test + fun `taking more than is there takes nothing`() { + val stock = ShopStock(max = 10, restockSeconds = 0L, remaining = 3, lastRestock = START) + + assertFalse(stock.take(4, START)) + assertEquals(3, stock.remaining) + } + + @Test + fun `taking what is there succeeds`() { + val stock = ShopStock(max = 10, restockSeconds = 0L, remaining = 3, lastRestock = START) + + assertTrue(stock.take(3, START)) + assertEquals(0, stock.remaining) + } + + @Test + fun `putting stock back never goes above the maximum`() { + val stock = ShopStock(max = 10, restockSeconds = 0L, remaining = 9, lastRestock = START) + + stock.restore(5) + + assertEquals(10, stock.remaining) + } + + private companion object { + /** An arbitrary fixed instant, so no test depends on the wall clock. */ + const val START = 1_700_000_000_000L + } +} diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/JsonShopStorageTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/JsonShopStorageTest.kt new file mode 100644 index 0000000..246d8a7 --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/shops/storage/JsonShopStorageTest.kt @@ -0,0 +1,126 @@ +package net.trilleo.mc.plugins.tritown.shops.storage + +import java.io.File +import java.nio.file.Files +import java.util.logging.Level +import java.util.logging.Logger +import kotlin.test.* + +/** + * The shop file is the only copy of work that took an administrator an + * afternoon, so what it must never do is lose it: not to a crash mid-write, not + * to one malformed entry, and not to an older build opening a newer file. + */ +class JsonShopStorageTest { + + private val directory: File = Files.createTempDirectory("tritown-shops").toFile() + private val logger = Logger.getAnonymousLogger().apply { level = Level.OFF } + private val storage = JsonShopStorage(directory, logger) + + private val shopsFile = File(File(directory, "shops"), "shops.json") + private val backupFile = File(File(directory, "shops"), "shops.json.bak") + + @AfterTest + fun cleanUp() { + directory.deleteRecursively() + } + + @Test + fun `a server with no shops starts empty`() { + assertEquals(emptyList(), storage.loadAll()) + } + + @Test + fun `a shop survives a round trip`() { + val shop = StoredShop( + id = "market", + displayName = "Market", + permission = "tritown.shop.market", + towny = "HAS_TOWN", + hideWhenLocked = true, + npcIds = listOf("npc-1", "npc-2"), + entries = listOf( + StoredEntry( + id = "entry-1", + item = "encoded-bread", + buy = StoredCost(12.5, listOf("encoded-iron")), + sell = StoredCost(6.25), + limitAmount = 3, + limitPeriod = "DAILY", + stockMax = 64, + stockRestockSeconds = 3600L, + stockRemaining = 12, + bought = 7L, + moneyIn = 87.5, + ) + ), + ) + + storage.saveAll(listOf(shop)) + + assertEquals(listOf(shop), storage.loadAll()) + } + + @Test + fun `saving replaces what was there rather than adding to it`() { + storage.saveAll(listOf(StoredShop(id = "one"), StoredShop(id = "two"))) + storage.saveAll(listOf(StoredShop(id = "one"))) + + assertEquals(listOf("one"), storage.loadAll().map { it.id }) + } + + @Test + fun `the previous copy is kept as a backup`() { + storage.saveAll(listOf(StoredShop(id = "one"))) + storage.saveAll(listOf(StoredShop(id = "two"))) + + assertTrue(backupFile.exists()) + assertTrue(backupFile.readText().contains("one")) + } + + @Test + fun `a corrupt file falls back to the backup instead of starting empty`() { + storage.saveAll(listOf(StoredShop(id = "one"))) + storage.saveAll(listOf(StoredShop(id = "two"))) + shopsFile.writeText("{ this is not json") + + assertEquals(listOf("one"), storage.loadAll().map { it.id }) + } + + @Test + fun `neither copy being readable is an error, not an empty server`() { + storage.saveAll(listOf(StoredShop(id = "one"))) + storage.saveAll(listOf(StoredShop(id = "two"))) + shopsFile.writeText("{ broken") + backupFile.writeText("also broken") + + assertFailsWith { storage.loadAll() } + } + + @Test + fun `one unreadable shop is skipped rather than taking the rest with it`() { + shopsFile.parentFile.mkdirs() + shopsFile.writeText( + """ + { + "schemaVersion": 1, + "shops": [ + { "id": "good", "displayName": "Good" }, + "not an object", + { "displayName": "No id at all" } + ] + } + """.trimIndent() + ) + + assertEquals(listOf("good"), storage.loadAll().map { it.id }) + } + + @Test + fun `a file from a newer build is refused rather than half-read`() { + shopsFile.parentFile.mkdirs() + shopsFile.writeText("""{ "schemaVersion": ${ShopSchema.CURRENT + 1}, "shops": [] }""") + + assertFailsWith { storage.loadAll() } + } +}