Compare commits

..
52 Commits
Author SHA1 Message Date
NotAlexNoyle 3dca9160c7 Sync gradle versions across branches 2025-06-05 11:34:43 -04:00
NotAlexNoyle 2bcc3c6af7 Update gradle 2025-06-02 19:21:32 -04:00
NotAlexNoyle 223b124f5c Merge pull request #12 from SKBotNL/master
Update Shadow to 8.3.6
2025-06-02 19:16:36 -04:00
Sven 9ea3a45341 Update Shadow to 8.3.6 2025-06-01 23:14:00 +02:00
NotAlexNoyle 8a1782804e Merge pull request #10 from SKBotNL/master
API calls are now null if the economy is disabled
2025-05-28 02:46:36 -04:00
Sven 1723dc8c0c Merge branch 'true-og:master' into master 2025-05-27 12:57:20 +02:00
SKBotNL 3db90103e1 API calls are now null if the economy is disabled 2025-05-27 12:57:05 +02:00
NotAlexNoyle 2c5d1ab576 Merge pull request #9 from SKBotNL/master
Change API to use Bukkit's ServiceManager
2025-05-26 08:40:40 -04:00
Sven 63b490ed39 Merge branch 'true-og:master' into master 2025-05-26 14:39:29 +02:00
SKBotNL 31e41cdc5a Change API to use the Bukkit ServicesManager 2025-05-26 14:29:23 +02:00
NotAlexNoyle 5ffb160f25 Merge pull request #8 from SKBotNL/master
Bump org.jetbrains.kotlin.jvm from 2.1.0 to 2.1.21
2025-05-25 07:47:59 -04:00
Sven 5a45860f4c Merge pull request #59 from SKBotNL/dependabot/gradle/org.jetbrains.kotlin.jvm-2.1.21
Bump org.jetbrains.kotlin.jvm from 2.1.0 to 2.1.21
2025-05-25 13:46:56 +02:00
dependabot[bot] 28a674cc6c Bump org.jetbrains.kotlin.jvm from 2.1.0 to 2.1.21
Bumps [org.jetbrains.kotlin.jvm](https://github.com/JetBrains/kotlin) from 2.1.0 to 2.1.21.
- [Release notes](https://github.com/JetBrains/kotlin/releases)
- [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md)
- [Commits](https://github.com/JetBrains/kotlin/compare/v2.1.0...v2.1.21)

---
updated-dependencies:
- dependency-name: org.jetbrains.kotlin.jvm
  dependency-version: 2.1.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-13 14:25:49 +00:00
NotAlexNoyle 0a5c35f057 Merge pull request #7 from SKBotNL/master
Update dependencies (master)
2025-05-05 10:57:57 -04:00
Sven 8997512b46 Update gradle.yml 2025-04-24 20:39:08 +02:00
Sven 5c7ab67726 Merge pull request #40 from SKBotNL/dependabot/gradle/io.sentry-sentry-kotlin-extensions-8.7.0
Bump io.sentry:sentry-kotlin-extensions from 7.20.0 to 8.7.0
2025-04-24 20:37:34 +02:00
Sven f82b20919d Create gradle.yml 2025-04-24 20:36:08 +02:00
dependabot[bot] 2445f1e490 Bump io.sentry:sentry-kotlin-extensions from 7.20.0 to 8.7.0
Bumps [io.sentry:sentry-kotlin-extensions](https://github.com/getsentry/sentry-java) from 7.20.0 to 8.7.0.
- [Release notes](https://github.com/getsentry/sentry-java/releases)
- [Changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-java/compare/7.20.0...8.7.0)

---
updated-dependencies:
- dependency-name: io.sentry:sentry-kotlin-extensions
  dependency-version: 8.7.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-24 18:34:30 +00:00
Sven b25b66b62f Merge pull request #43 from SKBotNL/dependabot/gradle/io.sentry-sentry-8.8.0
Bump io.sentry:sentry from 7.20.0 to 8.8.0
2025-04-24 20:33:28 +02:00
dependabot[bot] 220c1e1bb6 Bump io.sentry:sentry from 7.20.0 to 8.8.0
Bumps [io.sentry:sentry](https://github.com/getsentry/sentry-java) from 7.20.0 to 8.8.0.
- [Release notes](https://github.com/getsentry/sentry-java/releases)
- [Changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-java/compare/7.20.0...8.8.0)

---
updated-dependencies:
- dependency-name: io.sentry:sentry
  dependency-version: 8.8.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-15 14:30:24 +00:00
Sven 80fa951e56 Merge pull request #1 from SKBotNL/dependabot/gradle/io.sentry-sentry-7.20.0
Bump io.sentry:sentry from 7.14.0 to 7.20.0
2025-01-03 19:06:31 +00:00
dependabot[bot] af428263b5 Bump io.sentry:sentry from 7.14.0 to 7.20.0
Bumps [io.sentry:sentry](https://github.com/getsentry/sentry-java) from 7.14.0 to 7.20.0.
- [Release notes](https://github.com/getsentry/sentry-java/releases)
- [Changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-java/compare/7.14.0...7.20.0)

---
updated-dependencies:
- dependency-name: io.sentry:sentry
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-03 19:05:49 +00:00
Sven cee62fbe5c Merge pull request #2 from SKBotNL/dependabot/gradle/io.sentry-sentry-kotlin-extensions-7.20.0
Bump io.sentry:sentry-kotlin-extensions from 7.14.0 to 7.20.0
2025-01-03 19:05:05 +00:00
Sven 404111d5ce Merge pull request #3 from SKBotNL/dependabot/gradle/org.jetbrains.kotlin.jvm-2.1.0
Bump org.jetbrains.kotlin.jvm from 2.0.21 to 2.1.0
2025-01-03 19:04:57 +00:00
Sven 1c9c60aae8 Run Dependabot for the shards branch 2025-01-03 18:51:04 +00:00
dependabot[bot] ac11753844 Bump org.jetbrains.kotlin.jvm from 2.0.21 to 2.1.0
Bumps [org.jetbrains.kotlin.jvm](https://github.com/JetBrains/kotlin) from 2.0.21 to 2.1.0.
- [Release notes](https://github.com/JetBrains/kotlin/releases)
- [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md)
- [Commits](https://github.com/JetBrains/kotlin/compare/v2.0.21...v2.1.0)

---
updated-dependencies:
- dependency-name: org.jetbrains.kotlin.jvm
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-03 18:50:44 +00:00
dependabot[bot] e5b7697c7f Bump io.sentry:sentry-kotlin-extensions from 7.14.0 to 7.20.0
Bumps [io.sentry:sentry-kotlin-extensions](https://github.com/getsentry/sentry-java) from 7.14.0 to 7.20.0.
- [Release notes](https://github.com/getsentry/sentry-java/releases)
- [Changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-java/compare/7.14.0...7.20.0)

---
updated-dependencies:
- dependency-name: io.sentry:sentry-kotlin-extensions
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-03 18:50:06 +00:00
NotAlexNoyle 337fc3f2b8 Update kotlin 2024-11-01 12:20:36 +00:00
NotAlexNoyle ad0a0ad2d4 Merge pull request #5 from true-og/baltop-fixes
Baltop fixes
2024-09-14 10:21:25 -04:00
SKBotNL 69e5fa2f64 Fix casting Long to Int error 2024-09-14 16:19:03 +02:00
SKBotNL 8fbb70bedf Add missing MiniMessage deserialization 2024-09-14 16:18:03 +02:00
NotAlexNoyle c816383e12 Merge pull request #4 from true-og/totalbalance-api
Add getPlayerTotalBalance() to the API
2024-09-14 08:42:40 -04:00
SKBotNL 29c4d46dd5 Add getPlayerTotalBalance() to the API 2024-09-14 14:37:06 +02:00
Sven e051856b7a Merge pull request #3 from true-og/fix/gradle
Fix build and remove unneeded repository
2024-09-14 14:16:41 +02:00
SKBotNL a9f0985398 Fix build and remove unneeded repository 2024-09-14 14:13:56 +02:00
NotAlexNoyle 25f66ba41f Merge pull request #2 from s0me0ne-coder/new
initial implementation of migration
2024-09-14 07:59:48 -04:00
SKBotNL 1293994911 Rewrite migration 2024-09-14 13:55:12 +02:00
Sven e9195e9573 Change migrated message 2024-09-14 12:18:16 +02:00
Sven 291a96f505 Change disablePlugin function 2024-09-14 12:15:39 +02:00
NotAlexNoyle 8ba2b53ba7 Merge pull request #1 from SKBotNL/master
Update dependencies
2024-09-14 06:00:19 -04:00
Sven a874878fb4 Merge pull request #3 from SKBotNL/dependabot/gradle/org.jetbrains.kotlin.jvm-2.0.20
Bump org.jetbrains.kotlin.jvm from 1.9.23 to 2.0.20
2024-09-14 11:52:46 +02:00
Sven 4561ffa3d2 Merge pull request #4 from SKBotNL/dependabot/gradle/io.sentry-sentry-7.14.0
Bump io.sentry:sentry from 7.8.0 to 7.14.0
2024-09-14 11:52:27 +02:00
s0me0ne-coder 4b6c64fabd initial implementation of migration 2024-09-12 14:42:48 +03:00
dependabot[bot] 9754218e85 Bump io.sentry:sentry from 7.8.0 to 7.14.0
Bumps [io.sentry:sentry](https://github.com/getsentry/sentry-java) from 7.8.0 to 7.14.0.
- [Release notes](https://github.com/getsentry/sentry-java/releases)
- [Changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-java/compare/7.8.0...7.14.0)

---
updated-dependencies:
- dependency-name: io.sentry:sentry
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-26 18:17:49 +00:00
dependabot[bot] 0d3b285111 Bump org.jetbrains.kotlin.jvm from 1.9.23 to 2.0.20
Bumps [org.jetbrains.kotlin.jvm](https://github.com/JetBrains/kotlin) from 1.9.23 to 2.0.20.
- [Release notes](https://github.com/JetBrains/kotlin/releases)
- [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md)
- [Commits](https://github.com/JetBrains/kotlin/compare/v1.9.23...v2.0.20)

---
updated-dependencies:
- dependency-name: org.jetbrains.kotlin.jvm
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-26 18:17:44 +00:00
Sven c366bccb66 Create dependabot.yml 2024-08-26 19:55:22 +02:00
Sven 09a14a7c85 Fix indentation 2024-07-02 21:56:06 +02:00
Sven bcc0a70d2e Merge pull request #1 from NotAlexNoyle/master
Update to gradle 8.8
2024-07-02 20:34:17 +02:00
NotAlexNoyle 8068f50d19 Update to gradle 8.8 2024-07-02 14:30:41 -04:00
SKBotNL 6962ab1a6c Update build.gradle.kts 2024-04-17 19:01:04 +02:00
Sven 1987d30f18 Update README.md 2024-04-10 21:16:04 +02:00
SKBotNL 322dd3f71b Change from Long to Int 2024-04-10 19:24:40 +02:00
91 changed files with 2552 additions and 8243 deletions

No files matched your search

-1
View File
@@ -1 +0,0 @@
.gitea/CODEOWNERS linguist-detectable=false
-3
View File
@@ -1,3 +0,0 @@
# Pull requests request review from the eligible staff team(s).
# One approval from either an Admin or Developer satisfies branch protection.
* @trueog/Admins @trueog/Developers
+16
View File
@@ -0,0 +1,16 @@
version: 2
updates:
- package-ecosystem: "gradle"
directory: "/"
schedule:
interval: "daily"
ignore:
- dependency-name: "org.purpurmc.purpur:purpur-api"
- package-ecosystem: "gradle"
directory: "/"
target-branch: "shards"
schedule:
interval: "daily"
ignore:
- dependency-name: "org.purpurmc.purpur:purpur-api"
+67
View File
@@ -0,0 +1,67 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle
name: Java CI with Gradle
on:
push:
branches: [ "master", "shards" ]
pull_request:
branches: [ "master", "shards" ]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
# Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies.
# See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md
- name: Setup Gradle
uses: gradle/actions/setup-gradle@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0
- name: Build with Gradle Wrapper
run: ./gradlew build
# NOTE: The Gradle Wrapper is the default and recommended way to run Gradle (https://docs.gradle.org/current/userguide/gradle_wrapper.html).
# If your project does not have the Gradle Wrapper configured, you can use the following configuration to run Gradle with a specified version.
#
# - name: Setup Gradle
# uses: gradle/actions/setup-gradle@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0
# with:
# gradle-version: '8.9'
#
# - name: Build with Gradle 8.9
# run: gradle build
dependency-submission:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
# Generates and submits a dependency graph, enabling Dependabot Alerts for all project dependencies.
# See: https://github.com/gradle/actions/blob/main/dependency-submission/README.md
- name: Generate and submit dependency graph
uses: gradle/actions/dependency-submission@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0
+1 -1
View File
@@ -3,7 +3,7 @@
.classpath
.project
.settings/
.kotlin
*.iml
*.ipr
*.iws
-3
View File
@@ -1,3 +0,0 @@
[submodule "libs/Utilities-OG"]
path = libs/Utilities-OG
url = https://git.trueog.net/trueog/Utilities-OG
-27
View File
@@ -1,27 +0,0 @@
# DiamondBank-OG defect chart delta
* `K.consumeFromPlayer`, `J.consumeFromPlayer`, `K.playerPayPlayer`, `J.playerPayPlayer` (offline payer): fixed. `3df19f6` moves Utilities-OG to `1439e99`, whose offline inventory helpers read the converted profile and write the whole compound back (`UtilitiesOG.java:600-633`), together with TrueOG Purpur `97656e3c`, whose `getPlayerData` runs the DataFixer and stamps the current `DataVersion` (`PlayerDataStorage.java:257-269`).
* `J.addToPlayerBankShards`, `J.subtractFromPlayerBankShards`, `J.getBankShards`, `J.getInventoryShards`, `J.getEnderChestShards`, `J.getTotalShards`, `J.getAllShards`, `J.consumeFromPlayer`, `J.playerPayPlayer`: unfixed, no change.
* K and J argument and exception contracts: unfixed. Remains: K validation escapes its Result contract, J omits checked failures, and K payment KDoc names the wrong failure. Undeclared unchecked Java argument exceptions are withdrawn as a declaration defect; the current K argument and K/J exception documentation row carries the remaining issues (`9835d79` only moved `Balancetop.kt` lines).
* `/setbankbalance`, `/setbankbal`: unfixed, no change.
* `/compress`, `AutoCompress.compress` (from `onEntityPickupItem`, `onInventoryClose`): unfixed, no change to the space checks.
* `/deposit all`, `/withdraw all`: unfixed, no change.
* `/pay`: unfixed, no change.
* `AutoDeposit.deposit` via `onEntityPickupItem`: partially fixed. `aba9961` credits the whole entity (`AutoDeposit.kt:16`, `27`; `Events.kt:181`) and `2e64c21` skips cancelled pickups (`Events.kt:148`). Remains: a failed credit still loses the emptied stack (`AutoDeposit.kt:39-40`, `44`).
* `onPlayerJoin`: unfixed, no change.
* `DiamondBankReload` (`/diamondbankreload`) and the executors it leaves behind (`/deposit`, `/withdraw`, `/pay`, `/compress`): partially fixed. `c1452b7` adds the admin check (`DiamondBankReload.kt:16-21`). Remains: stale executors, now seven classes after `9835d79` (`Balance.kt:18-19`, `Balancetop.kt:20-21`, `SetBankBalance.kt:20-21`), the pool leak, and Redis not rebuilt.
* `Redis.getValue`, `Redis.setValue` via `onEntityPickupItem`, `onInventoryClose`, `/autocompress`, `/autodeposit`: unfixed, no change to `Redis.kt`; `b65077a` moved the currency test inside the disabled branch (`Events.kt:154-173`), so the main-thread call at `Events.kt:180` now runs for every item picked up.
* `onDisable`: unfixed. `35feaf6` (`DiamondBankOG.kt:148-157`) only moves the cancel and join ahead of the Redis and manager shutdown; in-flight restores are still dropped (now the submitted bank writes and `runOnMainThread { restoreTo }` completion row, which also covers caller cancellation and cache divergence after submitted writes).
* `plugin.yml`: partially fixed. `3df19f6` adds Utilities-OG to `softdepend` (`plugin.yml:59`); the LuckPerms clause did not reproduce (LuckPerms declares `load: STARTUP`) and is withdrawn. Remains: the `/dbtoggledebug` usage at `plugin.yml:56`.
* `onEnable` after a same-instance disable, and recipes across `/minecraft:reload`: unfixed, no change. The same-instance re-enable clause is withdrawn, because Paper closes the plugin class loader on disable (`PaperPluginInstanceManager.java:226-235`) and a re-enable fails there whatever DiamondBank does; the recipe loss across `/minecraft:reload` and the missing prismarine bricks guard remain.
* `TransactionLock.withLockSuspend`: unfixed, no change; `b65077a` (`Events.kt:154-173`, `205-224`) widens the trigger to pickups and drops of any item.
* `InventorySnapshotUtils.removeAllShards`: regressed. `fc6ea81` (`InventorySnapshotUtils.kt:29`, `107`; `InventoryExtensions.kt:36-71`) removes renamed shards, which ends the infinite deposit, but the new remover throws on an emptied offhand stack; see `InventoryExtensions.removeItemWithSameKey` on an `InventorySnapshot`.
* `InventorySnapshot.restoreTo` and the lock guards in `onInventoryMoveItem`: partially fixed. `b65077a` (`Events.kt:175`, `226`, `271`, `314`, `355`) now cancels every pickup, drop, click and block or entity interaction while locked and removes the own-inventory early return. Remains: the stale 41-slot restore across main-thread hops, applied after the bank write for bank-backed operations; right-click at air (`Events.kt:283`), armor stands, the recipe book, bow and trident release, consumption, totems, death and drags; and offline consumption writing the profile after the debit. With playerdata saving disabled, that offline write silently succeeds without persisting (`PlayerDataStorage.java:183`, `UtilitiesOG.java:627-628`), as covered by the shared `InventorySnapshot.restoreTo` row.
* `CommonOperations.getPlayerUsingUuidOrName` and its callers' existence check: unfixed, no change.
* `PostgreSQL.internalAddToPlayerShards`: unfixed, no change.
* `PostgreSQL.getBaltopWithUuid`: unfixed, no change.
* `InventorySnapshotUtils.removeShards`: unfixed, no change.
* `PostgreSQL.setPlayerShards`, `addToPlayerShards`, `transferBankShards` (event emission): unfixed, no change; not carried forward, because no contract requires a listener's re-read to see the write and the event already carries the committed values.
* `CommonOperations.diamondsToShards`: unfixed, no change.
Current commit `3df19f636773d113bcb6b58d715d3e6c2df852d3`. Reviewer: `ck:inspector` (Opus), because Codex (`gpt-5.6-sol` at `xhigh`) failed on the first run and on the retry after the reset with `ERROR: You’ve hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/settings/usage to purchase more credits or try again at 11:43 PM.` Reviewer findings: 20 accepted, 3 rejected. Codex (gpt-5.6-sol, xhigh): 11 accepted, 3 rejected.
-65
View File
@@ -1,65 +0,0 @@
# DiamondBank-OG defect chart
| Severity | Meaning |
|---|---|
| 🔴 | Critical. Freezes the economy or server, or loses, duplicates or misdirects value or items, and any player can trigger it in normal play (API consumers included) |
| 🟠 | Serious. Wrong money or state that needs an admin action, another plugin, a reload or a rare state to arm, or a feature any player hits that is broken or refuses valid input |
| 🟡 | Minor. Bounded slowdown, wrong message or audit value, API declaration mismatch, edge-input refusal |
## 1. Public API, offline-player compatibility
## 2. Public API, general correctness
| Entry point | Severity | Note |
|---|---|---|
| `J.addToPlayerBankShards`, `J.subtractFromPlayerBankShards`, `J.getBankShards`, `J.getInventoryShards`, `J.getEnderChestShards`, `J.getTotalShards`, `J.getAllShards`, `J.consumeFromPlayer`, `J.playerPayPlayer` | 🔴 server freeze | **J on the main thread deadlocks against any lock holder that is waiting for the next tick.** Every J method does `runBlocking { transactionLock.withLockSuspend(uuid) { ... } }` (`DiamondBankAPIJava.kt:40`, `76`, `155`, `169`, `226`, `285`), which parks the main thread in `Semaphore.acquire()` (`TransactionLock.kt:28`). Every holder that touches an inventory releases only after `runOnMainThread` (`MainThreadBlock.kt:14-26`), which needs the next scheduler heartbeat: `/deposit`, `/withdraw`, `/pay`, `/compress` (`Deposit.kt:82`, `Withdraw.kt:85`, `Pay.kt:95`, `Compress.kt:54`), `AutoCompress` (`AutoCompress.kt:39`), K and J `consumeFromPlayer` and `playerPayPlayer` called off the main thread, and the recounts in `onPlayerJoin`, `onPlayerQuit`, `onPlayerRespawn`, `onEntityPickupItem`, `onPlayerDropItem`, `onInventoryClose` (`Events.kt:80`, `105`, `133`, `190`, `233`, `383`). The pickup and drop recounts run for every item a player picks up or drops (`Events.kt:184-197`, `231-239`), so the window opens constantly. Repro: a plugin's command executor calls `J.getBankShards` for a player in the tick that player drops any item: the main thread parks, the recount never gets its tick, and the watchdog kills the server. The KDoc documents blocking, synchronous use (`:25-26`). *Fix: when `Bukkit.isPrimaryThread()`, acquire the lock in a polling loop that drains queued `runOnMainThread` blocks from a plugin-owned queue instead of `acquire()`.* |
| `K.addToPlayerBankShards`, `K.subtractFromPlayerBankShards`, `K.consumeFromPlayer`, `K.playerPayPlayer`, `K.getBankShards`, `K.getInventoryShards`, `K.getEnderChestShards`, `K.getTotalShards`, `K.getAllShards`, `K.getBaltop` under caller cancellation, and the same J methods when the calling thread is interrupted | 🟠 economy disabled without a database error | **Caller cancellation at a database await is classified as a database error and disables the economy.** The `PostgreSQL` methods catch `Exception`, including the `CancellationException` thrown by a cancelled await (`PostgreSQL.kt:97`, `119`, `201`, `231`, `266`, `279`, `305`, `334`, `425`, `444`; kotlinx-coroutines 1.10.2 `future/Future.kt:148-154`, `168-173`), and return `DatabaseException`. K then calls `handleError` (`DiamondBankAPIKotlin.kt:42`, `47`, `77`, `82`, `111`, `129`, `142`, `176`, `184`, `241`, `251`) and so does J (`DiamondBankAPIJava.kt:46`, `51`, `83`, `88`, `158`, `180`, `201`, `237`, `245`, `299`, `309`), although no database error occurred. Interrupting J's `runBlocking` caller cancels its coroutine (`Builders.kt:93-98`). Repro: `withTimeout(1) { api.getBankShards(uuid) }` while an uncached UUID's database read is pending disables the economy for everyone. *Fix: rethrow `CancellationException` before every generic catch in `PostgreSQL`.* |
| K argument contracts and K/J exception documentation: `K.addToPlayerBankShards`, `K.subtractFromPlayerBankShards`, `K.consumeFromPlayer`, `K.playerPayPlayer`, `K.getBaltop`, `J.consumeFromPlayer`, `J.playerPayPlayer`, `J.diamondsToShards` | 🟡 API contract mismatch | **K argument validation escapes its Result contract, J omits checked exceptions, and one KDoc names a nonexistent failure.** (a) `require(shards <= Long.MAX_VALUE.toULong())` (`DiamondBankAPIKotlin.kt:33`, `67`, `161`, `221`) and `require(offset >= 0)` (`CachingBalanceManager.kt:180`, reached from `DiamondBankAPIKotlin.kt:141`) throw `IllegalArgumentException` instead of returning K's promised `Result` (`api/README.md:29`). (b) J rethrows `InsufficientBalanceException` (`DiamondBankAPIJava.kt:244`, `308`, and `240`, `302`, which rethrow every non-database failure of `consume`) that its `@Throws` (`220`, `273-278`) and the README omit; reachable via a negative receiver bank (`PostgreSQL.kt:143-144`), via the excess-change credit on a negative bank (`InventorySnapshotUtils.kt:41-43`, `CachingBalanceManager.kt:67-68`), or split caches after reload. (c) `MoreThanOneDecimalDigitRuntimeException` extends the checked `DiamondBankException` (`DiamondBankException.kt:20`, base `Exception` at `:3`) and `J.diamondsToShards` has no `@Throws` (`DiamondBankAPIJava.kt:335`, `343`), so `catch (MoreThanOneDecimalDigitRuntimeException e)` does not compile in Java and `catch (RuntimeException e)` misses it. (d) `K.playerPayPlayer` KDoc (`DiamondBankAPIKotlin.kt:206-208`) names `CouldNotRemoveEnoughException.notRemoved` and tells callers to continue with a partial amount; the real failure is `InsufficientFundsException.short` and nothing moved. Repro: `K.getBaltop(-1)` throws `IllegalArgumentException` instead of returning a `Result`. *Fix: return `Result.failure(InvalidArgumentException())` for K's four shard validations and validate K's offset into a failure before the manager call at `DiamondBankAPIKotlin.kt:141`; keep these caller errors out of `handleError`; add `InsufficientBalanceException` to both J `@Throws` lists and the README; make `MoreThanOneDecimalDigitRuntimeException` extend `DiamondBankRuntimeException`; correct the KDoc.* |
## 3. Commands
| Entry point | Severity | Note |
|---|---|---|
| `/setbankbalance`, `/setbankbal` | 🟠 negative balance accepted | **Any negative shard count is stored, and nothing downstream expects one.** `SetBankBalance.kt:68` parses with `toLong()` and `74` stores it. With `-900`: `/withdraw all` takes the negative balance as the amount (`Withdraw.kt:99-104`), `subtractFromBankShards` negates it into a credit (`Withdraw.kt:144`, `CachingBalanceManager.kt:67`, `85-86`), the inventory change is skipped because `addItem` ignores non-positive stacks (`Withdraw.kt:123-131`), the bank returns to 0 and the player reads "Successfully withdrew -100.0 Diamonds" (`Withdraw.kt:161-169`); `/pay` and `playerPayPlayer` demand `shards + 900` from the inventory (`CommonOperations.kt:29-41`), and if it covers that, the negative amount hits `require(shardsToSubtractFromSender >= 0)` (`CachingBalanceManager.kt:94`) with any excess-change credit (`InventorySnapshotUtils.kt:41`, `67`, `74`) already committed. A negative receiver also arms the half-transfer in `PostgreSQL.internalAddToPlayerShards`. Repro: `/setbankbal Alice -900`, then Alice runs `/withdraw all`. *Fix: reject `balance < 0` after the parse at `SetBankBalance.kt:68`.* |
| `/withdraw`, `/pay` | 🟠 economy disabled on a refusal | **A balance refusal from the database pre-check is handled as a database error, so `/withdraw` and `/pay` disable the economy whenever the cached balance is higher than the stored one.** `Withdraw.kt:144-145` and `Pay.kt:141-142` pass every failure of `subtractFromBankShards` and `transferBankShards` to `handleError`, including the `InsufficientBalanceException` that `CachingBalanceManager.kt:67-68` and `104-105` return when the stored balance does not cover the amount; K and J treat that exception as a caller error (`DiamondBankAPIKotlin.kt:76`, `183`, `250`, `DiamondBankAPIJava.kt:82`, `244`, `308`). The commands' own check reads the cached balance (`Withdraw.kt:89-90`, `CommonOperations.kt:25`), which is higher than the stored one after a reload (the commands keep the old manager), after a failed transfer leaves the sender's cache stale, or when `/setbankbal`, which takes no transaction lock (`SetBankBalance.kt:74`), lowers the balance during the command. Repro: after `/diamondbankreload`, another plugin debits 90 of a player's 100 bank shards through `J.subtractFromPlayerBankShards`; the player runs `/withdraw 5`: the economy is disabled. *Fix: at `Withdraw.kt:144` and `Pay.kt:141`, answer `InsufficientBalanceException` with the insufficient-funds reply and refresh the cached balance instead of calling `handleError`.* |
| `/compress`, `AutoCompress.compress` (from `onEntityPickupItem`, `onInventoryClose`) | 🟡 wrong refusal | **The space check runs before the shards it will convert are removed, so it ignores the slots they free.** `Compress.kt:131-147` (diamonds) and `149-165` (blocks) check before the removals at `169-183` and `198-212`; `AutoCompress.kt:65-99` repeats the same check before `101-157`. Repro: 35 slots of anything, 18 Diamond Shards in one slot, occupied offhand: "You do not have enough space" although the freed slot fits 2 diamonds; with auto-compress on, the message repeats on every item pickup and container close. *Fix: in both places compute free space after applying the pending removals to a copy, or run the `addItem` calls first and abort on leftover; share the routine.* |
| `/deposit all`, `/withdraw all` | 🟡 wrong audit value | **Both log `-1`, the `all` sentinel, as the transferred amount.** `Deposit.kt:148` passes `shards` (`50`); `Withdraw.kt:171` passes `shards` (`53`). Repro: `/deposit all` with 5 diamonds: the audit row reads `-1`. *Fix: pass `removedInShards` at `Deposit.kt:148` and `shardsToWithdraw` at `Withdraw.kt:171`.* |
| `/balancetop <page>`, `/baltop <page>` | 🟡 no reply or wrong page | **Unchecked page bounds and Int offset arithmetic cause exceptions or nonsensical negative-page replies.** `Balancetop.kt:58-59` parses the page and computes `offset = 9 * (index - 1)` in `Int`. Page 0, ordinary negative pages, and positive pages whose offsets wrap negative, such as 238609296, reach `require(offset >= 0)` (`CachingBalanceManager.kt:180`) through `Balancetop.kt:113-114`, throw `IllegalArgumentException` and kill the coroutine without a reply. Other negative pages wrap to positive offsets: -238609295 produces 2147483632, passes the offset check and the upper-page check (`Balancetop.kt:133`), and displays a negative page number. Repro: `/baltop 0`: no reply, a stack trace in the console. *Fix: at `Balancetop.kt:58-59` reject `index < 1` with the existing page message, compute the offset as `Long`, and refuse pages beyond the last before querying.* |
| `/pay` | 🟡 documented `all` refused | **`README.md:19` promises `/pay <player> all`; `Pay.kt:69` parses with `toFloat()` and answers "Invalid argument".** Repro: `/pay Bob all`. *Fix: accept `all` at `69` (pay the full bank balance) or drop it from the README.* |
## 4. Event listeners and automatic features
| Entry point | Severity | Note |
|---|---|---|
| `AutoDeposit.deposit` via `onEntityPickupItem` | 🟠 item loss on database error | **The picked-up stack is deleted before the credit, so a failed credit loses it.** `AutoDeposit.kt:39-40` empties the item entity on the main thread (the entity is discarded on its next tick, `ItemEntity.java:130-131`), and `42-53` credit the bank later on a coroutine; when `addToBankShards` fails (`AutoDeposit.kt:44`), `handleError` disables the economy and the player is told to notify staff, but the stack is never given back. Repro: auto-deposit on, stop PostgreSQL, walk over 64 diamonds: the economy disables and the 64 diamonds are gone. *Fix: keep a copy of the stack before `39` and, in the `getOrElse` at `44-52`, drop it at the player through `runOnMainThread`.* |
| `onEntityPickupItem`, `onPlayerDropItem`, `onInventoryClose` | 🟠 interactions cancelled, commands refused, per-pickup load | **The currency test now guards only the disabled-economy branch, so every pickup or drop of any item runs the auto features, a main-thread Redis read and a locked recount, and with auto-compress on every pickup locks the whole inventory.** `Events.kt:154-173` and `205-224` test the item type only while `economyDisabled` is set; otherwise `Events.kt:180-197` and `231-239` run for cobblestone as for diamonds, including the synchronous Redis read at `Events.kt:180` (a new connection per call, see the Redis row). Each recount holds the transaction lock across a main-thread hop and a database write (`Events.kt:184-197`, `231-239`, `381-400`), and `/deposit`, `/withdraw`, `/pay` and `/compress` take that lock with `tryWithLockSuspend` (`Deposit.kt:78`, `Withdraw.kt:81`, `Pay.kt:92`, `Compress.kt:50`), so a player who is picking up items is told "You are currently blocked from using /deposit." (`Deposit.kt:155`, `Withdraw.kt:178`, `Pay.kt:198`, `Compress.kt:260`). With auto-compress on, each pickup (and each container close, `Events.kt:376-379`) runs `AutoCompress.compress`, which takes both locks and waits a tick for the snapshot even when nothing converts (`AutoCompress.kt:37-39`, `159`); while the inventory lock is held, `Events.kt:175`, `226`, `271`, `314`, `355` cancel every pickup, drop, click and block or entity interaction, and a cancelled `LEFT_CLICK_BLOCK` makes the server resend the block (`ServerPlayerGameMode.java:198-209`), so the dig is lost. A player with auto-deposit or auto-compress on who lacks `diamondbank-og.deposit` or `diamondbank-og.compress` is messaged on every pickup (`AutoDeposit.kt:22-25` before the type check at `28-37`; `AutoCompress.kt:30-34`). Repro: `/autocompress` on, mine a row of stone with an Efficiency V pickaxe: blocks whose break starts while the previous drop is being picked up reappear. *Fix: repeat the four-type currency test after the lock checks at `Events.kt:178` and `229` and return for any other item; in `AutoCompress.compress` decide on the main thread, from the live inventory, whether anything can be compressed, and take no lock when nothing can.* |
| `onPlayerJoin` | 🟠 legacy balance lost, false message, migration rounding | **Migration runs only for a player without a database row, so a legacy balance is skipped for good once anything creates that row, every new player is told a balance was migrated, and a fractional legacy balance is truncated.** (a) `Events.kt:56` gates the migration on `!hasEntry`. Paying an offline legacy player passes `hasPlayedBefore()` (`Pay.kt:58`) and the transfer's upsert inserts the receiver row (`PostgreSQL.kt:136-137` via `transferBankShards`), as does an API credit; at that player's next join `hasEntry` is true and `diamondbank:balance` is never read. (b) The key removal and the message at `Events.kt:70-75` sit outside `if (legacyBalance != null)` (`62-69`), so every first join prints "Your old balance has been successfully migrated". (c) The legacy value is a `DOUBLE` (`Events.kt:57-60`) converted with `legacyBalance.toLong() * 9` (`64`), so `10.7` becomes 90 shards, not 97 (`CommonOperations.kt:48-53`). Repro: for a player whose data still holds `diamondbank:balance` and who has not joined since the migration shipped, `/pay <them> 0.1`; after they join their bank holds 1 shard. *Fix: at `56` gate on the presence of the legacy key instead of `hasEntry`, credit it with `addToBankShards` instead of `setPlayerShards`, convert at `64` with `diamondsToShards`, and move the removal and the message inside the `legacyBalance != null` branch.* |
## 5. Startup, shutdown, reload, configuration, and persistence setup
| Entry point | Severity | Note |
|---|---|---|
| `DiamondBankReload` (`/diamondbankreload`) and the executors it leaves behind (`/deposit`, `/withdraw`, `/pay`, `/compress`, `/balance`, `/bal`, `/balancetop`, `/baltop`, `/setbankbalance`, `/setbankbal`) | 🟠 stale balances after reload, pool leak, partial reload | **Reload leaves command executors bound to stale state, leaks the previous pool, and keeps the old Redis configuration.** (a) Seven command executors, behind ten command labels, keep the manager and config that existed at enable, while events, auto features and both APIs move to the new manager. `Deposit.kt:22-23`, `Withdraw.kt:25-26`, `Pay.kt:23-24`, `Compress.kt:33-34`, `Balance.kt:18-19`, `Balancetop.kt:20-21`, `SetBankBalance.kt:20-21` take `DiamondBankOG.config` and `DiamondBankOG.balanceManager` as constructor defaults, evaluated once by `setUpCommands` (`DiamondBankOG.kt:103`, `124-134`). The reload swaps the companion (`DiamondBankReload.kt:23`, `35`) without calling `setUpCommands` or `shutdown()`, so the two caches never see each other's writes and the old pool lives until restart, one more per run. Repro: a player online since before the reload has 100 bank shards; another plugin debits 90 through `J.subtractFromPlayerBankShards`; the player runs `/balance`: it still shows 100, and `/withdraw 5` then passes the stale check at `Withdraw.kt:106` (see the `/withdraw`, `/pay` row). (b) Redis is never rebuilt. `redis` is created once (`DiamondBankOG.kt:86`) and reads the config only at construction (`Redis.kt:9-15`); after editing `config.yml:14-17` the command still says "Successfully reloaded the config." (`DiamondBankReload.kt:47`). *Fix: after the swap at `DiamondBankReload.kt:35-45`, call `plugin.setUpCommands()` so the executors bind the new manager and config; shut the previous manager down only after the transactions running against it finish; rebuild `redis` (shutting down the old client) in the same command.* |
| `Redis.getValue`, `Redis.setValue` via `onEntityPickupItem`, `onInventoryClose`, `/autocompress`, `/autodeposit` | 🟠 main-thread stall per pickup, exception on outage, half-applied toggle | **Every call opens a new connection and catches nothing, the pickup handler makes that call on the main thread, and the toggle commands write their two keys separately.** `Redis.kt:28-33` and `36-41` connect, handshake, `SELECT`, run one command and close, on every call. Main-thread callers: `Events.kt:180` (each pickup that reaches the auto-deposit check), `command/AutoCompress.kt:33-47`, `command/AutoDeposit.kt:33-47`. Async callers: `Events.kt:185`, `377`. When Redis is down, `RedisConnectionException` escapes the pickup handler (the pickup completes and the recount at `184-197` is skipped), kills the `onInventoryClose` recount coroutine (`376-401`), and reaches the two commands as "An internal error occurred"; a failure between the two `setValue` calls (`:45`, `:47` in both commands) clears one toggle without setting the other. When the host is unreachable, each pickup costs a 10 s connect timeout on the main thread (`SocketOptions.java:37`). A throw after `connect()` leaks the connection. Repro: drop routing to the Redis host, then pick up a diamond: the server stalls 10 s. *Fix: use a managed long-lived connection and return explicit command failures separately from missing keys; stop and report a failed toggle at `command/AutoCompress.kt:33-48` and `command/AutoDeposit.kt:33-48` instead of continuing to a success message; serve the read at `Events.kt:180` from a cache or off the main thread; move both toggle commands' Redis work into `scope.launch` and write the mutually exclusive key pair in one `MSET`.* |
| Recipes across `/minecraft:reload`, and the craft guard | 🟠 shard recipes lost, currency crafted away | **Recipes are registered only in `onEnable`, so a datapack reload removes them until restart, and the craft guard misses prismarine bricks.** `Shard.kt:33`, `39` add both recipes once (`DiamondBankOG.kt:105`); `/minecraft:reload` installs a fresh `RecipeManager` (`MinecraftServer.java:2086`) without the plugin recipes and nothing re-registers them, so diamonds no longer split into shards and 9 Diamond Shards in a 3x3 craft vanilla prismarine bricks, which `onPrepareItemCraft` does not block (`Events.kt:408-410` list only prismarine, dark prismarine, sea lantern). The bricks recipe also wins without a reload whenever one shard in the grid was renamed, because the plugin recipe's exact ingredient compares full NBT (`Ingredient.java:85-86`). Repro: `/minecraft:reload`, then 9 Diamond Shards in a crafting table: prismarine bricks. *Fix: re-register both recipes on `ServerResourcesReloadedEvent`; add `PRISMARINE_BRICKS` to the list at `Events.kt:408-410`.* |
| `onEnable` and `DiamondBankReload` configuration loading (`YamlConfig.create`, `PostgreSQL.create`) | 🟠 plugin disabled with valid configuration | **Configuration loading omits upgrade defaults and environment expansion, rejects numeric credentials, and refuses pre-Flyway databases.** (a) `saveDefaultConfig` (`YamlConfig.kt:23`) leaves existing files untouched (`JavaPlugin.java:194-197`), and `YamlConfig.kt:25` loads without defaults. A deployed config predating `postgresMaxActiveConnections` lacks that key; `parseKeyAs<Int>` returns null (`YamlConfig.kt:71-76`), the guard at `47` rejects it, and `onEnable` disables the plugin (`DiamondBankOG.kt:70-75`). (b) `YamlConfig.kt:25`, `72` never resolves the `^{ENV}` placeholders at `config.yml:9-10`, `17`. Bukkit reads and constructs the scalars unchanged (`FileConfiguration.java:123-128`, `152-160`, `YamlConfiguration.java:149-164`, `YamlConstructor.java:25-27`), so even defined environment variables reach PostgreSQL and Redis as literal placeholder credentials (`PostgreSQL.kt:37-38`, `49`, `Redis.kt:12`), causing authentication failure and plugin disable. Numeric literal credentials are also rejected by `as? String` (`YamlConfig.kt:72`); an all-digit password parsed as an Integer becomes null and the connection is attempted without it. (c) `PostgreSQL.kt:40-41` configures Flyway without a baseline; a database created by a pre-Flyway build has the existing `diamond` and `diamond_log` tables but no schema history, so migration fails and `PostgreSQL.create` returns null (`53-59`). Repro: start with the bundled placeholder credentials and the corresponding environment variables set: the literal placeholder is used as the PostgreSQL user. *Fix: attach the bundled YAML as defaults after `YamlConfig.kt:25`, use typed scalar getters that stringify credentials, and resolve `^{ENV}` in parsed string values before constructing `YamlConfig`; report missing variables explicitly; at `PostgreSQL.kt:40` baseline at version 1 only after validating the known existing schema and confirming no history table exists.* |
| `plugin.yml` | 🟡 wrong help text | **The debug command help names a nonexistent command.** `plugin.yml:56` gives `/dbtoggledebug` as the usage for `dbogtoggledebug`, so `/help dbogtoggledebug` shows the wrong command. Repro: `/help dbogtoggledebug`. *Fix: write `/dbogtoggledebug` at `56`.* |
## 6. Shared internals
| Component | Severity | Note |
|---|---|---|
| `TransactionLock.withLockSuspend` | 🔴 economy freeze, shutdown hang | Reached from: every K balance method except `getBaltop`, every J balance method except `getBaltop` when called from a `Dispatchers.Default` thread (including J calls made inside a `PlayerBalanceChangedListener`, `EventManager.kt:23-27`), `/deposit`, `/withdraw`, `/pay`, `/compress` (holders through `tryWithLockSuspend`), `onPlayerJoin`, `onPlayerQuit`, `onPlayerRespawn`, `onEntityPickupItem`, `onPlayerDropItem`, `onInventoryClose`, `AutoCompress.compress`, `AutoDeposit.deposit`, `onDisable`. **Waiting parks a `Dispatchers.Default` thread, so enough waiters starve the holder and nothing in the scope ever runs again.** `TransactionLock.kt:26-34` calls the blocking `Semaphore.acquire()` inside a suspend function; the scope is `Dispatchers.Default` (`DiamondBankOG.kt:36`), capped at `max(2, nCPU)` workers (`Tasks.kt:29-33`), and a parked thread counts as busy. Once `nCPU` waiters for one UUID are parked while the holder is suspended (next-tick `runOnMainThread` or a database await), the holder never resumes and never releases: every command, recount, K call and the jasync pool actor (also on `Dispatchers.Default`, `ActorBasedObjectPool.kt:147-148`) hang; `economyDisabled` stays false; when the stuck holder owns the inventory lock, the guards at `Events.kt:175`, `226`, `271`, `314`, `355` cancel every pickup, drop, click and interaction of that player until restart; and auto-deposit keeps emptying picked-up stacks on the main thread (`AutoDeposit.kt:39-40`) while their credits wait forever (`AutoDeposit.kt:43`). The pickup and drop handlers launch a waiter for every item of any type (`Events.kt:184-197`, `231-239`), two per pickup with auto-compress on (`Events.kt:186`, `AutoCompress.kt:24`, `37`). `/stop` then hangs: `onDisable` joins the scope (`DiamondBankOG.kt:150`), a waiter blocked in `acquire()` ignores cancellation, the watchdog is already stopped (`MinecraftServer.java:931`), and players are never saved (`:971`); the join hangs even with a single waiter when the holder is an outside K or J call suspended in `runOnMainThread`, because the main thread is inside `onDisable`. Repro on 8 cores: break a chest holding 27 different stacks and stand in the drops so that nine are picked up in one tick; afterwards no economy command replies and `/stop` never finishes. *Fix: replace `java.util.concurrent.Semaphore` with `kotlinx.coroutines.sync.Semaphore` or `Mutex` at `26-34` and `49-60` so waiting suspends and honors cancellation; J's `runBlocking` keeps its blocking behavior on top.* |
| `InventorySnapshot.restoreTo` and the lock guards in `Events.kt` | 🔴 item dup and loss | Reached from: `K`/`J` `consumeFromPlayer`, `K`/`J` `playerPayPlayer`, `/deposit`, `/withdraw`, `/pay`, `/compress`, `AutoCompress.compress` (from every item pickup and container close). **The restore overwrites all 41 live slots from a snapshot that can span ticks, while incomplete guards allow intervening item changes.** `InventorySnapshot.kt:81` (`player.inventory.contents = newContents`) never compares the live inventory with what `from` copied. Commands and auto features take and restore snapshots across main-thread hops (`MainThreadBlock.kt:14-26`); bank-backed operations restore after their bank write, while compression rewrites without one (`Compress.kt:242`, `AutoCompress.kt:159`). The guards cancel pickups, drops, clicks and block and entity interactions while locked (`Events.kt:175`, `226`, `271`, `314`, `355`), but: `onPlayerInteract` returns at `283` before the lock check when no block was clicked, so a right-click at air still throws ender pearls, snowballs, splash potions and experience bottles (`ServerGamePacketListenerImpl.java:2098-2101`); armor stand swaps fire `PlayerInteractAtEntityEvent`, which keeps its own handler list (`PlayerInteractAtEntityEvent.java:21`, `ServerGamePacketListenerImpl.java:2955-2959`); a recipe book click moves ingredients into the crafting grid and fires only `PlayerRecipeBookClickEvent` (`ServerGamePacketListenerImpl.java:3397-3401`); releasing a bow or trident, finishing food or a potion, a totem pop and dying are not cancelled, and a death clears the same `ServerPlayer` that the restore then refills and respawn reuses (`ServerPlayer.java:992-998`, `PlayerList.java:815`); every handler returns before its lock check outside the three vanilla worlds (`Events.kt:152`, `203`, `246`, `281`, `324`). Each leaves the item both in the world or the grid and back in its slot. `InventoryDragEvent` is unguarded, so a stack already on the cursor dragged into the inventory is overwritten; arrow and trident pickups fire only `PlayerPickupArrowEvent`, which extends `PlayerPickupItemEvent` rather than `EntityPickupItemEvent` (`AbstractArrow.java:565-580`), so the guard at `Events.kt:175` never sees them and the restore deletes the picked-up item; items another plugin adds in the window are deleted the same way. For offline `consumeFromPlayer` the restore writes the stored profile through `UtilitiesOG.setInventoryData` (`InventorySnapshot.kt:78-79`), which throws when TrueOG Purpur rejects the RocksDB write (`PlayerDataStorage.java:181-192`): the bank debit and any excess-change credit stay committed, the profile keeps every removed item, and the audit row (`DiamondBankAPIKotlin.kt:195`, `DiamondBankAPIJava.kt:252`) is skipped. With `players.disable-saving` enabled (`SpigotConfig.java:406-408`), the same offline restore silently writes nothing (`PlayerDataStorage.java:183`), although Utilities-OG returns true (`UtilitiesOG.java:627-628`); consuming from a previously saved offline inventory can therefore report success without removing its currency. Repro: spam `/deposit 0.1` while a repeating command block runs `/kill <player>`: the death pile and the restored inventory both exist. *Fix: replace the unconditional write at `InventorySnapshot.kt:78-82` with live-slot reconciliation and apply only changed slots on the main thread; for bank-backed operations apply the item change before the bank write and safely return the removed items if that write fails, preserving intervening slot changes; refuse offline rewriting when playerdata saving is disabled, write the profile before the bank transaction, and confirm the persisted inventory instead of trusting the helper boolean; skip unchanged snapshots.* |
| `CommonOperations.getPlayerUsingUuidOrName` and its callers' existence check | 🔴 wrong recipient, new players unpayable, main-thread stall | Reached from: `/pay`, `/balance <player>`, `/bal <player>`, `/balancetop <player>`, `/baltop <player>`, `/setbankbalance`, `/setbankbal`. **Name resolution prefix-matches online players, then treats a first-session player as nonexistent, and `/pay` runs it on the main thread.** (a) `CommonOperations.kt:68` calls `Bukkit.getPlayer(String)`, which returns the online player whose name merely starts with the argument (`CraftServer.java:619-641`). Repro: Bob offline, Bobby online, `/pay Bob 10` pays Bobby; `/setbankbal Bob 0` zeroes Bobby. (b) Each caller then uses `hasPlayedBefore()` as the existence test (`Pay.kt:58`, `Balance.kt:66`, `Balancetop.kt:62`, `SetBankBalance.kt:61`); for an online player that is `CraftPlayer.hasPlayedBefore`, a field set only by `readExtraData` when saved data existed at login (`CraftPlayer.java:2110-2111`, `ServerPlayer.java:497`), so a player in their first session gets "That player doesn't exist" from all four commands until they relog. (c) `Pay.kt:52` resolves before `scope.launch` (`90`); on a user-cache miss `Bukkit.getOfflinePlayer(String)` performs a synchronous Mojang profile lookup (`CraftServer.java:1914-1940`), stalling the server for an unknown name. *Fix: use `Bukkit.getPlayerExact(value)` at `68` before the `getOfflinePlayer` fallback; in the four callers accept the target when it `is Player` and apply `hasPlayedBefore()` only to the `OfflinePlayer` fallback; move `Pay.kt:52` into the `scope.launch` block.* |
| `PostgreSQL.internalAddToPlayerShards` | 🟠 half-transfer loss | Reached from: `/pay`, `K`/`J` `playerPayPlayer` (via `transferBankShards`). **A nested `inTransaction` on the caller's connection commits the outer transaction early.** When the guarded upsert (`PostgreSQL.kt:136-137`) returns no row, `:143` calls `internalGetShardTypeShards(uuid, type, conn)`, which runs `conn.inTransaction` (`:290`); jasync sends `BEGIN`, the query and `COMMIT` on that connection (`ConcreteConnectionBase.kt:22-31`). PostgreSQL 16 warns "there is already a transaction in progress", commits the outer work, and answers the outer `ROLLBACK` with "there is no transaction in progress". In `transferBankShards` the writes are ordered by UUID (`PostgreSQL.kt:191`) and only the sender is pre-checked (`CachingBalanceManager.kt:100-106`), so a receiver with a negative bank fails the guard after the sender's debit is committed: the sender loses the amount, the receiver gets nothing, the sender's cache keeps the old balance (`CachingBalanceManager.kt:113` runs only on success), `/pay` disables the economy (`Pay.kt:142`), and `K.playerPayPlayer` returns `InsufficientBalanceException`, which reads as "nothing moved". A negative bank can be set through `/setbankbalance` (`SetBankBalance.kt:68`, `74`). Repro: `/setbankbal Bob -900`, then `/pay Bob 1` from a sender whose UUID sorts first: the sender's bank drops by 9 and Bob's stays at -900. *Fix: at `143` run the balance `SELECT` on `conn` with `sendPreparedStatement` instead of a nested `inTransaction`, so the guard failure rolls back inside the outer transaction; optionally pre-check the receiver in `CachingBalanceManager.transferBankShards`.* |
| `PostgreSQL.getBaltopWithUuid` | 🟠 dead feature | Reached from: `/balancetop <player>`, `/baltop <player>`. **The query is a syntax error.** `PostgreSQL.kt:356-361`: the `target AS (...)` CTE is closed at `357`, `paged AS` follows at `358` with no comma and no opening parenthesis, and a stray `)` remains; PostgreSQL 16 answers `syntax error at or near "paged"`. Every call returns `DatabaseException`, and the command replies "Something went wrong while trying to get the information for balancetop." (`Balancetop.kt:75-80`). Repro: `/baltop Steve` for any player who has joined. *Fix: write `target AS (...), paged AS (...)` at `357-358`.* |
| `InventoryExtensions.removeItemWithSameKey` on an `InventorySnapshot` | 🟠 transactions fail with offhand shards | Reached from: `/deposit all`, `/deposit <amount>`, `/pay`, `/compress`, `AutoCompress.compress`, `K`/`J` `consumeFromPlayer`, `K`/`J` `playerPayPlayer`. **The shard remover finds the slot in the 41-slot `getContents()` layout but clears it through the snapshot's 37-slot array, so emptying an offhand shard stack throws.** `InventoryExtensions.kt:44`, `51` and `67-71` read `contents`, which on `InventorySnapshot` is `getContents()` with armor at 36 to 39 and the offhand at 40 (`InventorySnapshot.kt:259-261`); `InventoryExtensions.kt:57-58` then calls `setItem(firstSlot, null)`, which writes the snapshot's own 37-slot array with the offhand at 36 (`InventorySnapshot.kt:51`, `128`). Emptying the offhand stack throws `ArrayIndexOutOfBoundsException: Index 40 out of bounds for length 37`: the coroutine dies before any bank write or restore, the player gets no reply, K throws instead of returning a `Result`, and J throws an undeclared exception. `/deposit all` always empties it (`InventorySnapshotUtils.kt:105-107`; the count includes the offhand, `PlayerInventoryExtensions.kt:10`); `/deposit <amount>`, `/pay`, the API, `/compress` and auto-compress do when the part they take from the offhand empties that stack (`InventorySnapshotUtils.kt:29`, `Compress.kt:171`, `AutoCompress.kt:103`). Repro: hold 5 Diamond Shards in the offhand, `/deposit all`: no reply, nothing deposited, a stack trace in the console. *Fix: read `storageContents` instead of `contents` at `InventoryExtensions.kt:51` and `68`; on `InventorySnapshot` that is the same 37-slot array `setItem` writes (`InventorySnapshot.kt:267`), and on a shulker inventory it is all 27 slots.* |
| `InventorySnapshotUtils.removeShards` | 🟠 excess dup on failure | Reached from: `K`/`J` `consumeFromPlayer`, `K`/`J` `playerPayPlayer`, `/pay`, `/deposit <amount>`. **Excess change is credited in its own transaction before the operation's bank write.** `InventorySnapshotUtils.kt:41`, `67` and `74` call `addToBankShards` for change that does not fit in the snapshot. If the later write fails, `restoreTo` is skipped, so the inventory keeps every item while up to 8 shards (80 after a broken diamond block) stay credited, unlogged. The later write is `Deposit.kt:121`, the transfer at `Pay.kt:134-149`, or the debit or transfer at `DiamondBankAPIKotlin.kt:182-186`, `247-252` (J at `DiamondBankAPIJava.kt:243-246`, `306-310`); it fails through a negative receiver, a stale cache after reload, or a database error. Repro: `/setbankbal Bob -900`; Alice has an empty bank and a full inventory with 2 diamonds in one slot; `/pay Bob 0.1`: Alice keeps both diamonds and her bank gains 8 shards. *Fix: return the excess together with the removed count, and let the caller's single bank write carry it.* |
| Submitted bank writes and `runOnMainThread { restoreTo }` completion | 🟠 committed money without cache or inventory completion | Reached from: caller cancellation of K `addToPlayerBankShards`, `subtractFromPlayerBankShards`, `consumeFromPlayer`, `playerPayPlayer`; interruption of the same J calls; `onDisable` during those API calls, `/deposit`, `/withdraw`, `/pay`, `/setbankbalance`, `/setbankbal`, `onPlayerJoin` migration and `AutoDeposit.deposit`. **Cancellation or shutdown can abandon cache and inventory completion after a submitted bank write still commits.** `PostgreSQL.kt:73-94`, `109-110` and `169-195` await transactions whose completion jasync owns; addition and transfer bodies also use DiamondBank's `scope.future`. Cancelling the outer awaited future does not cancel the independent inner completion chain (`AsyncObjectPool.kt:66-84`, `ConcreteConnectionBase.kt:23-34`, coroutines `future/Future.kt:168-173`). Once submitted, a successful write can commit while the caller receives failure and skips cache updates (`CachingBalanceManager.kt:50-53`, `72-76`, `108-113`). Inventory-backed commands then have a further cancellable hop (`Deposit.kt:131`, `Withdraw.kt:154`, `Pay.kt:151`), as do K/J consume and pay when called off the main thread (`DiamondBankAPIKotlin.kt:188`, `255`, `DiamondBankAPIJava.kt:249`, `313`, `MainThreadBlock.kt:11-26`). (a) `onDisable` cancels and joins the plugin scope (`DiamondBankOG.kt:148-150`); pending command restores are abandoned, and Paper cancels queued tasks after that (`PaperPluginInstanceManager.java:244`). External K/J calls are outside that scope, so a dropped scheduled task can leave them suspended or blocked holding their locks. Plugins disable before players are saved (`MinecraftServer.java:960`, `971`), so a committed deposit can leave both bank credit and the original items, while a withdrawal can debit the bank without adding items. Auto-deposit has already emptied the entity before launching its credit (`AutoDeposit.kt:39-44`), which cancellation before submission can also abandon. (b) Caller cancellation at a queued restore releases the inventory lock before the task runs; its later lock check fails (`InventorySnapshot.kt:67-69`), and the audit is skipped (`DiamondBankAPIKotlin.kt:195`, `263`, `DiamondBankAPIJava.kt:252`, `317`). Repro: run `/withdraw 10` and stop the server while its committed debit is awaiting the main-thread restore: after restart the bank is lower without the diamonds. *Fix: track in-flight plugin and API transactions, stop accepting new work at shutdown, and protect each accepted write through database outcome, cache, inventory reconciliation and audit with `withContext(NonCancellable)` beginning before submission; include auto-deposit's entity removal and credit in the tracked operation; drain pending main-thread work from a plugin-owned queue in `DiamondBankOG.kt:148-150` until those transactions finish, then close persistence and cancel idle work.* |
| Material-based currency counts versus meta-sensitive removal | 🟡 valid currency refused | Reached from: `/compress`, `/compress yes`, `AutoCompress.compress` (every item pickup and container close), `/deposit <amount>`, `/pay`, `K`/`J` `consumeFromPlayer`, `K`/`J` `playerPayPlayer`. **Currency counts include renamed Diamonds and Diamond Blocks that the removal rule refuses when they are needed for the conversion or payment.** `PlayerInventoryExtensions.kt:26`, `31` count by material, while `InventorySnapshot.kt:308-309` matches only stacks similar to the plain `ItemStack` passed to `removeItem`. If ordinary stacks cannot cover the requested removal, compression aborts (`Compress.kt:200-207`, `AutoCompress.kt:125-133`) and payment removal returns too little (`InventorySnapshotUtils.kt:36`, `62`) despite the higher count. `/compress yes` selects the shulker inventory (`Compress.kt:85-88`, `103-109`), whose counters also ignore metadata (`InventoryExtensions.kt:26-32`), while Bukkit removal uses similarity (`CraftInventory.java:232-242`, `359-383`, `ItemStack.java:302-310`). A renamed surplus item does not prevent a conversion covered by plain stacks. No value moves. Repro: rename one Diamond, hold nine Diamonds including it, and run `/compress`: the command reports "Something went wrong"; the same nine items in a held undyed shulker fail `/compress yes`. *Fix: share a material-based remover across snapshots and shulker inventories at `InventorySnapshot.kt:308-309` and `Compress.kt:200`, or make both counter implementations count only stacks accepted by the corresponding removal rule.* |
| `PostgreSQL.insertTransactionLog` and `fifo_limit_trigger` | 🟡 audit exceeds 500 rows | Reached from: K/J `addToPlayerBankShards`, `subtractFromPlayerBankShards`, `consumeFromPlayer`, `playerPayPlayer`, `/deposit`, `/withdraw`, `/pay`, `/setbankbalance`, `/setbankbal`. **Concurrent audit inserts can commit more than the guaranteed 500 rows because each trim sees only its own transaction and already committed rows.** `PostgreSQL.kt:416-424` submits each log INSERT in a separate transaction with no shared serialization; `V1__init.sql:17-22` trims using the visible row at offset 500. With 499 committed rows, two concurrent inserts each see 500 and delete nothing, leaving 501 after both commit; the configured pool permits ten concurrent connections (`config.yml:11`, `PostgreSQL.kt:50`). Repro: seed 499 audit rows, start two transactions that each insert a log row before either commits, then commit both: PostgreSQL 16 retains 501. *Fix: at `PostgreSQL.kt:417-419` acquire one common transaction-scoped advisory lock in a separate statement before INSERT, then execute INSERT and trimming in that transaction, so each INSERT starts with a snapshot taken after the previous log transaction commits.* |
| `EventManager.queueFor` | 🟡 events lost for a player | Reached from: `K`/`J` `registerEventListener`, and every balance change that emits an event (`PostgreSQL.kt:95`, `112`, `198`). **A listener that throws an `Error` ends that player's consumer coroutine, so no listener receives another event for that player until restart.** `EventManager.kt:28` catches only `Exception`; an `Error` (for example `NoClassDefFoundError` from a listener whose plugin was unloaded, since listeners cannot be unregistered, `:41-43`) ends the `for` loop at `:24`, the `SupervisorJob` absorbs it, `queueFor` keeps returning the dead channel from `computeIfAbsent` (`:21`), and `trySend` (`:38`) keeps buffering into the unlimited channel. Repro: register a listener that calls `TODO()` once, change a player's balance twice: no listener sees the second event. *Fix: at `EventManager.kt:28` catch `Throwable` and rethrow `CancellationException`, or remove the queue from `queues` when its consumer completes.* |
| `CommonOperations.diamondsToShards` | 🟡 crash on NaN, wrong or refused amounts | Reached from: `K`/`J` `diamondsToShards` (`DiamondBankAPIKotlin.kt:276`, `279`; `DiamondBankAPIJava.kt:335`, `343`), `/deposit`, `/withdraw`, `/pay`. **Diamond conversion throws on non-finite input and misreads or refuses other representable amounts.** At `CommonOperations.kt:49-53`, conversion goes through `Float.toString()`; `NaN` and `Infinity` contain no `.`, so `split[1]` at `:50` throws; both pass the `amount <= 0` checks (`Deposit.kt:55`, `Withdraw.kt:58`, `Pay.kt:70`) and are converted before `scope.launch` (`Deposit.kt:66`, `Withdraw.kt:69`, `Pay.kt:81`), so `/deposit NaN` reaches the player as "An internal error occurred", and `K.diamondsToShards(NaN)` throws instead of returning a `Result`. Amounts of 1e7 and above format as `1.0E7` and are refused as "more than one decimal digit". From 1048576 up, Float spacing changes the tenth digit silently: `/pay Bob 1048576.3` pays 1048576.2. Negative fractions convert wrongly through the API: `-0.5` yields `+5` and `-1.5` yields `-4`. The Double overloads narrow to Float first (`DiamondBankAPIKotlin.kt:276`, `DiamondBankAPIJava.kt:335`). Repro: `/withdraw NaN`. *Fix: parse numerically at `49-53`: reject non-finite input, take the whole part and the tenth digit from the original string or by `BigDecimal`, apply the sign to both, and have the Double overloads parse the Double directly.* |
Verified against commit `3df19f636773d113bcb6b58d715d3e6c2df852d3`.
-121
View File
@@ -1,121 +0,0 @@
Use Cavekit 3.1 with Codex adversarial review, falling back to the Cavekit inspector agent when Codex is unavailable, to produce the follow-up defect chart for DiamondBank-OG after the maintainer's fix pass.
## Objective
`DEFECT-CHART.md` in the repository root describes 22 defects verified at commit `0f2c29acdec4160f3db0fb477edbe38115063e6d`. The maintainer has since committed fixes. Produce two files:
1. A new `DEFECT-CHART.md` containing only the defects that still exist at the current commit, plus any defect the fixes introduced, in the same format as the current file.
2. `DEFECT-CHART-DELTA.md`, one line per row of the old chart, stating `fixed`, `partially fixed`, `unfixed` or `regressed`, with the commit and lines that changed it.
Every claim in both files is settled by reading the source at the current commit. The old chart is a set of hypotheses, not facts: a row is carried forward only if its wrong outcome still reproduces from the current code, and a row is marked fixed only if the causing lines no longer produce it.
## Inputs
* `DEFECT-CHART.md` at the start of the run. Read it first. Do not edit it in place; write the new chart to a scratch file and replace it at the end.
* `git log 0f2c29a..HEAD` and `git diff 0f2c29a HEAD`. Every file the diff touches is in scope for a fresh audit, not only the lines the old chart named.
* Dependency source, read rather than recalled: the Purpur fork at `~/eclipse-workspace/Purpur` (`Purpur-Server/src/main/java`, `Purpur-API/src/main/java`; CraftBukkit under `org/bukkit/craftbukkit/`, Paper DataConverter under `ca/spottedleaf/dataconverter/`), `libs/Utilities-OG/src/main/java`, and the jasync 2.2.4, kotlinx-coroutines 1.10.2 and Lettuce 7.5.1 sources jars in `~/.gradle/caches/modules-2/files-2.1/` (unzip to the scratchpad). A `postgres:16-alpine` podman image is available for validating SQL; use it for any query the fixes changed.
## Scope
The six surfaces of the original audit, unchanged:
1. Public API offline-player compatibility (`api/DiamondBankAPIKotlin` as K, `api/DiamondBankAPIJava` as J)
2. Public API general correctness
3. Commands (every executor registered in `DiamondBankOG.onEnable`, `util/CommonCommandInterlude`)
4. Event listeners and automatic features (`Events.kt`, `autocompress/AutoCompress`, `autodeposit/AutoDeposit`)
5. Startup, shutdown, reload, configuration, persistence setup (`onEnable`, `onDisable`, `DiamondBankReload`, `config`, `config.yml`, `plugin.yml`, `Redis.kt`, `PostgreSQL.create`, `CachingBalanceManager.create` and `shutdown`, migrations, service registration)
6. Shared internals (everything the first five reach)
Out of scope: tests under `src/test` (read them for intent only), external API consumers, build scripts.
## Contracts
A finding may say the code violates a contract only when the contract comes from: `api/README.md`; KDoc and `@Throws` on public API methods; K versus J parity; the root `README.md` command usage lines and permission list; `plugin.yml` usage and description strings; `config.yml` keys, comments and the `^{ENV}` placeholder syntax; Bukkit, Paper and Purpur runtime guards as found in the dependency source, only where a violation produces a demonstrated wrong outcome; the Guarantees below.
## Maintainer Requirements
Guarantees are contracts. Intent items are never contracts and never remediation targets.
1. Guarantee. Any database error may disable the economy. Do not recommend changing that; distinguish a concrete code defect from the intended disable.
2. Intent. `TransactionLock` may block the calling thread in both APIs. The blocking design itself is not a defect.
3. Intent. Per-UUID cache, lock and channel state may remain allocated for offline UUIDs.
4. Guarantee. The audit log is limited to 500 rows, written after money movement, and its failure may disable the economy.
5. Guarantee. Database causes are hidden from API callers.
6. Intent. The absence of schema constraints is accepted unless current code demonstrably relies on a missing constraint and produces a live failure.
7. Guarantee. `getBaltop` uses a page size of nine.
8. Intent. `playerPayPlayer` is intended for an online payer; do not recommend an online check.
9. Guarantee. Offline inventory and ender-chest shard values are display-oriented and approximate; ordinary staleness is not a defect.
10. Guarantee. Integer overflow may disable the economy.
11. Guarantee. Event delivery is fire-and-forget; dropped shutdown events are not defects.
12. Guarantee. `subtractFromPlayerBankShards` subtracts only bank shards.
13. Intent. A funded database row without playerdata is not assumed realistic.
14. Guarantee. `economyDisabled` is the safety net; a money-moving or inventory-rewriting path that starts while the flag is set is reportable. A flag that flips after a path passed its entry check is not.
15. Intent. `CommonCommandInterlude` restricts player commands to `world`, `world_nether`, `world_the_end`.
16. Intent. Auto-compress and auto-deposit toggles live in Redis, are mutually exclusive, and a Redis failure at startup disables the plugin.
17. Intent. Player commands are refused for console and command-block senders.
18. Intent. Aliases share one executor.
19. Intent. Only the undyed `SHULKER_BOX` material is treated as a currency container, plugin-wide.
## Phase 1: Re-verify every old row
For each row of the old chart, in order:
1. Locate every causing line the row names in the current tree (the numbers will have moved; find the code, not the number).
2. Determine whether the fix commit changed it, and whether the wrong outcome still reproduces. Trace the whole path again; do not stop at the changed line.
3. Classify: `fixed` (the wrong outcome cannot occur), `partially fixed` (some listed findings or entry points remain), `unfixed` (untouched or the change does not remove the outcome), `regressed` (the change removes the outcome but creates a new wrong outcome, which becomes a new row).
4. For a fix, check it against the pitfalls the old chart already flagged, and against these known-bad fix shapes:
* `AutoDeposit`: cancelling the pickup event and removing the entity later re-fires `playerTouch` and credits again; refusing on the main thread in J contradicts its KDoc; a restore-time divergence check leaves the money moved; stamping the current `DataVersion` while writing only the `Inventory` list leaves every other field unconverted; a suspending semaphore in `TransactionLock` does not fix `runBlocking` on the main thread.
* A fix that replaces `require` with a `Result.failure` must not route that failure into `handleError`, or every bad argument disables the economy.
* A fix that moves work off the main thread must keep every Bukkit inventory, entity and scheduler call on the main thread; check each moved line against the async guards in the dependency source.
* A fix that adds a synchronous main-thread wait inside a coroutine reintroduces the dispatcher starvation the `TransactionLock` row describes.
* A fix that shuts down the previous balance manager on reload must not cut off transactions in flight against it.
## Phase 2: Fresh audit of changed code
For every file in `git diff 0f2c29a HEAD`, audit the changed functions and everything they call or are called by as if no chart existed, with the same finding discipline as the original audit: every finding names a wrong outcome (incorrect value, lost or duplicated or misdirected value or items, escaped exception, wrong state, blocked operation), the line that causes it, and a fix that changes that line. Pay particular attention to:
* New locks, queues, dispatchers or `runBlocking` calls: trace deadlock cycles between the main thread, `Dispatchers.Default` and jasync callbacks.
* New inventory write paths: which slots, on which thread, against which snapshot, and what the handlers block in the window.
* New SQL: run it against the migration schema in the podman image.
* New Redis usage: connection lifetime, exception handling, thread.
* Changed `plugin.yml`, `config.yml`, `YamlConfig`: required keys that an existing deployed config lacks (an upgrade must not disable the plugin), and dependency ordering.
* Any new recipe, event handler or listener registration: lifecycle across disable, re-enable and `/minecraft:reload`.
A defect the fixes introduced is a row in the new chart regardless of whether the old chart covered its area.
## Phase 3: Merge and self-check
Build the new chart: carried-forward rows rewritten from the current trace (current line numbers, current mechanism), plus new rows. Then check every row against the three questions: what is the wrong outcome, which line causes it, does the fix change that line. Merge rows whose fixes overlap, as the current chart does; a row that only restates another row's cause through a different entry point is deleted.
## Adversarial Review
Hand the merged draft to Codex:
```
codex exec --skip-git-repo-check --model gpt-5.6-sol -c model_reasoning_effort=xhigh -o <scratch>/codex-review.md "<review instructions followed by the full draft>"
```
Confirm the output header shows `model: gpt-5.6-sol` and `reasoning effort: xhigh` before accepting any findings. Give the reviewer the draft, the six surfaces, the Contracts, the Maintainer Requirements, the finding discipline, and the source locations; do not give it the old chart, the delta, or the fix pitfalls list. Ask it to falsify the draft in six buckets: wrong claims, missing entry points or defects (including regressions the fixes introduced), overstated triggers or impact, stale line references, derivative or invented findings, misplaced findings.
Codex is unavailable when `command -v codex` finds nothing, when `codex exec` exits with an authentication, network, quota or unknown-model error and one retry fails the same way, or when the header shows another model or effort after one rerun. A quota error names the next reset time; if that time is within the session, wait for it and retry once. Otherwise run the Cavekit 3 inspector (`ck:inspector`, opus) with the same inputs and record the exact Codex error. Never skip the review; if both reviewers are unavailable, stop and report that.
Verify every reviewer finding against the current source before accepting it. Record the counts of accepted and rejected findings.
## Output format
`DEFECT-CHART.md`:
* Title line.
* The severity key as a three-row table, verbatim from the current chart: 🔴 critical, 🟠 serious, 🟡 minor, with their meanings.
* Six sections in surface order, each a three-column table `Entry point | Severity | Note` (surface 6: `Component | Severity | Note`). Rows ordered by severity within a section. A section with no rows has the heading and no table.
* Each Note: a bold one-sentence statement of what is wrong; the mechanism with current `File.kt:line` references (write `command/AutoCompress.kt` and `command/AutoDeposit.kt` for the command classes, which share basenames with the feature classes); a one-line repro where one exists; then the fix in italics as `*Fix: ...*`. Surface 6 notes begin with `Reached from:` listing every entry point.
* Severity cell holds the glyph and a short plain-language impact tag (for example `🔴 item dup`, `🟠 dead feature`, `🟡 wrong audit value`). No verdict words, no priority codes.
* No header sentences listing what is correct, no methodology, no reviewer discussion, no mention of the previous chart inside rows. Close with one line: `Verified against commit <full hash>.`
* Concise prose, complete sentences, no em dashes or en dashes, no rows for intentional limitations or clean refusals.
`DEFECT-CHART-DELTA.md`:
* One line per old row: the old row's first cell, its status, and the commit hash plus the lines that changed it (or `no change`). For `partially fixed`, name what remains. For `regressed`, name the new row.
* One closing line: the current commit, the reviewer used (`gpt-5.6-sol` at `xhigh`, or `ck:inspector` with the exact Codex error), and the accepted and rejected reviewer finding counts.
Replace the old `DEFECT-CHART.md` only after both files pass the self-check and the review.
+670 -20
View File
@@ -1,24 +1,674 @@
This is free and unencumbered software released into the public domain.
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
Preamble
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
For more information, please refer to <https://unlicense.org>
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+8 -17
View File
@@ -1,13 +1,13 @@
> [!NOTE]
> An experimental branch that implements Shards is available [here](https://github.com/SKBotNL/DiamondBank-OG/tree/shards).
# DiamondBank-OG
A FOSS and reliable Diamond Economy plugin for Spigot inspired by [The Diamond Bank](https://www.spigotmc.org/resources/the-diamond-bank.72020/).\
Maintained for TrueOG Network.
**For developers: see the [API README](src/main/kotlin/net/trueog/diamondbankog/api/README.md).**
A free and open source Diamond Economy plugin for Spigot inspired by [The Diamond Bank](https://www.spigotmc.org/resources/the-diamond-bank.72020/).\
Maintained for TrueOG.
## PostgreSQL
This plugin uses PostgreSQL which needs to be set up first, the [PostgreSQL page](https://wiki.archlinux.org/title/PostgreSQL#Initial_configuration) on the Arch Linux wiki or the [readme of the Postgres Docker container](https://hub.docker.com/_/postgres) can be used to see how.\
The PostgreSQL URL, user and password that should be used are configurable in the config.
This plugin uses PostgreSQL which needs to be set up first. The Arch Linux wiki has a [pretty good guide](https://wiki.archlinux.org/title/PostgreSQL#Initial_configuration).\
The PostgreSQL URL, user, password and the table that should be used are configurable in the config.
## User Commands
@@ -20,8 +20,7 @@ The PostgreSQL URL, user and password that should be used are configurable in th
## Admin Commands
/setbankbalance \<player name or player uuid> \<balance>\
/dbogtoggledebug
/setbankbalance \<player name or player uuid> \<balance>
## Permissions
diamondbank-og.balance\
@@ -30,12 +29,4 @@ diamondbank-og.balancetop\
diamondbank-og.deposit\
diamondbank-og.withdraw\
diamondbank-og.pay\
diamondbank-og.compress\
diamondbank-og.setbankbalance\
diamondbank-og.admin\
diamondbank-og.debug
## License
This project is licensed under the terms of **The Unlicense** (public domain dedication). See `LICENSE`.
The Unlicense is used with permission of the original author.<sup>[1](https://github.com/andromdaa/UnityTemplate/issues/1#issuecomment-3342032195)</sup> <sup>[2](https://web.archive.org/web/20250927211153/https://github.com/andromdaa/UnityTemplate/issues/1#issuecomment-3342032195)</sup> <sup>[3](https://git.trueog.net/trueog/DiamondBank-OG/pulls/13#issuecomment-28)</sup> <sup>[4](https://web.archive.org/web/20260119131305/https://github.com/true-og/DiamondBank-OG/pull/13#issuecomment-3678843384)</sup>
diamondbank-og.setbankbalance
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/bash
# Fetch all submodule content.
git submodule update --force --recursive --init
+72 -114
View File
@@ -1,123 +1,81 @@
import java.io.BufferedReader
/* ------------------------------ Plugins ------------------------------ */
plugins {
id("java") // Import Java plugin.
id("java-library") // Import Java Library plugin.
id("com.diffplug.spotless") version "8.1.0" // Import Spotless plugin.
id("com.gradleup.shadow") version "8.3.9" // Import Shadow plugin.
eclipse // Import Eclipse plugin.
kotlin("jvm") version "2.1.21" // Import Kotlin JVM plugin.
id("org.jetbrains.kotlin.jvm") version "2.1.21"
id("com.gradleup.shadow") version "8.3.6"
id("maven-publish")
id("eclipse")
}
group = "net.trueog.diamondbankog"
version = "1.1"
val apiVersion = "1.19"
publishing {
publications {
create<MavenPublication>("mavenPublication") {
groupId = "net.trueog.diamondbankog"
artifactId = "DiamondBankOG"
version = version
}
}
}
tasks.named<ProcessResources>("processResources") {
val props = mapOf(
"version" to version,
"apiVersion" to apiVersion,
)
filesMatching("plugin.yml") {
expand(props)
}
}
repositories {
mavenCentral()
maven {
url = uri("https://repo.purpurmc.org/snapshots")
}
maven {
url = uri("https://jitpack.io")
}
}
dependencies {
compileOnly("org.purpurmc.purpur:purpur-api:1.19.4-R0.1-SNAPSHOT")
implementation("com.github.christianniehaus:Utilities-OG:e9ebc26c1f")
implementation("com.github.jasync-sql:jasync-postgresql:2.2.4")
implementation("io.sentry:sentry:8.8.0")
implementation("io.sentry:sentry-kotlin-extensions:8.9.0")
implementation("org.jetbrains.kotlin:kotlin-stdlib")
}
tasks.shadowJar {
minimize()
}
tasks.shadowJar.configure {
archiveClassifier.set("")
}
tasks.build {
dependsOn("shadowJar")
}
tasks.jar.configure {
archiveClassifier.set("part")
}
kotlin {
jvmToolchain(17)
}
/* ---------------------------- Java / Kotlin -------------------------- */
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
vendor = JvmVendorSpec.GRAAL_VM
}
}
kotlin { jvmToolchain(17) }
/* ----------------------------- Metadata ------------------------------ */
val commitHash =
Runtime.getRuntime().exec(arrayOf("git", "rev-parse", "--short=10", "HEAD")).let { process ->
process.waitFor()
val output = process.inputStream.use { it.bufferedReader().use(BufferedReader::readText) }
process.destroy()
output.trim()
}
group = "net.trueog.diamondbankog" // Declare bundle identifier.
val apiVersion = "1.19" // Declare plugin version (will be in .jar).
version = "$apiVersion-$commitHash" // Declare minecraft server target version.
/* ----------------------------- Resources ----------------------------- */
tasks.named<ProcessResources>("processResources") {
val props = mapOf("version" to version, "apiVersion" to apiVersion)
inputs.properties(props) // Indicates to rerun if version changes.
filesMatching("plugin.yml") { expand(props) }
from("LICENSE") { into("/") } // Bundle licenses into jarfiles.
}
/* ---------------------------- Repos ---------------------------------- */
repositories {
mavenCentral() // Import the Maven Central Maven Repository.
gradlePluginPortal() // Import the Gradle Plugin Portal Maven Repository.
maven { url = uri("https://repo.purpurmc.org/snapshots") } // Import the PurpurMC Maven Repository.
maven { url = uri("https://jitpack.io") } // Import the Jitpack Maven Repository.
}
/* ---------------------- Java project deps ---------------------------- */
dependencies {
compileOnly("org.purpurmc.purpur:purpur-api:1.19.4-R0.1-SNAPSHOT") // Declare Purpur API version to be packaged.
compileOnly("net.luckperms:api:5.5") // Import the LuckPerms API.
compileOnlyApi(project(":libs:Utilities-OG")) // Import TrueOG Network Utilities-OG Java API (from source).
implementation("io.lettuce:lettuce-core:7.5.1.RELEASE") // Import the Lettuce API for keydb.
implementation("org.flywaydb:flyway-core:12.3.0")
implementation("org.flywaydb:flyway-database-postgresql:12.3.0")
implementation("org.postgresql:postgresql:42.7.9")
implementation("com.github.jasync-sql:jasync-postgresql:2.2.4") // Import the jasync Postgres API.
implementation("it.unimi.dsi:fastutil-core:8.5.18")
implementation("org.jetbrains.kotlin:kotlin-stdlib") // Import the Kotlin standard library.
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("org.slf4j:slf4j-api:2.0.17") // Bundle a relocated SLF4J API for shaded dependencies.
implementation("org.slf4j:slf4j-nop:2.0.17") // Provide a relocated no-op SLF4J backend to avoid provider warnings.
testImplementation(kotlin("test"))
testImplementation("org.junit.jupiter:junit-jupiter-api:6.0.3")
testImplementation("org.junit.jupiter:junit-jupiter-params:6.0.3")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:6.0.3")
testRuntimeOnly("org.junit.platform:junit-platform-launcher:6.0.3")
testImplementation("io.mockk:mockk:1.14.9")
testImplementation("org.purpurmc.purpur:purpur-api:1.19.4-R0.1-SNAPSHOT")
testImplementation("net.luckperms:api:5.5")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
}
tasks.test { useJUnitPlatform() }
/* ---------------------- Reproducible jars ---------------------------- */
tasks.withType<AbstractArchiveTask>().configureEach { // Ensure reproducible .jars
isPreserveFileTimestamps = false
isReproducibleFileOrder = true
}
/* ----------------------------- Shadow -------------------------------- */
tasks.shadowJar {
archiveClassifier.set("") // Use empty string instead of null.
isEnableRelocation = true
relocationPrefix = "${project.group}.shadow"
mergeServiceFiles()
}
tasks.jar { archiveClassifier.set("part") } // Applies to root jarfile only.
tasks.build { dependsOn(tasks.spotlessApply, tasks.shadowJar) } // Build depends on spotless and shadow.
/* --------------------------- Javac opts ------------------------------- */
tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.add("-Xlint:deprecation") // Trigger deprecation warning messages.
options.encoding = "UTF-8" // Use UTF-8 file encoding.
}
/* ----------------------------- Auto Formatting ------------------------ */
spotless {
kotlin { ktfmt().kotlinlangStyle().configure { it.setMaxWidth(120) } }
kotlinGradle {
ktfmt().kotlinlangStyle().configure { it.setMaxWidth(120) } // JetBrains Kotlin formatting.
target("build.gradle.kts", "settings.gradle.kts") // Gradle files to format.
}
}
tasks.named("spotlessCheck") {
dependsOn("spotlessApply") // Run spotless before checking if spotless ran.
}
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+7
View File
@@ -0,0 +1,7 @@
jdk:
- 17.0.9
before_install:
- sdk install java 17.0.9-graalce
- sdk use java 17.0.9-graalce
install:
- ./gradlew build publishToMavenLocal
+3 -13
View File
@@ -1,15 +1,5 @@
plugins { id("org.gradle.toolchains.foojay-resolver-convention") version ("1.0.0") }
rootProject.name = "DiamondBank-OG"
ProcessBuilder("sh", "bootstrap.sh").directory(rootDir).inheritIO().start().let {
if (it.waitFor() != 0) throw GradleException("bootstrap.sh failed")
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version("0.8.0")
}
file("libs")
.listFiles()
?.filter { it.isDirectory && !it.name.startsWith(".") }
?.forEach { dir ->
include(":libs:${dir.name}")
project(":libs:${dir.name}").projectDir = dir
}
rootProject.name = "DiamondBank-OG"
@@ -0,0 +1,75 @@
package net.trueog.diamondbankog
import org.bukkit.configuration.file.YamlConfiguration
import java.io.File
import kotlin.properties.Delegates
object Config {
lateinit var prefix: String
var sentryEnabled by Delegates.notNull<Boolean>()
lateinit var sentryDsn: String
lateinit var postgresUrl: String
lateinit var postgresUser: String
lateinit var postgresPassword: String
lateinit var postgresTable: String
fun load(): Boolean {
val file = File(DiamondBankOG.plugin.dataFolder, "config.yml")
if (!file.exists()) {
DiamondBankOG.plugin.saveDefaultConfig()
}
val config = YamlConfiguration.loadConfiguration(file)
config.save(file)
try {
prefix = config.get("prefix") as String
} catch (_: Exception) {
DiamondBankOG.plugin.logger.severe("Failed to parse config option \"prefix\" as a string")
return true
}
try {
sentryEnabled = config.get("sentryEnabled") as Boolean
} catch (_: Exception) {
DiamondBankOG.plugin.logger.severe("Failed to parse config option \"sentryEnabled\" as a boolean")
return true
}
try {
sentryDsn = config.get("sentryDsn") as String
} catch (_: Exception) {
DiamondBankOG.plugin.logger.severe("Failed to parse config option \"sentryDsn\" as a string")
return true
}
try {
postgresUrl = config.get("postgresUrl") as String
} catch (_: Exception) {
DiamondBankOG.plugin.logger.severe("Failed to parse config option \"postgresUrl\" as a string")
return true
}
try {
postgresUser = config.get("postgresUser") as String
} catch (_: Exception) {
DiamondBankOG.plugin.logger.severe("Failed to parse config option \"postgresUser\" as a string")
return true
}
try {
postgresPassword = config.get("postgresPassword") as String
} catch (_: Exception) {
DiamondBankOG.plugin.logger.severe("Failed to parse config option \"postgresPassword\" as a string")
return true
}
try {
postgresTable = config.get("postgresTable") as String
} catch (_: Exception) {
DiamondBankOG.plugin.logger.severe("Failed to parse config option \"postgresTable\" as a string")
return true
}
return false
}
}
@@ -0,0 +1,91 @@
package net.trueog.diamondbankog
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.future.future
import org.bukkit.Bukkit
import java.util.*
import java.util.concurrent.CompletableFuture
@OptIn(DelicateCoroutinesApi::class)
class DiamondBankAPI(private var postgreSQL: PostgreSQL) {
@Suppress("unused")
fun addToPlayerBankBalance(uuid: UUID, amount: Int): CompletableFuture<Boolean?> {
if (DiamondBankOG.economyDisabled) return GlobalScope.future { null }
return GlobalScope.future { postgreSQL.addToPlayerBalance(uuid, amount, PostgreSQL.BalanceType.BANK_BALANCE) }
}
@Suppress("unused")
fun subtractFromPlayerBankBalance(uuid: UUID, amount: Int): CompletableFuture<Boolean?> {
if (DiamondBankOG.economyDisabled) return GlobalScope.future { null }
return GlobalScope.future {
postgreSQL.subtractFromPlayerBalance(
uuid,
amount,
PostgreSQL.BalanceType.BANK_BALANCE
)
}
}
@Suppress("unused")
fun getPlayerBalance(uuid: UUID, type: PostgreSQL.BalanceType): CompletableFuture<PostgreSQL.PlayerBalance?> {
if (DiamondBankOG.economyDisabled) return GlobalScope.future { null }
return GlobalScope.future { postgreSQL.getPlayerBalance(uuid, type) }
}
@Suppress("unused")
fun getPlayerTotalBalance(uuid: UUID): CompletableFuture<Int?> {
if (DiamondBankOG.economyDisabled) return GlobalScope.future { null }
return GlobalScope.future {
val balance = postgreSQL.getPlayerBalance(uuid, PostgreSQL.BalanceType.ALL)
balance.bankBalance!! + balance.inventoryBalance!! + balance.enderChestBalance!!
}
}
@Suppress("unused")
fun withdrawFromPlayer(uuid: UUID, amount: Int): CompletableFuture<Boolean?> {
if (DiamondBankOG.economyDisabled) return GlobalScope.future { null }
val player = Bukkit.getPlayer(uuid) ?: Bukkit.getOfflinePlayer(uuid)
if (!player.hasPlayedBefore()) return GlobalScope.future { true }
if (!player.isOnline) return GlobalScope.future { true }
val playerPlayer = player.player ?: return GlobalScope.future { true }
return GlobalScope.future { Helper.withdrawFromPlayer(playerPlayer, amount) == null }
}
@Suppress("unused")
fun payPlayer(senderUuid: UUID, receiverUuid: UUID, amount: Int): CompletableFuture<Boolean?> {
if (DiamondBankOG.economyDisabled) return GlobalScope.future { null }
val sender = Bukkit.getPlayer(senderUuid) ?: Bukkit.getOfflinePlayer(senderUuid)
if (!sender.hasPlayedBefore()) return GlobalScope.future { true }
if (!sender.isOnline) return GlobalScope.future { true }
val senderPlayer = sender.player ?: return GlobalScope.future { true }
val receiver = Bukkit.getPlayer(receiverUuid) ?: Bukkit.getOfflinePlayer(receiverUuid)
if (!receiver.hasPlayedBefore()) return GlobalScope.future { true }
return GlobalScope.future {
Helper.withdrawFromPlayer(senderPlayer, amount) ?: GlobalScope.future { true }
val error = postgreSQL.addToPlayerBalance(
receiver.uniqueId,
amount,
PostgreSQL.BalanceType.BANK_BALANCE
)
if (error) {
Helper.handleError(
sender.uniqueId,
Helper.PostgresFunction.ADD_TO_PLAYER_BALANCE, amount, PostgreSQL.BalanceType.BANK_BALANCE,
null, "pay"
)
true
} else false
}
}
}
@@ -1,21 +0,0 @@
package net.trueog.diamondbankog
sealed class DiamondBankException(message: String) : Exception(message) {
class EconomyDisabledException : DiamondBankException("Economy is disabled")
class InvalidPlayerException : DiamondBankException("Invalid player")
class SenderEqualToReceiverException : DiamondBankException("Sender is equal to the receiver")
class DatabaseException(message: String) : DiamondBankException(message)
class InsufficientBalanceException(val balance: Long) : DiamondBankException("Insufficient balance")
class InsufficientFundsException(val short: Long) : DiamondBankException("Insufficient funds")
class InvalidArgumentException : Exception()
}
sealed class DiamondBankRuntimeException(message: String) : RuntimeException(message) {
class MoreThanOneDecimalDigitRuntimeException : DiamondBankException("More than one decimal digit")
}
@@ -1,24 +0,0 @@
package net.trueog.diamondbankog
import net.trueog.diamondbankog.DiamondBankOG.Companion.config
import net.trueog.diamondbankog.DiamondBankOG.Companion.mm
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
internal class DiamondBankHelp : CommandExecutor {
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>?): Boolean {
sender.sendMessage(
mm.deserialize(
"<gray>---- ${config.prefix}<reset> Help <gray>----<reset>\n" +
"<bold>How does the <aqua>Diamonds<white> currency work?<reset>\n" +
"The currency on TrueOG Network is based on Diamonds. The smallest form is Diamond Shards, followed by Diamonds, and the largest form is Diamond Blocks. 1 Diamond Block equals 9 Diamonds, and 1 Diamond equals 9 Diamond Shards. When converting Diamond Shards to Diamonds, any amount ending with .9 is rounded up to the next whole Diamond. Some examples:\n" +
"- 1.8 Diamonds = 1 Diamond + 8 Shards\n" +
"- 1.9 Diamonds = 1 Diamonds + 9 Shards (equal to 2 Diamonds)\n" +
"- 2.0 Diamonds = 2 Diamonds\n" +
"- 2.1 Diamonds = 2 Diamonds + 1 Shard"
)
)
return true
}
}
@@ -1,127 +1,65 @@
package net.trueog.diamondbankog
import kotlinx.coroutines.*
import io.sentry.Sentry
import net.kyori.adventure.text.minimessage.MiniMessage
import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver
import net.kyori.adventure.text.minimessage.tag.standard.StandardTags
import net.luckperms.api.LuckPerms
import net.trueog.diamondbankog.api.DiamondBankAPIJava
import net.trueog.diamondbankog.api.DiamondBankAPIKotlin
import net.trueog.diamondbankog.api.EventManager
import net.trueog.diamondbankog.autocompress.command.AutoCompress
import net.trueog.diamondbankog.autodeposit.command.AutoDeposit
import net.trueog.diamondbankog.balance.BalanceManager
import net.trueog.diamondbankog.balance.CachingBalanceManager
import net.trueog.diamondbankog.balance.command.Balance
import net.trueog.diamondbankog.balance.command.Balancetop
import net.trueog.diamondbankog.balance.command.SetBankBalance
import net.trueog.diamondbankog.balance.shard.Shard
import net.trueog.diamondbankog.config.Config
import net.trueog.diamondbankog.config.YamlConfig
import net.trueog.diamondbankog.config.command.DiamondBankReload
import net.trueog.diamondbankog.economy.command.DisableEconomy
import net.trueog.diamondbankog.economy.command.EnableEconomy
import net.trueog.diamondbankog.persistence.Redis
import net.trueog.diamondbankog.transaction.TransactionLock
import net.trueog.diamondbankog.transaction.command.Compress
import net.trueog.diamondbankog.transaction.command.Deposit
import net.trueog.diamondbankog.transaction.command.Pay
import net.trueog.diamondbankog.transaction.command.Withdraw
import net.trueog.diamondbankog.commands.*
import org.bukkit.Bukkit
import org.bukkit.plugin.ServicePriority
import org.bukkit.plugin.java.JavaPlugin
import java.util.*
internal class DiamondBankOG : JavaPlugin() {
class DiamondBankOG : JavaPlugin() {
companion object {
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
lateinit var plugin: DiamondBankOG
lateinit var config: Config
lateinit var eventManager: EventManager
lateinit var balanceManager: BalanceManager
lateinit var redis: Redis
lateinit var luckPerms: LuckPerms
var debug: Boolean = false
fun isRedisInitialized() = ::redis.isInitialized
fun isBalanceManagerInitialized() = ::balanceManager.isInitialized
var mm =
MiniMessage.builder()
.tags(
TagResolver.builder()
.resolver(StandardTags.color())
.resolver(StandardTags.decorations())
.resolver(StandardTags.rainbow())
.resolver(StandardTags.reset())
.build()
)
.build()
val transactionLock = TransactionLock()
lateinit var postgreSQL: PostgreSQL
fun isPostgreSQLInitialised() = ::postgreSQL.isInitialized
var mm = MiniMessage.builder()
.tags(
TagResolver.builder()
.resolver(StandardTags.color())
.resolver(StandardTags.reset())
.build()
)
.build()
val blockInventoryFor = mutableListOf<UUID>()
val blockCommandsWithInventoryActionsFor = mutableListOf<UUID>()
var sentryEnabled: Boolean = false
var economyDisabled: Boolean = false
}
override fun onEnable() {
plugin = this
Companion.config =
YamlConfig.create()
?: run {
Bukkit.getPluginManager().disablePlugin(this)
return
}
eventManager = EventManager()
balanceManager =
CachingBalanceManager.create()
?: run {
Bukkit.getPluginManager().disablePlugin(this)
return
}
redis = Redis()
if (redis.testConnection()) {
logger.severe("Could not connect to Redis")
if (Config.load()) {
Bukkit.getPluginManager().disablePlugin(this)
return
}
val luckPermsProvider = Bukkit.getServicesManager().getRegistration(LuckPerms::class.java)
if (luckPermsProvider == null) {
logger.severe("Luckperms API is null, quitting...")
if (Config.sentryEnabled) {
try {
Sentry.init { options ->
options.dsn = Config.sentryDsn
}
sentryEnabled = true
} catch (_: Exception) {
sentryEnabled = false
this.logger.severe("Could not initialise Sentry. The Sentry(-compatible) DSN in your config might be invalid.")
}
}
postgreSQL = PostgreSQL()
try {
postgreSQL.initDB()
} catch (e: Exception) {
plugin.logger.info(e.toString())
Bukkit.getPluginManager().disablePlugin(this)
return
}
luckPerms = luckPermsProvider.provider
this.server.pluginManager.registerEvents(Events(), this)
setUpCommands()
Shard.createCraftingRecipes()
val diamondBankAPIJava = DiamondBankAPIJava()
this.server.servicesManager.register(
DiamondBankAPIJava::class.java,
diamondBankAPIJava,
this,
ServicePriority.Normal,
)
val diamondBankAPIKotlin = DiamondBankAPIKotlin()
this.server.servicesManager.register(
DiamondBankAPIKotlin::class.java,
diamondBankAPIKotlin,
this,
ServicePriority.Normal,
)
}
fun setUpCommands() {
this.getCommand("deposit")?.setExecutor(Deposit())
this.getCommand("withdraw")?.setExecutor(Withdraw())
this.getCommand("setbankbalance")?.setExecutor(SetBankBalance())
@@ -131,32 +69,15 @@ internal class DiamondBankOG : JavaPlugin() {
this.getCommand("baltop")?.setExecutor(Balancetop())
this.getCommand("balance")?.setExecutor(Balance())
this.getCommand("bal")?.setExecutor(Balance())
this.getCommand("compress")?.setExecutor(Compress())
this.getCommand("autocompress")?.setExecutor(AutoCompress())
this.getCommand("autodeposit")?.setExecutor(AutoDeposit())
this.getCommand("diamondbankreload")?.setExecutor(DiamondBankReload())
this.getCommand("diamondbankhelp")?.setExecutor(DiamondBankHelp())
this.getCommand("enableeconomy")?.setExecutor(EnableEconomy())
this.getCommand("disableeconomy")?.setExecutor(DisableEconomy())
this.getCommand("dbogtoggledebug")?.setExecutor(ToggleDebug())
val diamondBankAPI = DiamondBankAPI(postgreSQL)
this.server.servicesManager.register(DiamondBankAPI::class.java, diamondBankAPI, this,
ServicePriority.Normal)
}
override fun onDisable() {
scope.cancel()
runBlocking { scope.coroutineContext[Job]?.join() }
if (isRedisInitialized()) {
redis.shutdown()
}
if (isBalanceManagerInitialized()) {
balanceManager.shutdown()
}
transactionLock.removeAllLocks()
if (isPostgreSQLInitialised()) postgreSQL.pool.disconnect().get()
}
}
+218 -352
View File
@@ -1,22 +1,11 @@
package net.trueog.diamondbankog
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import net.trueog.diamondbankog.DiamondBankOG.Companion.balanceManager
import net.trueog.diamondbankog.DiamondBankOG.Companion.config
import net.trueog.diamondbankog.DiamondBankOG.Companion.economyDisabled
import net.trueog.diamondbankog.DiamondBankOG.Companion.mm
import net.trueog.diamondbankog.DiamondBankOG.Companion.redis
import net.trueog.diamondbankog.DiamondBankOG.Companion.scope
import net.trueog.diamondbankog.DiamondBankOG.Companion.transactionLock
import net.trueog.diamondbankog.autocompress.AutoCompress.compress
import net.trueog.diamondbankog.autodeposit.AutoDeposit.deposit
import net.trueog.diamondbankog.balance.shard.Shard
import net.trueog.diamondbankog.balance.shard.ShardType
import net.trueog.diamondbankog.transaction.InventoryLockExtensions.isInventoryLocked
import net.trueog.diamondbankog.util.ErrorHandler.handleError
import net.trueog.diamondbankog.util.InventoryExtensions.countTotal
import net.trueog.diamondbankog.util.MainThreadBlock.runOnMainThread
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countTotal
import net.trueog.diamondbankog.Helper.PostgresFunction
import net.trueog.diamondbankog.Helper.countDiamonds
import net.trueog.diamondbankog.PostgreSQL.BalanceType
import org.bukkit.Material
import org.bukkit.NamespacedKey
import org.bukkit.entity.Player
@@ -24,396 +13,273 @@ import org.bukkit.event.EventHandler
import org.bukkit.event.EventPriority
import org.bukkit.event.Listener
import org.bukkit.event.entity.EntityPickupItemEvent
import org.bukkit.event.inventory.InventoryClickEvent
import org.bukkit.event.inventory.InventoryCloseEvent
import org.bukkit.event.inventory.InventoryType
import org.bukkit.event.inventory.PrepareItemCraftEvent
import org.bukkit.event.player.*
import org.bukkit.inventory.ItemStack
import org.bukkit.event.player.PlayerDropItemEvent
import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.persistence.PersistentDataType
import org.bukkit.scheduler.BukkitRunnable
internal class Events : Listener {
private val vanillaWorlds = arrayOf("world", "world_nether", "world_the_end")
@EventHandler(priority = EventPriority.MONITOR)
@OptIn(DelicateCoroutinesApi::class)
class Events : Listener {
@EventHandler
fun onPlayerJoin(event: PlayerJoinEvent) {
if (economyDisabled) {
event.player.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>The economy is disabled. Please notify a staff member.")
)
if (DiamondBankOG.economyDisabled) {
event.player.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>The economy is disabled because of a severe error. Please notify a staff member."))
return
}
val worldName = event.player.world.name
if (!vanillaWorlds.contains(worldName)) return
if (worldName != "world" && worldName != "world_nether" && worldName != "world_the_end") return
scope.launch {
val hasEntry =
balanceManager.hasEntry(event.player.uniqueId).getOrElse {
handleError(it)
return@launch
}
if (!hasEntry) {
val legacyBalance =
event.player.persistentDataContainer.get(
NamespacedKey.fromString("diamondbank:balance")!!,
PersistentDataType.DOUBLE,
)
if (legacyBalance != null) {
balanceManager
.setPlayerShards(event.player.uniqueId, legacyBalance.toLong() * 9, ShardType.BANK)
.getOrElse {
handleError(it)
return@launch
}
}
event.player.persistentDataContainer.remove(NamespacedKey.fromString("diamondbank:balance")!!)
event.player.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <green>Your old balance has been successfully migrated to DiamondBank-OG!"
)
GlobalScope.launch {
if (!DiamondBankOG.postgreSQL.entryExists(event.player.uniqueId)) {
val oldBalance = event.player.persistentDataContainer.get(
NamespacedKey.fromString("diamondbank:balance")!!,
PersistentDataType.DOUBLE
)
}
transactionLock.withLockSuspend(event.player.uniqueId) {
val (inventoryShards, enderChestShards) =
runOnMainThread { Pair(event.player.inventory.countTotal(), event.player.enderChest.countTotal()) }
balanceManager.setPlayerShards(event.player.uniqueId, inventoryShards, ShardType.INVENTORY).getOrElse {
handleError(it)
return@withLockSuspend
}
balanceManager
.setPlayerShards(event.player.uniqueId, enderChestShards, ShardType.ENDER_CHEST)
.getOrElse {
handleError(it)
return@withLockSuspend
if (oldBalance != null) {
val error = DiamondBankOG.postgreSQL.setPlayerBalance(
event.player.uniqueId,
oldBalance.toInt(),
BalanceType.BANK_BALANCE
)
if (error) {
Helper.handleError(
event.player.uniqueId,
PostgresFunction.SET_PLAYER_BALANCE,
oldBalance.toInt(),
BalanceType.BANK_BALANCE,
null,
"onPlayerJoin"
)
return@launch
}
balanceManager.cacheForPlayer(event.player.uniqueId)
}
}
}
@EventHandler(priority = EventPriority.MONITOR)
fun onPlayerQuit(event: PlayerQuitEvent) {
val worldName = event.player.world.name
val isVanillaWorld = vanillaWorlds.contains(worldName)
scope.launch {
transactionLock.withLockSuspend(event.player.uniqueId) {
if (isVanillaWorld) {
val (inventoryShards, enderChestShards) =
runOnMainThread {
Pair(event.player.inventory.countTotal(), event.player.enderChest.countTotal())
}
balanceManager
.setPlayerShards(event.player.uniqueId, inventoryShards, ShardType.INVENTORY)
.getOrElse {
handleError(it)
return@withLockSuspend
}
balanceManager
.setPlayerShards(event.player.uniqueId, enderChestShards, ShardType.ENDER_CHEST)
.getOrElse {
handleError(it)
return@withLockSuspend
}
event.player.persistentDataContainer.remove(NamespacedKey.fromString("diamondbank:balance")!!)
event.player.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <green>Your old balance has successfully been migrated to DiamondBank-OG!"))
}
balanceManager.removeCacheForPlayer(event.player.uniqueId)
// We don't need to set the player bank balance when we don't migrate since an entry will be created with the code below
}
val inventoryDiamonds = event.player.inventory.countDiamonds()
var error = DiamondBankOG.postgreSQL.setPlayerBalance(
event.player.uniqueId,
inventoryDiamonds,
BalanceType.INVENTORY_BALANCE
)
if (error) {
Helper.handleError(
event.player.uniqueId,
PostgresFunction.SET_PLAYER_BALANCE,
inventoryDiamonds,
BalanceType.INVENTORY_BALANCE,
null,
"onPlayerJoin"
)
return@launch
}
val enderChestDiamonds = event.player.enderChest.countDiamonds()
error = DiamondBankOG.postgreSQL.setPlayerBalance(
event.player.uniqueId,
enderChestDiamonds,
BalanceType.ENDER_CHEST_BALANCE
)
if (error) {
Helper.handleError(
event.player.uniqueId,
PostgresFunction.SET_PLAYER_BALANCE,
enderChestDiamonds,
BalanceType.ENDER_CHEST_BALANCE,
null,
"onPlayerJoin"
)
return@launch
}
}
}
@EventHandler(priority = EventPriority.MONITOR)
fun onPlayerRespawn(event: PlayerRespawnEvent) {
if (!vanillaWorlds.contains(event.respawnLocation.world.name)) return
scope.launch {
transactionLock.withLockSuspend(event.player.uniqueId) {
val (inventoryShards, enderChestShards) =
runOnMainThread { Pair(event.player.inventory.countTotal(), event.player.enderChest.countTotal()) }
balanceManager.setPlayerShards(event.player.uniqueId, inventoryShards, ShardType.INVENTORY).getOrElse {
handleError(it)
return@withLockSuspend
}
balanceManager
.setPlayerShards(event.player.uniqueId, enderChestShards, ShardType.ENDER_CHEST)
.getOrElse {
handleError(it)
return@withLockSuspend
}
}
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
@EventHandler
fun onEntityPickupItem(event: EntityPickupItemEvent) {
val player = event.entity as? Player ?: return
val worldName = player.world.name
if (!vanillaWorlds.contains(worldName)) return
if (economyDisabled) {
val itemStack = event.item.itemStack
val itemType = itemStack.type
if (
itemType != Material.DIAMOND &&
itemType != Material.DIAMOND_BLOCK &&
itemType != Material.SHULKER_BOX &&
!(itemType == Material.PRISMARINE_SHARD && Shard.isShardItem(itemStack))
) {
return
}
event.isCancelled = true
player.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>You cannot pick up any economy-related items while the economy is disabled."
)
)
if (DiamondBankOG.economyDisabled) {
return
}
if (player.uniqueId.isInventoryLocked()) {
val worldName = event.entity.world.name
if (worldName != "world" && worldName != "world_nether" && worldName != "world_the_end") return
val itemType = event.item.itemStack.type
if (itemType != Material.DIAMOND && itemType != Material.DIAMOND_BLOCK && itemType != Material.SHULKER_BOX) return
if (DiamondBankOG.blockInventoryFor.contains(event.entity.uniqueId)) {
event.isCancelled = true
return
}
if (redis.getValue("diamondbankog:${player.uniqueId}:autodeposit") == "true") {
deposit(player, event.item, event.remaining)
}
val player = event.entity
if (player !is Player) return
scope.launch {
if (redis.getValue("diamondbankog:${player.uniqueId}:autocompress") == "true") {
compress(player)
}
transactionLock.withLockSuspend(player.uniqueId) {
val inventoryShards = runOnMainThread { player.inventory.countTotal() }
balanceManager.setPlayerShards(player.uniqueId, inventoryShards, ShardType.INVENTORY).getOrElse {
handleError(it)
return@withLockSuspend
object : BukkitRunnable() {
override fun run() {
GlobalScope.launch {
val inventoryDiamonds = player.inventory.countDiamonds()
val error = DiamondBankOG.postgreSQL.setPlayerBalance(
player.uniqueId,
inventoryDiamonds,
BalanceType.INVENTORY_BALANCE
)
if (error) {
Helper.handleError(
player.uniqueId,
PostgresFunction.SET_PLAYER_BALANCE,
inventoryDiamonds,
BalanceType.INVENTORY_BALANCE,
null,
"onEntityPickupItem"
)
return@launch
}
}
}
}
}.runTaskLater(DiamondBankOG.plugin, 1)
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
@EventHandler
fun onPlayerDropItem(event: PlayerDropItemEvent) {
if (DiamondBankOG.economyDisabled) {
return
}
val worldName = event.player.world.name
if (!vanillaWorlds.contains(worldName)) return
if (worldName != "world" && worldName != "world_nether" && worldName != "world_the_end") return
if (economyDisabled) {
val itemStack = event.itemDrop.itemStack
val itemType = itemStack.type
if (
itemType != Material.DIAMOND &&
itemType != Material.DIAMOND_BLOCK &&
itemType != Material.SHULKER_BOX &&
!(itemType == Material.PRISMARINE_SHARD && Shard.isShardItem(itemStack))
) {
return
}
val itemType = event.itemDrop.itemStack.type
if (itemType != Material.DIAMOND && itemType != Material.DIAMOND_BLOCK && itemType != Material.SHULKER_BOX) return
event.isCancelled = true
event.player.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>You cannot drop any economy-related items while the economy is disabled."
)
)
return
}
if (event.player.uniqueId.isInventoryLocked()) {
if (DiamondBankOG.blockInventoryFor.contains(event.player.uniqueId)) {
event.isCancelled = true
return
}
scope.launch {
transactionLock.withLockSuspend(event.player.uniqueId) {
val inventoryShards = runOnMainThread { event.player.inventory.countTotal() }
balanceManager.setPlayerShards(event.player.uniqueId, inventoryShards, ShardType.INVENTORY).getOrElse {
handleError(it)
return@withLockSuspend
object : BukkitRunnable() {
override fun run() {
GlobalScope.launch {
val inventoryDiamonds = event.player.inventory.countDiamonds()
val error = DiamondBankOG.postgreSQL.setPlayerBalance(
event.player.uniqueId,
inventoryDiamonds,
BalanceType.INVENTORY_BALANCE
)
if (error) {
Helper.handleError(
event.player.uniqueId,
PostgresFunction.SET_PLAYER_BALANCE,
inventoryDiamonds,
BalanceType.INVENTORY_BALANCE,
null,
"onPlayerDropItem"
)
return@launch
}
}
}
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
fun onInventoryClickEvent(event: InventoryClickEvent) {
val player = event.whoClicked as? Player ?: return
val worldName = player.world.name
if (!vanillaWorlds.contains(worldName)) return
if (economyDisabled) {
val itemStack = event.currentItem ?: return
val itemType = itemStack.type
if (
itemType != Material.DIAMOND &&
itemType != Material.DIAMOND_BLOCK &&
itemType != Material.SHULKER_BOX &&
!(itemType == Material.PRISMARINE_SHARD &&
itemStack.persistentDataContainer.has(Shard.namespacedKey))
) {
return
}
event.isCancelled = true
player.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>You cannot move any economy-related items while the economy is disabled."
)
)
return
}
if (player.uniqueId.isInventoryLocked()) {
event.isCancelled = true
return
}
}
@EventHandler(priority = EventPriority.HIGHEST)
fun onPlayerInteract(event: PlayerInteractEvent) {
val player = event.player
val worldName = player.world.name
if (!vanillaWorlds.contains(worldName)) return
val clickedBlock = event.clickedBlock ?: return
if (clickedBlock.type == Material.AIR) return
if (economyDisabled) {
if (event.action.isLeftClick) {
if (clickedBlock.type != Material.DIAMOND_BLOCK && clickedBlock.type != Material.SHULKER_BOX) {
return
}
} else {
val itemStack = event.item ?: return
val itemType = itemStack.type
if (
itemType != Material.DIAMOND &&
itemType != Material.DIAMOND_BLOCK &&
itemType != Material.SHULKER_BOX &&
!(itemType == Material.PRISMARINE_SHARD && Shard.isShardItem(itemStack))
) {
return
}
}
event.isCancelled = true
event.player.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>You cannot interact with any economy-related items while the economy is disabled."
)
)
return
}
if (player.uniqueId.isInventoryLocked()) {
event.isCancelled = true
return
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
fun onPlayerInteractEntity(event: PlayerInteractEntityEvent) {
val player = event.player
val worldName = player.world.name
if (!vanillaWorlds.contains(worldName)) return
if (economyDisabled) {
val itemStackInMainHand = player.inventory.itemInMainHand
val itemTypeInMainHand = itemStackInMainHand.type
val itemStackInOffHand = player.inventory.itemInOffHand
val itemTypeInOffHand = itemStackInOffHand.type
if (
(itemTypeInMainHand != Material.DIAMOND &&
itemTypeInMainHand != Material.DIAMOND_BLOCK &&
itemTypeInMainHand != Material.SHULKER_BOX &&
!(itemTypeInMainHand == Material.PRISMARINE_SHARD && Shard.isShardItem(itemStackInMainHand)) &&
(itemTypeInOffHand != Material.DIAMOND &&
itemTypeInOffHand != Material.DIAMOND_BLOCK &&
itemTypeInOffHand != Material.SHULKER_BOX &&
!(itemTypeInOffHand == Material.PRISMARINE_SHARD && Shard.isShardItem(itemStackInOffHand))))
) {
return
}
event.isCancelled = true
event.player.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>You cannot interact with any economy-related items while the economy is disabled."
)
)
return
}
if (player.uniqueId.isInventoryLocked()) {
event.isCancelled = true
return
}
}.runTaskLater(DiamondBankOG.plugin, 1)
}
@EventHandler(priority = EventPriority.MONITOR)
fun onInventoryClose(event: InventoryCloseEvent) {
if (economyDisabled) {
if (DiamondBankOG.economyDisabled) {
return
}
val player = event.player as? Player ?: return
val worldName = event.player.world.name
if (worldName != "world" && worldName != "world_nether" && worldName != "world_the_end") return
if (player.uniqueId.isInventoryLocked()) {
return
}
val worldName = player.world.name
if (!vanillaWorlds.contains(worldName)) return
scope.launch {
if (redis.getValue("diamondbankog:${player.uniqueId}:autocompress") == "true") {
compress(player)
}
transactionLock.withLockSuspend(player.uniqueId) {
val (inventoryShards, enderChestShards) =
runOnMainThread {
Pair(
player.inventory.countTotal(),
if (event.inventory.type == InventoryType.ENDER_CHEST) player.enderChest.countTotal()
else null,
object : BukkitRunnable() {
override fun run() {
GlobalScope.launch {
val inventoryDiamonds = event.player.inventory.countDiamonds()
var error = DiamondBankOG.postgreSQL.setPlayerBalance(
event.player.uniqueId,
inventoryDiamonds,
BalanceType.INVENTORY_BALANCE
)
if (error) {
Helper.handleError(
event.player.uniqueId,
PostgresFunction.SET_PLAYER_BALANCE,
inventoryDiamonds,
BalanceType.INVENTORY_BALANCE,
null,
"onPlayerJoin"
)
return@launch
}
balanceManager.setPlayerShards(player.uniqueId, inventoryShards, ShardType.INVENTORY).getOrElse {
handleError(it)
return@withLockSuspend
}
if (enderChestShards == null) return@withLockSuspend
balanceManager.setPlayerShards(player.uniqueId, enderChestShards, ShardType.ENDER_CHEST).getOrElse {
handleError(it)
return@withLockSuspend
if (event.inventory.type != InventoryType.ENDER_CHEST) return@launch
val enderChestDiamonds = event.player.enderChest.countDiamonds()
error = DiamondBankOG.postgreSQL.setPlayerBalance(
event.player.uniqueId,
enderChestDiamonds,
BalanceType.ENDER_CHEST_BALANCE
)
if (error) {
Helper.handleError(
event.player.uniqueId,
PostgresFunction.SET_PLAYER_BALANCE,
enderChestDiamonds,
BalanceType.ENDER_CHEST_BALANCE,
null,
"onInventoryClose"
)
return@launch
}
}
}
}
}.runTaskLater(DiamondBankOG.plugin, 1)
}
@EventHandler(priority = EventPriority.HIGHEST)
fun onPrepareItemCraft(event: PrepareItemCraftEvent) {
val resultType = event.recipe?.result?.type
if (
resultType != Material.PRISMARINE &&
resultType != Material.DARK_PRISMARINE &&
resultType != Material.SEA_LANTERN
) {
return
}
if (!event.inventory.any { it?.persistentDataContainer?.has(Shard.namespacedKey) == true }) {
return
}
event.inventory.result = ItemStack(Material.AIR)
}
// @EventHandler
// fun onPlayerCommandPreprocess(event: PlayerCommandPreprocessEvent) {
// val args = event.message.split(" ", limit = 3)
// handleGive(args)
// }
//
// @EventHandler
// fun onServerCommand(event: ServerCommandEvent) {
// val args = event.command.split(" ", limit = 3)
// handleGive(args)
// }
//
// private fun handleGive(args: List<String>) {
// GlobalScope.launch {
// if (args[0] != "/give" && args[0] != "/essentials:give" && args[0] != "/minecraft:give") return@launch
// if (args.size != 3 && args.size != 4) return@launch
// if (args[2] != "diamond" && args[2] != "minecraft:diamond") return@launch
//
// val player = Bukkit.getOfflinePlayer(args[1])
// val playerPlayer = player.player ?: return@launch
//
// if (player.isOnline) {
// val inventoryDiamonds = playerPlayer.inventory.countDiamondsInInventory()
// val error = DiamondBankOG.postgreSQL.setPlayerBalance(
// player.uniqueId,
// inventoryDiamonds,
// INVENTORY_BALANCE
// )
// if (error) {
// Helper.handleError(
// playerPlayer.uniqueId,
// SET_PLAYER_BALANCE,
// inventoryDiamonds,
// INVENTORY_BALANCE,
// null,
// "handleGive"
// )
// return@launch
// }
// }
// }
// }
}
@@ -0,0 +1,412 @@
package net.trueog.diamondbankog
import io.sentry.Sentry
import io.sentry.SentryEvent
import io.sentry.protocol.Message
import io.sentry.protocol.User
import net.trueog.diamondbankog.PostgreSQL.BalanceType
import org.bukkit.Material
import org.bukkit.block.ShulkerBox
import org.bukkit.entity.Player
import org.bukkit.event.inventory.InventoryType
import org.bukkit.inventory.Inventory
import org.bukkit.inventory.ItemStack
import org.bukkit.inventory.meta.BlockStateMeta
import java.util.*
import kotlin.math.ceil
object Helper {
enum class PostgresFunction(val string: String) {
SET_PLAYER_BALANCE("setPlayerBalance"),
ADD_TO_PLAYER_BALANCE("addToPlayerBalance"),
SUBTRACT_FROM_PLAYER_BALANCE("subtractFromPlayerBalance"),
}
suspend fun withdrawFromPlayer(player: Player, amount: Int): Int? {
val somethingWentWrongMessage =
DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Something went wrong.")
val playerBalance = DiamondBankOG.postgreSQL.getPlayerBalance(player.uniqueId, BalanceType.ALL)
if (playerBalance.bankBalance == null || playerBalance.inventoryBalance == null || playerBalance.enderChestBalance == null) {
player.sendMessage(somethingWentWrongMessage)
return null
}
// Withdraw everything
if (amount == -1) {
var error = DiamondBankOG.postgreSQL.subtractFromPlayerBalance(
player.uniqueId,
playerBalance.bankBalance,
BalanceType.BANK_BALANCE
)
if (error) {
handleError(
player.uniqueId,
PostgresFunction.SUBTRACT_FROM_PLAYER_BALANCE, playerBalance.bankBalance, BalanceType.BANK_BALANCE,
playerBalance, "withdrawFromPlayer"
)
player.sendMessage(somethingWentWrongMessage)
return null
}
error = player.inventory.withdraw(
playerBalance.inventoryBalance,
playerBalance
)
if (error) {
player.sendMessage(somethingWentWrongMessage)
return null
}
error = player.enderChest.withdraw(
playerBalance.enderChestBalance,
playerBalance
)
if (error) {
player.sendMessage(somethingWentWrongMessage)
return null
}
return playerBalance.bankBalance + playerBalance.inventoryBalance + playerBalance.enderChestBalance
}
if (amount > playerBalance.bankBalance + playerBalance.inventoryBalance + playerBalance.enderChestBalance) {
player.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Cannot withdraw <yellow>$amount <aqua>${if (amount == 1) "Diamond" else "Diamonds"} <red>because you only have <yellow>${playerBalance.bankBalance + playerBalance.inventoryBalance} <aqua>${if (playerBalance.bankBalance + playerBalance.inventoryBalance == 1) "Diamond" else "Diamonds"}<red>."))
return null
}
if (amount <= playerBalance.bankBalance) {
val error = DiamondBankOG.postgreSQL.subtractFromPlayerBalance(
player.uniqueId,
amount,
BalanceType.BANK_BALANCE
)
if (error) {
handleError(
player.uniqueId,
PostgresFunction.SUBTRACT_FROM_PLAYER_BALANCE, amount, BalanceType.BANK_BALANCE,
playerBalance, "withdrawFromPlayer"
)
player.sendMessage(somethingWentWrongMessage)
return null
}
return amount
}
if (amount <= playerBalance.bankBalance + playerBalance.inventoryBalance) {
var error = DiamondBankOG.postgreSQL.subtractFromPlayerBalance(
player.uniqueId,
playerBalance.bankBalance,
BalanceType.BANK_BALANCE
)
if (error) {
handleError(
player.uniqueId,
PostgresFunction.SUBTRACT_FROM_PLAYER_BALANCE, playerBalance.bankBalance, BalanceType.BANK_BALANCE,
playerBalance, "withdrawFromPlayer"
)
player.sendMessage(somethingWentWrongMessage)
return null
}
error = player.inventory.withdraw(
amount - playerBalance.bankBalance,
playerBalance
)
if (error) {
player.sendMessage(somethingWentWrongMessage)
return null
}
return amount
}
var error = DiamondBankOG.postgreSQL.subtractFromPlayerBalance(
player.uniqueId,
playerBalance.bankBalance,
BalanceType.BANK_BALANCE
)
if (error) {
handleError(
player.uniqueId,
PostgresFunction.SUBTRACT_FROM_PLAYER_BALANCE, playerBalance.bankBalance, BalanceType.BANK_BALANCE,
playerBalance, "withdrawFromPlayer"
)
player.sendMessage(somethingWentWrongMessage)
return null
}
error = player.inventory.withdraw(playerBalance.inventoryBalance, playerBalance)
if (error) {
player.sendMessage(somethingWentWrongMessage)
return null
}
error = player.enderChest.withdraw(
amount - (playerBalance.bankBalance + playerBalance.inventoryBalance),
playerBalance
)
if (error) {
player.sendMessage(somethingWentWrongMessage)
return null
}
return amount
}
private suspend fun Inventory.withdrawDiamondBlocks(
player: Player,
toBeRemoved: Int,
balanceType: BalanceType,
isShulkerBox: Boolean
): Int? {
val diamondBlocks =
this.contents.filterNotNull().filter { it.type == Material.DIAMOND_BLOCK }.sumOf { it.amount }
if (diamondBlocks == 0) return 0
val blocksNeeded = ceil(toBeRemoved.toDouble() / 9).toInt()
val wholeBlocks = if (blocksNeeded > diamondBlocks) diamondBlocks else blocksNeeded
val remainder = (toBeRemoved) % 9
val change = if (remainder == 0) 0 else 9 - remainder
this.removeItem(ItemStack(Material.DIAMOND_BLOCK, wholeBlocks))
val emptySlots = this.storageContents.filter { it == null }.size * 64
val leftOverSpace = this.storageContents.filterNotNull().filter { it.type == Material.DIAMOND }
.sumOf { 64 - it.amount }
if (change > (emptySlots + leftOverSpace)) {
if (isShulkerBox) {
val emptySlotsInInventory = player.inventory.storageContents.filter { it == null }.size * 64
val leftOverSpaceInInventory =
player.inventory.storageContents.filterNotNull().filter { it.type == Material.DIAMOND }
.sumOf { 64 - it.amount }
if (change > (emptySlotsInInventory + leftOverSpaceInInventory)) {
player.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: You don't have enough inventory space in your shulker box to store <yellow>$change <aqua>${if (change == 1) "Diamond" else "Diamonds"} <reset>of change from converting diamond blocks into <aqua>Diamonds<reset>, so the <aqua>${if (change == 1) "Diamond" else "Diamonds"} <reset>has been deposited into your bank."))
val error = DiamondBankOG.postgreSQL.addToPlayerBalance(
player.uniqueId,
change,
BalanceType.BANK_BALANCE
)
if (error) {
handleError(
player.uniqueId,
PostgresFunction.ADD_TO_PLAYER_BALANCE,
change,
balanceType,
null,
"Inventory.withdrawDiamondBlocks"
)
player.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Something went wrong."))
DiamondBankOG.blockInventoryFor.remove(player.uniqueId)
return null
}
} else {
player.inventory.addItem(ItemStack(Material.DIAMOND, change))
player.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: You don't have enough inventory space in your shulker box to store <yellow>$change <aqua>${if (change == 1) "Diamond" else "Diamonds"} <reset>of change from converting diamond blocks into <aqua>Diamonds<reset>, so the <aqua>${if (change == 1) "Diamond" else "Diamonds"} <reset>have been added to your inventory."))
}
} else {
player.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: You don't have enough inventory space to store <yellow>$change <aqua>${if (change == 1) "Diamond" else "Diamonds"} <reset>of change from converting diamond blocks into <aqua>Diamonds<reset>, so the <aqua>${if (change == 1) "Diamond" else "Diamonds"} <reset>has been deposited into your bank."))
val error = DiamondBankOG.postgreSQL.addToPlayerBalance(
player.uniqueId,
change,
BalanceType.BANK_BALANCE
)
if (error) {
handleError(
player.uniqueId,
PostgresFunction.ADD_TO_PLAYER_BALANCE,
change,
balanceType,
null,
"Inventory.withdrawDiamondBlocks"
)
player.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Something went wrong."))
DiamondBankOG.blockInventoryFor.remove(player.uniqueId)
return null
}
}
} else {
this.addItem(ItemStack(Material.DIAMOND, change))
}
return if (blocksNeeded > diamondBlocks) diamondBlocks else (wholeBlocks * 9) - change
}
suspend fun Inventory.withdraw(
amount: Int,
playerBalance: PostgreSQL.PlayerBalance
): Boolean {
if (this.holder !is Player) return true
val player = this.holder as Player
DiamondBankOG.blockInventoryFor.add(player.uniqueId)
val balance = if (this.type == InventoryType.PLAYER) {
playerBalance.inventoryBalance!!
} else {
playerBalance.enderChestBalance!!
}
val balanceType = if (this.type == InventoryType.PLAYER) {
BalanceType.INVENTORY_BALANCE
} else {
BalanceType.ENDER_CHEST_BALANCE
}
val inventoryDiamonds = this.countDiamonds()
if (playerBalance.inventoryBalance != inventoryDiamonds) {
val error = DiamondBankOG.postgreSQL.setPlayerBalance(
player.uniqueId,
balance - (amount - inventoryDiamonds),
balanceType
)
if (error) {
handleError(
player.uniqueId,
PostgresFunction.SET_PLAYER_BALANCE,
amount,
balanceType,
playerBalance,
"Inventory.withdraw"
)
player.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Something went wrong."))
DiamondBankOG.blockInventoryFor.remove(player.uniqueId)
return true
}
}
val error = DiamondBankOG.postgreSQL.subtractFromPlayerBalance(
player.uniqueId,
amount,
balanceType
)
if (error) {
handleError(
player.uniqueId,
PostgresFunction.SUBTRACT_FROM_PLAYER_BALANCE,
amount,
balanceType,
playerBalance,
"Inventory.withdraw"
)
player.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Something went wrong."))
DiamondBankOG.blockInventoryFor.remove(player.uniqueId)
return true
}
val removeMap = this.removeItem(ItemStack(Material.DIAMOND, amount))
if (removeMap.isNotEmpty()) {
var toBeRemoved = removeMap[0]!!.amount
val removed = this.withdrawDiamondBlocks(player, toBeRemoved, balanceType, false) ?: return true
toBeRemoved -= removed
if (toBeRemoved != 0) {
val itemStacks = this.contents.filterNotNull().filter { it.type == Material.SHULKER_BOX }
for (itemStack in itemStacks) {
val blockStateMeta = (itemStack.itemMeta as BlockStateMeta)
itemStack.itemMeta
val shulkerBox = blockStateMeta.blockState as ShulkerBox
val shulkerRemoveMap = shulkerBox.inventory.removeItem(ItemStack(Material.DIAMOND, toBeRemoved))
blockStateMeta.blockState = shulkerBox
itemStack.itemMeta = blockStateMeta
toBeRemoved -= if (shulkerRemoveMap.isEmpty()) toBeRemoved else toBeRemoved - shulkerRemoveMap[0]!!.amount
if (toBeRemoved == 0) break
}
if (toBeRemoved != 0) {
for (itemStack in itemStacks) {
val blockStateMeta = (itemStack.itemMeta as BlockStateMeta)
itemStack.itemMeta
val shulkerBox = blockStateMeta.blockState as ShulkerBox
val shulkerRemoved =
shulkerBox.inventory.withdrawDiamondBlocks(player, toBeRemoved, balanceType, true)
?: return true
blockStateMeta.blockState = shulkerBox
itemStack.itemMeta = blockStateMeta
toBeRemoved -= shulkerRemoved
if (toBeRemoved == 0) break
}
}
}
}
DiamondBankOG.blockInventoryFor.remove(player.uniqueId)
return false
}
fun Inventory.countDiamonds(): Int {
val inventoryDiamonds = this.all(Material.DIAMOND).values.sumOf { it.amount }
val shulkerBoxDiamonds = this.all(Material.SHULKER_BOX).values.sumOf { itemStack ->
((itemStack.itemMeta as BlockStateMeta).blockState as ShulkerBox).inventory.all(Material.DIAMOND).values.sumOf { it.amount }
}
val inventoryDiamondBlocks = this.all(Material.DIAMOND_BLOCK).values.sumOf { it.amount * 9 }
val shulkerBoxDiamondBlocks = this.all(Material.SHULKER_BOX).values.sumOf { itemStack ->
((itemStack.itemMeta as BlockStateMeta).blockState as ShulkerBox).inventory.all(Material.DIAMOND_BLOCK).values.sumOf { it.amount * 9 }
}
return inventoryDiamonds + shulkerBoxDiamonds + inventoryDiamondBlocks + shulkerBoxDiamondBlocks
}
fun handleError(
uuid: UUID,
function: PostgresFunction,
amount: Int,
balanceType: BalanceType,
playerBalance: PostgreSQL.PlayerBalance?,
inFunction: String
) {
DiamondBankOG.economyDisabled = true
if (DiamondBankOG.sentryEnabled) {
val sentryUser = User()
sentryUser.id = uuid.toString()
val sentryEvent = SentryEvent()
sentryEvent.user = sentryUser
sentryEvent.setExtra("Function", "${function.string}(amount = $amount, type = $balanceType)")
if (playerBalance != null) {
if (playerBalance.bankBalance != null) sentryEvent.setExtra("Bank Balance", playerBalance.bankBalance)
if (playerBalance.inventoryBalance != null) sentryEvent.setExtra(
"Inventory Balance",
playerBalance.inventoryBalance
)
if (playerBalance.enderChestBalance != null) sentryEvent.setExtra(
"Ender Chest Balance",
playerBalance.enderChestBalance
)
}
val message = Message()
message.message = "${function.string} failed in $inFunction"
sentryEvent.message = message
Sentry.captureEvent(sentryEvent)
}
DiamondBankOG.plugin.logger.severe(
"""
${function.string} failed in $inFunction
Player UUID: $uuid
Function: ${function.string}(amount = $amount, type = $balanceType)
${
if (playerBalance != null) {
if (playerBalance.bankBalance != null) "Bank Balance: ${playerBalance.bankBalance}" else ""
} else ""
}
${
if (playerBalance != null) {
if (playerBalance.inventoryBalance != null) "Inventory Balance: ${playerBalance.inventoryBalance}" else ""
} else ""
}
${
if (playerBalance != null) {
if (playerBalance.enderChestBalance != null) "Ender Chest Balance: ${playerBalance.enderChestBalance}" else ""
} else ""
}
""".trimIndent()
)
}
}
@@ -0,0 +1,214 @@
package net.trueog.diamondbankog
import com.github.jasync.sql.db.asSuspending
import com.github.jasync.sql.db.general.ArrayRowData
import com.github.jasync.sql.db.pool.ConnectionPool
import com.github.jasync.sql.db.postgresql.PostgreSQLConnection
import com.github.jasync.sql.db.postgresql.PostgreSQLConnectionBuilder
import kotlinx.coroutines.future.await
import org.bukkit.Bukkit
import java.sql.SQLException
import java.util.*
class PostgreSQL {
lateinit var pool: ConnectionPool<PostgreSQLConnection>
enum class BalanceType {
BANK_BALANCE, INVENTORY_BALANCE, ENDER_CHEST_BALANCE, ALL
}
@Throws(SQLException::class, ClassNotFoundException::class)
fun initDB() {
try {
pool =
PostgreSQLConnectionBuilder.createConnectionPool("${Config.postgresUrl}?user=${Config.postgresUser}&password=${Config.postgresPassword}")
val createTable =
pool.sendPreparedStatement("CREATE TABLE IF NOT EXISTS ${Config.postgresTable}(uuid TEXT, bank_balance integer, inventory_balance integer, ender_chest_balance integer, unique(uuid))")
createTable.join()
val createIndex =
pool.sendPreparedStatement("CREATE INDEX IF NOT EXISTS idx_balance ON ${Config.postgresTable}(bank_balance, inventory_balance, ender_chest_balance)")
createIndex.join()
} catch (e: Exception) {
DiamondBankOG.economyDisabled = true
DiamondBankOG.plugin.logger.severe("ECONOMY DISABLED! Something went wrong while trying to initialise PostgreSQL. Is PostgreSQL running? Are the PostgreSQL config variables correct?")
return
}
}
suspend fun setPlayerBalance(uuid: UUID, balance: Int, type: BalanceType): Boolean {
try {
val connection = pool.asSuspending.connect()
val balanceType = when (type) {
BalanceType.BANK_BALANCE -> "bank_balance"
BalanceType.INVENTORY_BALANCE -> "inventory_balance"
BalanceType.ENDER_CHEST_BALANCE -> "ender_chest_balance"
else -> return true
}
val preparedStatement =
connection.sendPreparedStatement("INSERT INTO ${Config.postgresTable}(uuid, $balanceType) VALUES('$uuid', $balance) ON CONFLICT (uuid) DO UPDATE SET $balanceType = excluded.$balanceType")
preparedStatement.await()
} catch (e: Exception) {
return true
}
return false
}
suspend fun addToPlayerBalance(uuid: UUID, amount: Int, type: BalanceType): Boolean {
val playerBalance = getPlayerBalanceWrapper(uuid, type) ?: return true
val error = setPlayerBalance(uuid, playerBalance + amount, type)
return error
}
suspend fun subtractFromPlayerBalance(uuid: UUID, amount: Int, type: BalanceType): Boolean {
val playerBalance = getPlayerBalanceWrapper(uuid, type) ?: return true
val error = setPlayerBalance(uuid, playerBalance - amount, type)
return error
}
private suspend fun getPlayerBalanceWrapper(uuid: UUID, type: BalanceType): Int? {
val playerBalance = getPlayerBalance(uuid, type)
return when (type) {
BalanceType.BANK_BALANCE -> playerBalance.bankBalance
BalanceType.INVENTORY_BALANCE -> playerBalance.inventoryBalance
BalanceType.ENDER_CHEST_BALANCE -> playerBalance.enderChestBalance
BalanceType.ALL -> if (playerBalance.bankBalance != null && playerBalance.inventoryBalance != null && playerBalance.enderChestBalance != null) playerBalance.bankBalance + playerBalance.inventoryBalance + playerBalance.enderChestBalance else null
}
}
data class PlayerBalance(val bankBalance: Int?, val inventoryBalance: Int?, val enderChestBalance: Int?)
suspend fun getPlayerBalance(uuid: UUID, type: BalanceType): PlayerBalance {
var bankBalance: Int? = null
var inventoryBalance: Int? = null
var enderChestBalance: Int? = null
try {
val connection = pool.asSuspending.connect()
val balanceType = when (type) {
BalanceType.BANK_BALANCE -> "bank_balance"
BalanceType.INVENTORY_BALANCE -> "inventory_balance"
BalanceType.ENDER_CHEST_BALANCE -> "ender_chest_balance"
BalanceType.ALL -> "bank_balance, inventory_balance, ender_chest_balance"
}
val preparedStatement =
connection.sendPreparedStatement("SELECT $balanceType FROM ${Config.postgresTable} WHERE uuid = '$uuid' LIMIT 1")
val result = preparedStatement.await()
if (result.rows.size != 0) {
val rowData = result.rows[0] as ArrayRowData
when (type) {
BalanceType.BANK_BALANCE -> {
bankBalance = if (rowData.columns[0] != null) {
rowData.columns[0] as Int
} else 0
}
BalanceType.INVENTORY_BALANCE -> {
inventoryBalance = if (rowData.columns[0] != null) {
rowData.columns[0] as Int
} else 0
}
BalanceType.ENDER_CHEST_BALANCE -> {
enderChestBalance = if (rowData.columns[0] != null) {
rowData.columns[0] as Int
} else 0
}
BalanceType.ALL -> {
bankBalance = if (rowData.columns[0] != null) {
rowData.columns[0] as Int
} else 0
inventoryBalance = if (rowData.columns[1] != null) {
rowData.columns[1] as Int
} else 0
enderChestBalance = if (rowData.columns[2] != null) {
rowData.columns[2] as Int
} else 0
}
}
} else {
bankBalance = 0
inventoryBalance = 0
enderChestBalance = 0
}
} catch (e: Exception) {
DiamondBankOG.plugin.logger.info(e.toString())
}
return PlayerBalance(bankBalance, inventoryBalance, enderChestBalance)
}
suspend fun getBaltop(offset: Int): MutableMap<String?, Int>? {
try {
val connection = pool.asSuspending.connect()
val preparedStatement =
connection.sendPreparedStatement("SELECT * FROM ${Config.postgresTable} ORDER BY bank_balance DESC, inventory_balance DESC, ender_chest_balance DESC OFFSET $offset LIMIT 10")
val result = preparedStatement.await()
val baltop = mutableMapOf<String?, Int>()
result.rows.forEach {
val rowData = it as ArrayRowData
val bankBalance = if (rowData.columns[1] != null) {
rowData.columns[1] as Int
} else 0
val inventoryBalance = if (rowData.columns[2] != null) {
rowData.columns[2] as Int
} else 0
val enderChestBalance = if (rowData.columns[3] != null) {
rowData.columns[3] as Int
} else 0
val player = Bukkit.getPlayer(UUID.fromString(rowData.columns[0] as String)) ?: Bukkit.getOfflinePlayer(
UUID.fromString(rowData.columns[0] as String)
)
baltop[player.name] = bankBalance + inventoryBalance + enderChestBalance
}
return baltop
} catch (e: Exception) {
DiamondBankOG.plugin.logger.info(e.toString())
}
return null
}
suspend fun getNumberOfRows(): Long? {
var number: Long? = null
try {
val connection = pool.asSuspending.connect()
val preparedStatement =
connection.sendPreparedStatement("SELECT count(*) AS exact_count FROM ${Config.postgresTable}")
val result = preparedStatement.await()
if (result.rows.size != 0) {
val rowData = result.rows[0] as ArrayRowData
number = if (rowData.columns[0] != null) {
rowData.columns[0] as Long
} else 0
}
} catch (e: Exception) {
DiamondBankOG.plugin.logger.info(e.toString())
}
return number
}
suspend fun entryExists(uuid: UUID): Boolean {
try {
val connection = pool.asSuspending.connect()
val preparedStatement =
connection.sendPreparedStatement("SELECT 1 FROM ${Config.postgresTable} WHERE uuid = '${uuid}';")
val result = preparedStatement.await()
if (result.rows.size == 1) {
return true
}
} catch (e: Exception) {
DiamondBankOG.plugin.logger.info(e.toString())
}
return false
}
}
@@ -1,26 +0,0 @@
package net.trueog.diamondbankog
import net.trueog.diamondbankog.DiamondBankOG.Companion.config
import net.trueog.diamondbankog.DiamondBankOG.Companion.mm
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
class ToggleDebug : CommandExecutor {
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>?): Boolean {
if (!sender.hasPermission("diamondbank-og.debug")) {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>You do not have permission to use this command.")
)
return true
}
DiamondBankOG.debug = !DiamondBankOG.debug
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: Debug mode is now turned ${if (DiamondBankOG.debug) "<green>on" else "<red>off"}<reset>."
)
)
return true
}
}
@@ -1,347 +0,0 @@
package net.trueog.diamondbankog.api
import java.util.*
import kotlinx.coroutines.runBlocking
import net.trueog.diamondbankog.DiamondBankException
import net.trueog.diamondbankog.DiamondBankException.*
import net.trueog.diamondbankog.DiamondBankOG.Companion.balanceManager
import net.trueog.diamondbankog.DiamondBankOG.Companion.economyDisabled
import net.trueog.diamondbankog.DiamondBankOG.Companion.eventManager
import net.trueog.diamondbankog.DiamondBankOG.Companion.transactionLock
import net.trueog.diamondbankog.balance.shard.PlayerShards
import net.trueog.diamondbankog.balance.shard.ShardType
import net.trueog.diamondbankog.transaction.CommonOperations
import net.trueog.diamondbankog.transaction.InventoryLockExtensions.withInventoryLockSuspend
import net.trueog.diamondbankog.transaction.InventorySnapshot
import net.trueog.diamondbankog.util.ErrorHandler.handleError
import net.trueog.diamondbankog.util.MainThreadBlock.runOnMainThread
import org.bukkit.Bukkit
import org.bukkit.entity.Player
class DiamondBankAPIJava {
/**
* WARNING: if the player has a transaction lock applied this function will block until its released.
*
* This function also blocks for the database call, this is so you don't have to manually run .get() on a
* CompletableFuture
*
* @param shards must not be negative
* @param transactionReason the reason for this transaction for in the transaction log
* @param notes any specifics for this transaction that may be nice to know for in the transaction log
* @throws DiamondBankException.EconomyDisabledException
* @throws DiamondBankException.InvalidPlayerException
*/
@Throws(EconomyDisabledException::class, InvalidPlayerException::class)
@Suppress("unused")
fun addToPlayerBankShards(uuid: UUID, shards: Long, transactionReason: String, notes: String?) {
require(shards >= 0) { "shards must not be negative" }
if (economyDisabled) throw EconomyDisabledException()
return runBlocking {
transactionLock.withLockSuspend(uuid) {
val player = Bukkit.getPlayer(uuid) ?: Bukkit.getOfflinePlayer(uuid)
if (player !is Player && !player.hasPlayedBefore()) throw InvalidPlayerException()
balanceManager.addToBankShards(uuid, shards).getOrElse {
handleError(it)
throw EconomyDisabledException()
}
balanceManager.insertTransactionLog(uuid, shards, null, transactionReason, notes).getOrElse {
handleError(it)
}
}
}
}
/**
* WARNING: if the player has a transaction lock applied this function will block until its released
*
* This function also blocks for the database call, this is so you don't have to manually run .get() on a
* CompletableFuture
*
* @param shards must not be negative
* @param transactionReason the reason for this transaction for in the transaction log
* @param notes any specifics for this transaction that may be nice to know for in the transaction log
* @throws DiamondBankException.EconomyDisabledException
* @throws DiamondBankException.InsufficientBalanceException
* @throws DiamondBankException.InvalidPlayerException
*/
@Throws(EconomyDisabledException::class, InsufficientBalanceException::class, InvalidPlayerException::class)
@Suppress("unused")
fun subtractFromPlayerBankShards(uuid: UUID, shards: Long, transactionReason: String, notes: String?) {
require(shards >= 0) { "shards must not be negative" }
if (economyDisabled) throw EconomyDisabledException()
return runBlocking {
transactionLock.withLockSuspend(uuid) {
val player = Bukkit.getPlayer(uuid) ?: Bukkit.getOfflinePlayer(uuid)
if (player !is Player && !player.hasPlayedBefore()) throw InvalidPlayerException()
balanceManager.subtractFromBankShards(uuid, shards).getOrElse {
if (it is InsufficientBalanceException) throw it
handleError(it)
throw EconomyDisabledException()
}
balanceManager.insertTransactionLog(uuid, shards, null, transactionReason, notes).getOrElse {
handleError(it)
}
}
}
}
/**
* WARNING: if the player has a transaction lock applied this function will block until its released
*
* This function also blocks for the database call, this is so you don't have to manually run .get() on a
* CompletableFuture
*
* @throws DiamondBankException.EconomyDisabledException
*/
@Throws(EconomyDisabledException::class)
@Suppress("unused")
fun getBankShards(uuid: UUID): Long = getShardTypeShards(uuid, ShardType.BANK)
/**
* WARNING: if the player has a transaction lock applied this function will block until its released
*
* This function also blocks for the database call, this is so you don't have to manually run .get() on a
* CompletableFuture
*
* @throws DiamondBankException.EconomyDisabledException
*/
@Throws(EconomyDisabledException::class)
@Suppress("unused")
fun getInventoryShards(uuid: UUID): Long = getShardTypeShards(uuid, ShardType.INVENTORY)
/**
* WARNING: if the player has a transaction lock applied this function will block until its released
*
* This function also blocks for the database call, this is so you don't have to manually run .get() on a
* CompletableFuture
*
* @throws DiamondBankException.EconomyDisabledException
*/
@Throws(EconomyDisabledException::class)
@Suppress("unused")
fun getEnderChestShards(uuid: UUID): Long = getShardTypeShards(uuid, ShardType.ENDER_CHEST)
/**
* WARNING: if the player has a transaction lock applied this function will block until its released
*
* This function also blocks for the database call, this is so you don't have to manually run .get() on a
* CompletableFuture
*
* @throws DiamondBankException.EconomyDisabledException
*/
@Throws(EconomyDisabledException::class)
@Suppress("unused")
fun getTotalShards(uuid: UUID): Long = getShardTypeShards(uuid, ShardType.TOTAL)
/**
* WARNING: if the player has a transaction lock applied this function will block until its released
*
* This function also blocks for the database call, this is so you don't have to manually run .get() on a
* CompletableFuture
*
* @throws DiamondBankException.EconomyDisabledException
*/
@Throws(EconomyDisabledException::class)
@Suppress("unused")
fun getAllShards(uuid: UUID): PlayerShards {
if (economyDisabled) throw EconomyDisabledException()
return runBlocking {
transactionLock.withLockSuspend(uuid) {
balanceManager.getAllShards(uuid).getOrElse {
handleError(it)
throw EconomyDisabledException()
}
}
}
}
@Throws(EconomyDisabledException::class)
private fun getShardTypeShards(uuid: UUID, type: ShardType): Long {
if (economyDisabled) throw EconomyDisabledException()
return runBlocking {
transactionLock
.withLockSuspend(uuid) {
when (type) {
ShardType.BANK -> balanceManager.getBankShards(uuid)
ShardType.INVENTORY -> balanceManager.getInventoryShards(uuid)
ShardType.ENDER_CHEST -> balanceManager.getEnderChestShards(uuid)
ShardType.TOTAL -> balanceManager.getTotalShards(uuid)
}
}
.getOrElse {
handleError(it)
throw EconomyDisabledException()
}
}
}
/**
* WARNING: if the player has a transaction lock applied this function will block until its released
*
* This function also blocks for the database call, this is so you don't have to manually run .get() on a
* CompletableFuture
*
* @throws DiamondBankException.EconomyDisabledException
*/
@Throws(EconomyDisabledException::class)
@Suppress("unused")
fun getBaltop(offset: Int): Map<UUID?, Long> {
if (economyDisabled) throw EconomyDisabledException()
return runBlocking {
balanceManager.getBaltop(offset).getOrElse {
handleError(it)
throw EconomyDisabledException()
}
}
}
/**
* WARNING: if the player has a transaction lock applied this function will block until its released
*
* This function also blocks for the database call, this is so you don't have to manually run .get() on a
* CompletableFuture
*
* @param shards must not be negative
* @param transactionReason the reason for this transaction for in the transaction log
* @param notes any specifics for this transaction that may be nice to know for in the transaction log
* @throws DiamondBankException.EconomyDisabledException
* @throws DiamondBankException.InvalidPlayerException
* @throws DiamondBankException.InsufficientFundsException
*/
@Throws(EconomyDisabledException::class, InvalidPlayerException::class, InsufficientFundsException::class)
@Suppress("unused")
fun consumeFromPlayer(uuid: UUID, shards: Long, transactionReason: String, notes: String?) {
require(shards >= 0) { "shards must not be negative" }
if (economyDisabled) throw EconomyDisabledException()
return runBlocking {
transactionLock.withLockSuspend(uuid) {
val player = Bukkit.getOfflinePlayer(uuid)
if (player !is Player && !player.hasPlayedBefore()) throw InvalidPlayerException()
player.uniqueId.withInventoryLockSuspend {
val inventorySnapshot = runOnMainThread { InventorySnapshot.from(player.uniqueId, balanceManager) }
val toSubtract =
CommonOperations.consume(player.uniqueId, shards, inventorySnapshot).getOrElse {
if (it is DatabaseException) {
handleError(it)
throw EconomyDisabledException()
}
throw it
}
balanceManager.subtractFromBankShards(player.uniqueId, toSubtract).getOrElse {
if (it is InsufficientBalanceException) throw it
handleError(it)
throw EconomyDisabledException()
}
runOnMainThread { inventorySnapshot.restoreTo(player.uniqueId) }
}
balanceManager.insertTransactionLog(uuid, shards, null, transactionReason, notes).getOrElse {
handleError(it)
}
}
}
}
/**
* WARNING: if the player has a transaction lock applied this function will block until its released
*
* This function also blocks for the database call, this is so you don't have to manually run .get() on a
* CompletableFuture
*
* @param shards must not be negative
* @param transactionReason the reason for this transaction for in the transaction log
* @param notes any specifics for this transaction that may be nice to know for in the transaction log
* @throws DiamondBankException.EconomyDisabledException
* @throws DiamondBankException.SenderEqualToReceiverException
* @throws DiamondBankException.InvalidPlayerException
* @throws DiamondBankException.InsufficientFundsException
*/
@Throws(
EconomyDisabledException::class,
SenderEqualToReceiverException::class,
InvalidPlayerException::class,
InsufficientFundsException::class,
)
@Suppress("unused")
fun playerPayPlayer(senderUuid: UUID, receiverUuid: UUID, shards: Long, transactionReason: String, notes: String?) {
require(shards >= 0) { "shards must not be negative" }
if (economyDisabled) throw EconomyDisabledException()
if (senderUuid == receiverUuid) throw SenderEqualToReceiverException()
return runBlocking {
transactionLock.withLockSuspend(senderUuid) {
val sender = Bukkit.getPlayer(senderUuid) ?: Bukkit.getOfflinePlayer(senderUuid)
if (sender !is Player && !sender.hasPlayedBefore()) throw InvalidPlayerException()
val receiver = Bukkit.getPlayer(receiverUuid) ?: Bukkit.getOfflinePlayer(receiverUuid)
if (receiver !is Player && !receiver.hasPlayedBefore()) throw InvalidPlayerException()
sender.uniqueId.withInventoryLockSuspend {
val inventorySnapshot = runOnMainThread { InventorySnapshot.from(sender.uniqueId, balanceManager) }
val shardsToSubtractFromSender =
CommonOperations.consume(sender.uniqueId, shards, inventorySnapshot).getOrElse {
if (it is DatabaseException) {
handleError(it)
throw EconomyDisabledException()
}
throw it
}
balanceManager
.transferBankShards(senderUuid, receiverUuid, shardsToSubtractFromSender, shards)
.getOrElse {
if (it is InsufficientBalanceException) throw it
handleError(it)
throw EconomyDisabledException()
}
runOnMainThread { inventorySnapshot.restoreTo(sender.uniqueId) }
}
balanceManager
.insertTransactionLog(senderUuid, shards, receiverUuid, transactionReason, notes)
.getOrElse { handleError(it) }
}
}
}
/** Register a PlayerBalanceChangedListener to listen for player balance changes */
@Suppress("unused")
fun registerEventListener(eventListener: PlayerBalanceChangedListener) {
eventManager.register(eventListener)
}
/**
* WARNING: This function can throw a MoreThanOneDecimalDigitRuntimeException, make sure it's checked when handling
* arbitrary input
*
* Converts a Diamond double to Shards
*/
@Suppress("unused") fun diamondsToShards(diamonds: Double) = diamondsToShards(diamonds.toFloat())
/**
* WARNING: This function can throw a MoreThanOneDecimalDigitRuntimeException, make sure it's checked when handling
* arbitrary input
*
* Converts a Diamond float to Shards
*/
@Suppress("unused") fun diamondsToShards(diamonds: Float) = CommonOperations.diamondsToShards(diamonds).getOrThrow()
/** Converts Shards into a formatted Diamonds string */
@Suppress("unused") fun shardsToDiamonds(shards: Long) = CommonOperations.shardsToDiamonds(shards)
}
@@ -1,283 +0,0 @@
package net.trueog.diamondbankog.api
import java.util.*
import net.trueog.diamondbankog.DiamondBankException.*
import net.trueog.diamondbankog.DiamondBankOG.Companion.balanceManager
import net.trueog.diamondbankog.DiamondBankOG.Companion.economyDisabled
import net.trueog.diamondbankog.DiamondBankOG.Companion.eventManager
import net.trueog.diamondbankog.DiamondBankOG.Companion.transactionLock
import net.trueog.diamondbankog.balance.shard.PlayerShards
import net.trueog.diamondbankog.balance.shard.ShardType
import net.trueog.diamondbankog.transaction.CommonOperations
import net.trueog.diamondbankog.transaction.InventoryLockExtensions.withInventoryLockSuspend
import net.trueog.diamondbankog.transaction.InventorySnapshot
import net.trueog.diamondbankog.util.ErrorHandler.handleError
import net.trueog.diamondbankog.util.MainThreadBlock.runOnMainThread
import org.bukkit.Bukkit
import org.bukkit.entity.Player
class DiamondBankAPIKotlin {
/**
* WARNING: if the player has a transaction lock applied this function will block until its released
*
* @param transactionReason the reason for this transaction for in the transaction log
* @param notes any specifics for this transaction that may be nice to know for in the transaction log
*/
@Suppress("unused")
suspend fun addToPlayerBankShards(
uuid: UUID,
shards: ULong,
transactionReason: String,
notes: String? = null,
): Result<Unit> {
require(shards <= Long.MAX_VALUE.toULong()) { "shards must not be above the max value of a Long" }
if (economyDisabled) return Result.failure(EconomyDisabledException())
return transactionLock.withLockSuspend(uuid) {
val player = Bukkit.getPlayer(uuid) ?: Bukkit.getOfflinePlayer(uuid)
if (player !is Player && !player.hasPlayedBefore())
return@withLockSuspend Result.failure(InvalidPlayerException())
balanceManager.addToBankShards(uuid, shards.toLong()).getOrElse {
handleError(it)
return@withLockSuspend Result.failure(EconomyDisabledException())
}
balanceManager.insertTransactionLog(uuid, shards.toLong(), null, transactionReason, notes).getOrElse {
handleError(it)
}
Result.success(Unit)
}
}
/**
* WARNING: if the player has a transaction lock applied this function will block until its released
*
* @param transactionReason the reason for this transaction for in the transaction log
* @param notes any specifics for this transaction that may be nice to know for in the transaction log
*/
@Suppress("unused")
suspend fun subtractFromPlayerBankShards(
uuid: UUID,
shards: ULong,
transactionReason: String,
notes: String? = null,
): Result<Unit> {
require(shards <= Long.MAX_VALUE.toULong()) { "shards must not be above the max value of a Long" }
if (economyDisabled) return Result.failure(EconomyDisabledException())
return transactionLock.withLockSuspend(uuid) {
val player = Bukkit.getPlayer(uuid) ?: Bukkit.getOfflinePlayer(uuid)
if (player !is Player && !player.hasPlayedBefore())
return@withLockSuspend Result.failure(InvalidPlayerException())
balanceManager.subtractFromBankShards(uuid, shards.toLong()).getOrElse {
if (it is InsufficientBalanceException) return@withLockSuspend Result.failure(it)
handleError(it)
return@withLockSuspend Result.failure(EconomyDisabledException())
}
balanceManager.insertTransactionLog(uuid, shards.toLong(), null, transactionReason, notes).getOrElse {
handleError(it)
}
Result.success(Unit)
}
}
/** WARNING: if the player has a transaction lock applied this function will block until its released */
@Suppress("unused") suspend fun getBankShards(uuid: UUID): Result<Long> = getShardTypeShards(uuid, ShardType.BANK)
/** WARNING: if the player has a transaction lock applied this function will block until its released */
@Suppress("unused")
suspend fun getInventoryShards(uuid: UUID): Result<Long> = getShardTypeShards(uuid, ShardType.INVENTORY)
/** WARNING: if the player has a transaction lock applied this function will block until its released */
@Suppress("unused")
suspend fun getEnderChestShards(uuid: UUID): Result<Long> = getShardTypeShards(uuid, ShardType.ENDER_CHEST)
/** WARNING: if the player has a transaction lock applied this function will block until its released */
@Suppress("unused") suspend fun getTotalShards(uuid: UUID): Result<Long> = getShardTypeShards(uuid, ShardType.TOTAL)
/** WARNING: if the player has a transaction lock applied this function will block until its released */
@Suppress("unused")
suspend fun getAllShards(uuid: UUID): Result<PlayerShards> {
if (economyDisabled) return Result.failure(EconomyDisabledException())
return transactionLock.withLockSuspend(uuid) {
Result.success(
balanceManager.getAllShards(uuid).getOrElse {
handleError(it)
return@withLockSuspend Result.failure(EconomyDisabledException())
}
)
}
}
private suspend fun getShardTypeShards(uuid: UUID, type: ShardType): Result<Long> {
if (economyDisabled) return Result.failure(EconomyDisabledException())
return transactionLock.withLockSuspend(uuid) {
Result.success(
when (type) {
ShardType.BANK -> balanceManager.getBankShards(uuid)
ShardType.INVENTORY -> balanceManager.getInventoryShards(uuid)
ShardType.ENDER_CHEST -> balanceManager.getEnderChestShards(uuid)
ShardType.TOTAL -> balanceManager.getTotalShards(uuid)
}.getOrElse {
handleError(it)
return@withLockSuspend Result.failure(EconomyDisabledException())
}
)
}
}
@Suppress("unused")
suspend fun getBaltop(offset: Int): Result<Map<UUID?, Long>> {
if (economyDisabled) return Result.failure(EconomyDisabledException())
return Result.success(
balanceManager.getBaltop(offset).getOrElse {
handleError(it)
return Result.failure(EconomyDisabledException())
}
)
}
/**
* WARNING: if the player has a transaction lock applied this function will block until its released
*
* @param transactionReason the reason for this transaction for in the transaction log
* @param notes any specifics for this transaction that may be nice to know for in the transaction log
*/
@Suppress("unused")
suspend fun consumeFromPlayer(
uuid: UUID,
shards: ULong,
transactionReason: String,
notes: String? = null,
): Result<Unit> {
require(shards <= Long.MAX_VALUE.toULong()) { "shards must not be above the max value of a Long" }
if (economyDisabled) return Result.failure(EconomyDisabledException())
return transactionLock.withLockSuspend(uuid) {
val player = Bukkit.getOfflinePlayer(uuid)
if (player !is Player && !player.hasPlayedBefore())
return@withLockSuspend Result.failure(InvalidPlayerException())
player.uniqueId
.withInventoryLockSuspend {
val inventorySnapshot = runOnMainThread { InventorySnapshot.from(player.uniqueId, balanceManager) }
val toSubtract =
CommonOperations.consume(player.uniqueId, shards.toLong(), inventorySnapshot).getOrElse {
if (it is DatabaseException) {
handleError(it)
return@withInventoryLockSuspend Result.failure(EconomyDisabledException())
}
return@withInventoryLockSuspend Result.failure(it)
}
balanceManager.subtractFromBankShards(player.uniqueId, toSubtract).getOrElse {
if (it is InsufficientBalanceException) return@withInventoryLockSuspend Result.failure(it)
handleError(it)
return@withInventoryLockSuspend Result.failure(EconomyDisabledException())
}
runOnMainThread { inventorySnapshot.restoreTo(player.uniqueId) }
Result.success(Unit)
}
.getOrElse {
return@withLockSuspend Result.failure(it)
}
balanceManager.insertTransactionLog(uuid, shards.toLong(), null, transactionReason, notes).getOrElse {
handleError(it)
}
Result.success(Unit)
}
}
/**
* WARNING: if the player has a transaction lock applied this function will block until its released
*
* WARNING: This function can return a CouldNotRemoveEnoughException, make sure you handle it properly. It has a
* field called notRemoved that has the amount of shards not removed, you should continue with the originally
* requested amount of shards minus notRemoved
*
* @param transactionReason the reason for this transaction for in the transaction log
* @param notes any specifics for this transaction that may be nice to know for in the transaction log
*/
@Suppress("unused")
suspend fun playerPayPlayer(
senderUuid: UUID,
receiverUuid: UUID,
shards: ULong,
transactionReason: String,
notes: String? = null,
): Result<Unit> {
require(shards <= Long.MAX_VALUE.toULong()) { "shards must not be above the max value of a Long" }
if (economyDisabled) return Result.failure(EconomyDisabledException())
if (senderUuid == receiverUuid) return Result.failure(SenderEqualToReceiverException())
return transactionLock.withLockSuspend(senderUuid) {
val sender = Bukkit.getPlayer(senderUuid) ?: Bukkit.getOfflinePlayer(senderUuid)
if (sender !is Player && !sender.hasPlayedBefore())
return@withLockSuspend Result.failure(InvalidPlayerException())
val receiver = Bukkit.getPlayer(receiverUuid) ?: Bukkit.getOfflinePlayer(receiverUuid)
if (receiver !is Player && !receiver.hasPlayedBefore())
return@withLockSuspend Result.failure(InvalidPlayerException())
sender.uniqueId
.withInventoryLockSuspend {
val inventorySnapshot = runOnMainThread { InventorySnapshot.from(sender.uniqueId, balanceManager) }
val shardsToSubtractFromSender =
CommonOperations.consume(sender.uniqueId, shards.toLong(), inventorySnapshot).getOrElse {
if (it is DatabaseException) {
handleError(it)
return@withInventoryLockSuspend Result.failure(EconomyDisabledException())
}
return@withInventoryLockSuspend Result.failure(it)
}
balanceManager
.transferBankShards(senderUuid, receiverUuid, shardsToSubtractFromSender, shards.toLong())
.getOrElse {
if (it is InsufficientBalanceException) return@withInventoryLockSuspend Result.failure(it)
handleError(it)
return@withInventoryLockSuspend Result.failure(EconomyDisabledException())
}
runOnMainThread { inventorySnapshot.restoreTo(sender.uniqueId) }
Result.success(Unit)
}
.getOrElse {
return@withLockSuspend Result.failure(it)
}
balanceManager
.insertTransactionLog(senderUuid, shards.toLong(), receiverUuid, transactionReason, notes)
.getOrElse { handleError(it) }
Result.success(Unit)
}
}
@Suppress("unused")
fun registerEventListener(eventListener: PlayerBalanceChangedListener) {
eventManager.register(eventListener)
}
/** Converts a Diamond double to Shards */
@Suppress("unused") fun diamondsToShards(diamonds: Double) = diamondsToShards(diamonds.toFloat())
/** Converts a Diamond float to Shards */
@Suppress("unused") fun diamondsToShards(diamonds: Float) = CommonOperations.diamondsToShards(diamonds)
/** Converts Shards into a formatted Diamonds string */
@Suppress("unused") fun shardsToDiamonds(shards: Long) = CommonOperations.shardsToDiamonds(shards)
}
@@ -1,44 +0,0 @@
package net.trueog.diamondbankog.api
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.logging.Level
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import net.trueog.diamondbankog.DiamondBankOG.Companion.plugin
import net.trueog.diamondbankog.DiamondBankOG.Companion.scope
import net.trueog.diamondbankog.balance.shard.PlayerShards
internal class EventManager {
private var listeners = CopyOnWriteArrayList<PlayerBalanceChangedListener>()
private val queues = ConcurrentHashMap<UUID, Channel<PlayerBalanceChangedEvent>>()
// One channel + one consumer coroutine per uuid. Consumer processes events
// strictly in send order, off any lock, so listeners can safely call back
// into the API without deadlocking TransactionLock.
private fun queueFor(uuid: UUID) =
queues.computeIfAbsent(uuid) {
Channel<PlayerBalanceChangedEvent>(Channel.UNLIMITED).also { channel ->
scope.launch {
for (event in channel) {
listeners.forEach {
try {
it.onUpdate(event)
} catch (e: Exception) {
plugin.logger.log(Level.SEVERE, "PlayerBalanceChangedEvent listener threw", e)
}
}
}
}
}
}
fun sendUpdate(uuid: UUID, playerShards: PlayerShards) {
queueFor(uuid).trySend(PlayerBalanceChangedEvent(uuid, playerShards))
}
fun register(listener: PlayerBalanceChangedListener) {
listeners += listener
}
}
@@ -1,6 +0,0 @@
package net.trueog.diamondbankog.api
import java.util.*
import net.trueog.diamondbankog.balance.shard.PlayerShards
data class PlayerBalanceChangedEvent(val uuid: UUID, val playerShards: PlayerShards)
@@ -1,5 +0,0 @@
package net.trueog.diamondbankog.api
interface PlayerBalanceChangedListener {
fun onUpdate(event: PlayerBalanceChangedEvent)
}
@@ -1,279 +0,0 @@
# DiamondBank-OG API
## Getting started
In your `onEnable` function:\
Kotlin:
```kotlin
val diamondBankAPIProvider = server.servicesManager.getRegistration(DiamondBankAPIKotlin::class.java)
if (diamondBankAPIProvider == null) {
logger.severe("DiamondBank-OG API is null")
Bukkit.getPluginManager().disablePlugin(this)
return
}
val diamondBankAPI = diamondBankAPIProvider.provider
```
Java:
```java
final RegisteredServiceProvider<DiamondBankAPIJava> diamondBankAPIProvider = getServer().getServicesManager().getRegistration(DiamondBankAPIJava.class);
if (diamondBankAPIProvider == null) {
getLogger().severe("DiamondBank-OG API is null");
Bukkit.getPluginManager().disablePlugin(this);
return;
}
DiamondBankAPIJava diamondBankAPI = diamondBankAPIProvider.getProvider();
```
## Available functions
> [!IMPORTANT]
> Always convert Diamonds to Shards before using the API using [`diamondsToShards`](#diamondstoshards).
Exceptions are only applicable when using the Java API, when using the Kotlin API the function will return a `Result` instead.
### addToPlayerShards
```kotlin
/**
* WARNING: if the player has a transaction lock applied this function will block until its released.
*
* This function also blocks for the database call, this is so you don't have to manually run .get() on a
* CompletableFuture
*
* @param shards must not be negative
* @param transactionReason the reason for this transaction for in the transaction log
* @param notes any specifics for this transaction that may be nice to know for in the transaction log
* @throws DiamondBankException.EconomyDisabledException
* @throws DiamondBankException.InvalidPlayerException
*/
```
Kotlin:
```kotlin
suspend fun addToPlayerBankShards(uuid: UUID, shards: ULong, transactionReason: String, notes: String?): Result<Unit>
```
Java:
```java
public void addToPlayerBankShards(UUID uuid, long shards, String transactionReason, @Nullable String notes)
```
### subtractFromPlayerBankShards
```kotlin
/**
* WARNING: if the player has a transaction lock applied this function will block until its released
*
* This function also blocks for the database call, this is so you don't have to manually run .get() on a
* CompletableFuture
*
* @param shards must not be negative
* @param transactionReason the reason for this transaction for in the transaction log
* @param notes any specifics for this transaction that may be nice to know for in the transaction log
* @throws DiamondBankException.EconomyDisabledException
* @throws DiamondBankException.InsufficientBalanceException
* @throws DiamondBankException.InvalidPlayerException
*/
```
Kotlin:
```kotlin
suspend fun subtractFromPlayerBankShards(uuid: UUID, shards: ULong, transactionReason: String, notes: String?): Result<Unit>
```
Java:
```java
public void subtractFromPlayerBankShards(UUID uuid, long shards, String transactionReason, @Nullable String notes)
```
### getBankShards
```kotlin
/**
* WARNING: if the player has a transaction lock applied this function will block until its released
*
* This function also blocks for the database call, this is so you don't have to manually run .get() on a
* CompletableFuture
*
* @throws DiamondBankException.EconomyDisabledException
*/
```
Kotlin:
```kotlin
suspend fun getBankShards(uuid: UUID): Result<Long>
```
Java:
```java
public long getBankShards(UUID uuid)
```
### getInventoryShards
```kotlin
/**
* WARNING: if the player has a transaction lock applied this function will block until its released
*
* This function also blocks for the database call, this is so you don't have to manually run .get() on a
* CompletableFuture
*
* @throws DiamondBankException.EconomyDisabledException
*/
```
Kotlin:
```kotlin
suspend fun getInventoryShards(uuid: UUID): Result<Long>
```
Java:
```java
public long getInventoryShards(UUID uuid)
```
### getEnderChestShards
```kotlin
/**
* WARNING: if the player has a transaction lock applied this function will block until its released
*
* This function also blocks for the database call, this is so you don't have to manually run .get() on a
* CompletableFuture
*
* @throws DiamondBankException.EconomyDisabledException
*/
```
Kotlin:
```kotlin
suspend fun getEnderChestShards(uuid: UUID): Result<Long>
```
Java:
```java
public long getEnderChestShards(UUID uuid)
```
### getTotalShards
```kotlin
/**
* WARNING: if the player has a transaction lock applied this function will block until its released
*
* This function also blocks for the database call, this is so you don't have to manually run .get() on a
* CompletableFuture
*
* @throws DiamondBankException.EconomyDisabledException
*/
```
Kotlin:
```kotlin
suspend fun getTotalShards(uuid: UUID): Result<Long>
```
Java:
```java
public long getTotalShards(UUID uuid)
```
### getBaltop
```kotlin
/**
* WARNING: if the player has a transaction lock applied this function will block until its released
*
* This function also blocks for the database call, this is so you don't have to manually run .get() on a
* CompletableFuture
*
* @throws DiamondBankException.EconomyDisabledException
*/
```
Kotlin:
```kotlin
suspend fun getBaltop(offset: Int): Result<Map<UUID?, Long>>
```
Java:
```java
public Map<@Nullable UUID, Long> getBaltop(int offset)
```
### consumeFromPlayer
```kotlin
/**
* WARNING: if the player has a transaction lock applied this function will block until its released
*
* This function also blocks for the database call, this is so you don't have to manually run .get() on a
* CompletableFuture
*
* @param shards must not be negative
* @param transactionReason the reason for this transaction for in the transaction log
* @param notes any specifics for this transaction that may be nice to know for in the transaction log
* @throws DiamondBankException.EconomyDisabledException
* @throws DiamondBankException.InvalidPlayerException
* @throws DiamondBankException.InsufficientFundsException
*/
```
Kotlin:
```kotlin
suspend fun consumeFromPlayer(uuid: UUID, shards: ULong, transactionReason: String, notes: String?): Result<Unit>
```
Java:
```java
public void consumeFromPlayer(UUID uuid, long shards, String transactionReason, @Nullable String notes)
```
### playerPayPlayer
```kotlin
/**
* WARNING: if the player has a transaction lock applied this function will block until its released
*
* This function also blocks for the database call, this is so you don't have to manually run .get() on a
* CompletableFuture
*
* @param shards must not be negative
* @param transactionReason the reason for this transaction for in the transaction log
* @param notes any specifics for this transaction that may be nice to know for in the transaction log
* @throws DiamondBankException.EconomyDisabledException
* @throws DiamondBankException.SenderEqualToReceiverException
* @throws DiamondBankException.InvalidPlayerException
* @throws DiamondBankException.InsufficientFundsException
*/
```
Kotlin:
```kotlin
suspend fun playerPayPlayer(senderUuid: UUID, receiverUuid: UUID, shards: ULong, transactionReason: String, notes: String?): Result<Unit>
```
Java:
```java
public void playerPayPlayer(UUID senderUuid, UUID receiverUuid, long shards, String transactionReason, @Nullable String notes)
```
### registerEventListener
```kotlin
/**
* Register a PlayerBalanceChangedListener to listen for player balance changes
*/
```
Kotlin:
```kotlin
fun registerEventListener(eventListener: PlayerBalanceChangedListener)
```
Java:
```java
public void registerEventListener(PlayerBalanceChangedListener eventListener)
```
### diamondsToShards
> [!NOTE]
> This function also has an overload for a double.
```kotlin
/**
* WARNING: This function can throw a MoreThanOneDecimalDigitRuntimeException,
* make sure it's checked when handling arbitrary input
*
* Converts a Diamond float to Shards
*/
```
Kotlin:
```kotlin
fun diamondsToShards(diamonds: Float): Result<Long>
```
Java:
```java
public long diamondsToShards(float diamonds)
```
### shardsToDiamonds
> [!CAUTION]
> This function should only be used for visual output, not calculations.
```kotlin
/** Converts Shards into a formatted Diamonds string */
```
Kotlin:
```kotlin
fun shardsToDiamonds(shards: Long): String
```
Java:
```java
public String shardsToDiamonds(long shards)
```
@@ -1,164 +0,0 @@
package net.trueog.diamondbankog.autocompress
import kotlin.math.abs
import kotlinx.coroutines.launch
import net.trueog.diamondbankog.DiamondBankOG.Companion.balanceManager
import net.trueog.diamondbankog.DiamondBankOG.Companion.config
import net.trueog.diamondbankog.DiamondBankOG.Companion.mm
import net.trueog.diamondbankog.DiamondBankOG.Companion.scope
import net.trueog.diamondbankog.DiamondBankOG.Companion.transactionLock
import net.trueog.diamondbankog.balance.shard.Shard
import net.trueog.diamondbankog.transaction.InventoryLockExtensions.withInventoryLockSuspend
import net.trueog.diamondbankog.transaction.InventorySnapshot
import net.trueog.diamondbankog.util.InventoryExtensions.removeItemWithSameKey
import net.trueog.diamondbankog.util.MainThreadBlock.runOnMainThread
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countDiamondBlocks
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countDiamonds
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countShards
import org.bukkit.Material
import org.bukkit.entity.Player
import org.bukkit.inventory.ItemStack
internal object AutoCompress {
fun compress(player: Player) {
scope.launch {
val worldName = player.world.name
if (worldName != "world" && worldName != "world_nether" && worldName != "world_the_end") {
return@launch
}
if (!player.hasPermission("diamondbank-og.compress")) {
player.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>You do not have permission to compress.")
)
return@launch
}
transactionLock.withLockSuspend(player.uniqueId) {
player.uniqueId.withInventoryLockSuspend {
val inventorySnapshot = runOnMainThread { InventorySnapshot.from(player.uniqueId, balanceManager) }
val shardsInInventory = inventorySnapshot.countShards().toInt()
val diamondsInInventory = inventorySnapshot.countDiamonds().toInt()
val diamondBlocksInInventory = inventorySnapshot.countDiamondBlocks().toInt()
var finalShards = shardsInInventory
var finalDiamonds = diamondsInInventory
var finalDiamondBlocks = diamondBlocksInInventory
finalDiamonds += finalShards / 9
if (finalDiamonds - diamondsInInventory != 0) {
val remainder = finalShards % 9
finalShards = remainder
}
finalDiamondBlocks += finalDiamonds / 9
if (finalDiamondBlocks - diamondBlocksInInventory != 0) {
val remainder = finalDiamonds % 9
finalDiamonds = remainder
}
val changeInShards = finalShards - shardsInInventory
val changeInDiamonds = finalDiamonds - diamondsInInventory
val changeInDiamondBlocks = finalDiamondBlocks - diamondBlocksInInventory
if (changeInDiamonds > 0) {
val emptySlots = inventorySnapshot.storageContents.filter { it == null }.size * 64
val leftOverSpaceDiamonds =
inventorySnapshot.storageContents
.filterNotNull()
.filter { it.type == Material.DIAMOND }
.sumOf { 64 - it.amount }
if (changeInDiamonds > emptySlots + leftOverSpaceDiamonds) {
player.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>You do not have enough space in your inventory to compress all the Diamond currency items (<green>+$changeInDiamonds <aqua>Diamonds<red>)."
)
)
return@withInventoryLockSuspend
}
}
if (changeInDiamondBlocks > 0) {
val emptySlots = inventorySnapshot.storageContents.filter { it == null }.size * 64
val leftOverSpaceDiamondBlocks =
inventorySnapshot.storageContents
.filterNotNull()
.filter { it.type == Material.DIAMOND_BLOCK }
.sumOf { 64 - it.amount }
if (changeInDiamondBlocks > emptySlots + leftOverSpaceDiamondBlocks) {
player.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>You do not have enough space in your inventory to compress all the Diamond currency items (<green>+$changeInDiamondBlocks <aqua>Diamond Blocks<red>)."
)
)
return@withInventoryLockSuspend
}
}
if (changeInShards < 0) {
val removeMap =
inventorySnapshot.removeItemWithSameKey(Shard.createItemStack(abs(changeInShards)))
if (removeMap.isNotEmpty()) {
player.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Something went wrong while trying to compress the Diamond currency items in your inventory."
)
)
return@withInventoryLockSuspend
}
}
if (changeInDiamonds > 0) {
val addMap = inventorySnapshot.addItem(ItemStack(Material.DIAMOND, changeInDiamonds))
if (addMap.isNotEmpty()) {
player.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Something went wrong while trying to compress the Diamond currency items in your inventory."
)
)
return@withInventoryLockSuspend
}
} else if (changeInDiamonds < 0) {
val removeMap = inventorySnapshot.removeItem(ItemStack(Material.DIAMOND, abs(changeInDiamonds)))
if (removeMap.isNotEmpty()) {
player.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Something went wrong while trying to compress the Diamond currency items in your inventory."
)
)
return@withInventoryLockSuspend
}
}
if (changeInDiamondBlocks > 0) {
val addMap = inventorySnapshot.addItem(ItemStack(Material.DIAMOND_BLOCK, changeInDiamondBlocks))
if (addMap.isNotEmpty()) {
player.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Something went wrong while trying to compress the Diamond currency items in your inventory."
)
)
return@withInventoryLockSuspend
}
} else if (changeInDiamondBlocks < 0) {
val removeMap =
inventorySnapshot.removeItem(ItemStack(Material.DIAMOND_BLOCK, abs(changeInDiamondBlocks)))
if (removeMap.isNotEmpty()) {
player.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Something went wrong while trying to compress the Diamond currency items in your inventory."
)
)
return@withInventoryLockSuspend
}
}
runOnMainThread { inventorySnapshot.restoreTo(player.uniqueId) }
}
}
}
}
}
@@ -1,52 +0,0 @@
package net.trueog.diamondbankog.autocompress.command
import net.trueog.diamondbankog.DiamondBankOG.Companion.config
import net.trueog.diamondbankog.DiamondBankOG.Companion.economyDisabled
import net.trueog.diamondbankog.DiamondBankOG.Companion.mm
import net.trueog.diamondbankog.DiamondBankOG.Companion.redis
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
import org.bukkit.entity.Player
internal class AutoCompress : CommandExecutor {
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>?): Boolean {
if (economyDisabled) {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>The economy is disabled. Please notify a staff member.")
)
return true
}
if (sender !is Player) {
sender.sendMessage("You can only execute this command as a player.")
return true
}
if (!sender.hasPermission("diamondbank-og.compress")) {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>You do not have permission to use this command.")
)
return true
}
if (redis.getValue("diamondbankog:${sender.uniqueId}:autocompress") == "true") {
redis.setValue("diamondbankog:${sender.uniqueId}:autocompress", "false")
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <green>Successfully turned auto-compress <red>off<green>.")
)
} else {
if (redis.getValue("diamondbankog:${sender.uniqueId}:autodeposit") == "true") {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <#FFA500>You cannot have both auto-compress and auto-deposit on at the same time, so auto-deposit has been turned <red>off<#FFA500>."
)
)
redis.setValue("diamondbankog:${sender.uniqueId}:autodeposit", "false")
}
redis.setValue("diamondbankog:${sender.uniqueId}:autocompress", "true")
sender.sendMessage(mm.deserialize("${config.prefix}<reset>: <green>Successfully turned auto-compress on."))
}
return true
}
}
@@ -1,56 +0,0 @@
package net.trueog.diamondbankog.autodeposit
import kotlinx.coroutines.launch
import net.trueog.diamondbankog.DiamondBankOG.Companion.balanceManager
import net.trueog.diamondbankog.DiamondBankOG.Companion.config
import net.trueog.diamondbankog.DiamondBankOG.Companion.mm
import net.trueog.diamondbankog.DiamondBankOG.Companion.scope
import net.trueog.diamondbankog.DiamondBankOG.Companion.transactionLock
import net.trueog.diamondbankog.balance.shard.Shard
import net.trueog.diamondbankog.util.ErrorHandler
import org.bukkit.Material
import org.bukkit.entity.Item
import org.bukkit.entity.Player
internal object AutoDeposit {
fun deposit(player: Player, item: Item, remaining: Int) {
val worldName = player.world.name
if (worldName != "world" && worldName != "world_nether" && worldName != "world_the_end") {
return
}
if (!player.hasPermission("diamondbank-og.deposit")) {
player.sendMessage(mm.deserialize("${config.prefix}<reset>: <red>You do not have permission to deposit."))
return
}
val itemStack = item.itemStack.clone().apply { amount += remaining }
val shards =
if (itemStack.type == Material.DIAMOND_BLOCK) {
(itemStack.amount * 9 * 9).toLong()
} else if (itemStack.type == Material.DIAMOND) {
(itemStack.amount * 9).toLong()
} else if (Shard.isShardItem(itemStack)) {
itemStack.amount.toLong()
} else {
return
}
itemStack.amount = 0
item.itemStack = itemStack
scope.launch {
transactionLock.withLockSuspend(player.uniqueId) {
balanceManager.addToBankShards(player.uniqueId, shards).getOrElse {
ErrorHandler.handleError(it)
player.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>A severe error has occurred. Please notify a staff member."
)
)
return@withLockSuspend
}
}
}
}
}
@@ -1,52 +0,0 @@
package net.trueog.diamondbankog.autodeposit.command
import net.trueog.diamondbankog.DiamondBankOG.Companion.config
import net.trueog.diamondbankog.DiamondBankOG.Companion.economyDisabled
import net.trueog.diamondbankog.DiamondBankOG.Companion.mm
import net.trueog.diamondbankog.DiamondBankOG.Companion.redis
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
import org.bukkit.entity.Player
internal class AutoDeposit : CommandExecutor {
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>?): Boolean {
if (economyDisabled) {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>The economy is disabled. Please notify a staff member.")
)
return true
}
if (sender !is Player) {
sender.sendMessage("You can only execute this command as a player.")
return true
}
if (!sender.hasPermission("diamondbank-og.deposit")) {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>You do not have permission to use this command.")
)
return true
}
if (redis.getValue("diamondbankog:${sender.uniqueId}:autodeposit") == "true") {
redis.setValue("diamondbankog:${sender.uniqueId}:autodeposit", "false")
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <green>Successfully turned auto-deposit <red>off<green>.")
)
} else {
if (redis.getValue("diamondbankog:${sender.uniqueId}:autocompress") == "true") {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <#FFA500>You cannot have both auto-deposit and auto-compress on at the same time, so auto-compress has been turned <red>off<#FFA500>."
)
)
redis.setValue("diamondbankog:${sender.uniqueId}:autocompress", "false")
}
redis.setValue("diamondbankog:${sender.uniqueId}:autodeposit", "true")
sender.sendMessage(mm.deserialize("${config.prefix}<reset>: <green>Successfully turned auto-deposit on."))
}
return true
}
}
@@ -1,54 +0,0 @@
package net.trueog.diamondbankog.balance
import java.util.*
import net.trueog.diamondbankog.balance.shard.PlayerShards
import net.trueog.diamondbankog.balance.shard.ShardType
interface BalanceManager {
suspend fun setPlayerShards(uuid: UUID, shards: Long, type: ShardType): Result<Unit>
suspend fun addToBankShards(uuid: UUID, shards: Long): Result<Unit>
suspend fun subtractFromBankShards(uuid: UUID, shards: Long): Result<Unit>
suspend fun transferBankShards(
sender: UUID,
receiver: UUID,
shardsToSubtractFromSender: Long,
shardsToAddToReceiver: Long,
): Result<Unit>
suspend fun getBankShards(uuid: UUID): Result<Long>
suspend fun getInventoryShards(uuid: UUID): Result<Long>
suspend fun getEnderChestShards(uuid: UUID): Result<Long>
suspend fun getShardTypeShards(uuid: UUID, type: ShardType): Result<Long>
suspend fun getTotalShards(uuid: UUID): Result<Long>
suspend fun getAllShards(uuid: UUID): Result<PlayerShards>
suspend fun getBaltop(offset: Int): Result<Map<UUID?, Long>>
suspend fun getBaltopWithUuid(uuid: UUID): Result<Pair<Map<UUID?, Long>, Long>>
suspend fun getNumberOfRows(): Result<Long>
suspend fun insertTransactionLog(
playerUuid: UUID,
transferredShards: Long,
playerToUuid: UUID?,
transactionReason: String,
notes: String?,
): Result<Unit>
suspend fun hasEntry(uuid: UUID): Result<Boolean>
suspend fun cacheForPlayer(uuid: UUID): Result<Unit>
suspend fun removeCacheForPlayer(uuid: UUID)
fun shutdown()
}
@@ -1,5 +0,0 @@
package net.trueog.diamondbankog.balance
interface BalanceManagerFactory {
fun create(): BalanceManager?
}
@@ -1,82 +0,0 @@
package net.trueog.diamondbankog.balance
import it.unimi.dsi.fastutil.objects.Object2LongOpenHashMap
import java.util.*
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.write
import net.trueog.diamondbankog.DiamondBankException.InvalidArgumentException
import net.trueog.diamondbankog.balance.shard.ShardType
internal class Cache {
private val bankBalanceCache = Object2LongOpenHashMap<UUID>().apply { defaultReturnValue(-1L) }
private val bankBalanceCacheLock = ReentrantReadWriteLock()
private val inventoryBalanceCache = Object2LongOpenHashMap<UUID>().apply { defaultReturnValue(-1L) }
private val inventoryBalanceCacheLock = ReentrantReadWriteLock()
private val enderChestBalanceCache = Object2LongOpenHashMap<UUID>().apply { defaultReturnValue(-1L) }
private val enderChestBalanceCacheLock = ReentrantReadWriteLock()
fun setBalance(uuid: UUID, value: Long, type: ShardType): Result<Unit> {
if (type == ShardType.TOTAL) return Result.failure(InvalidArgumentException())
when (type) {
ShardType.BANK -> {
bankBalanceCacheLock.write { bankBalanceCache.put(uuid, value) }
}
ShardType.INVENTORY -> {
inventoryBalanceCacheLock.write { inventoryBalanceCache.put(uuid, value) }
}
ShardType.ENDER_CHEST -> {
enderChestBalanceCacheLock.write { enderChestBalanceCache.put(uuid, value) }
}
else -> {}
}
return Result.success(Unit)
}
fun getBalance(uuid: UUID, type: ShardType): Result<Long> {
return when (type) {
ShardType.BANK -> {
bankBalanceCacheLock.read { Result.success(bankBalanceCache.getLong(uuid)) }
}
ShardType.INVENTORY -> {
inventoryBalanceCacheLock.read { Result.success(inventoryBalanceCache.getLong(uuid)) }
}
ShardType.ENDER_CHEST -> {
enderChestBalanceCacheLock.read { Result.success(enderChestBalanceCache.getLong(uuid)) }
}
else -> Result.failure(InvalidArgumentException())
}
}
fun removeBalance(uuid: UUID, type: ShardType): Result<Unit> {
if (type == ShardType.TOTAL) return Result.failure(InvalidArgumentException())
when (type) {
ShardType.BANK -> {
bankBalanceCacheLock.write { bankBalanceCache.removeLong(uuid) }
}
ShardType.INVENTORY -> {
inventoryBalanceCacheLock.write { inventoryBalanceCache.removeLong(uuid) }
}
ShardType.ENDER_CHEST -> {
enderChestBalanceCacheLock.write { enderChestBalanceCache.removeLong(uuid) }
}
else -> {}
}
return Result.success(Unit)
}
fun removeAll(uuid: UUID) {
removeBalance(uuid, ShardType.BANK)
removeBalance(uuid, ShardType.INVENTORY)
removeBalance(uuid, ShardType.ENDER_CHEST)
}
}
@@ -1,218 +0,0 @@
package net.trueog.diamondbankog.balance
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import net.trueog.diamondbankog.DiamondBankException.EconomyDisabledException
import net.trueog.diamondbankog.DiamondBankException.InsufficientBalanceException
import net.trueog.diamondbankog.DiamondBankException.InvalidArgumentException
import net.trueog.diamondbankog.DiamondBankOG.Companion.debug
import net.trueog.diamondbankog.DiamondBankOG.Companion.economyDisabled
import net.trueog.diamondbankog.DiamondBankOG.Companion.plugin
import net.trueog.diamondbankog.balance.shard.PlayerShards
import net.trueog.diamondbankog.balance.shard.ShardType
import net.trueog.diamondbankog.persistence.PostgreSQL
internal class CachingBalanceManager private constructor() : BalanceManager {
private val cache = Cache()
private lateinit var postgreSQL: PostgreSQL
companion object : BalanceManagerFactory {
override fun create(): BalanceManager? {
val cachingBalanceManager = CachingBalanceManager()
cachingBalanceManager.postgreSQL = PostgreSQL.create() ?: return null
return cachingBalanceManager
}
}
private val mutexMap = ConcurrentHashMap<UUID, ConcurrentHashMap<ShardType, Mutex>>()
private suspend fun <T> withLock(uuid: UUID, vararg types: ShardType, block: suspend () -> T): T {
val shardTypeToMutex = mutexMap.computeIfAbsent(uuid) { ConcurrentHashMap() }
val mutexes = types.sorted().map { shardTypeToMutex.computeIfAbsent(it) { Mutex() } }
return mutexes.foldRight(block) { mutex, acc -> { mutex.withLock { acc() } } }()
}
private suspend fun <T> withLock(type: ShardType, vararg uuids: UUID, block: suspend () -> T): T {
val mutexes =
uuids.sorted().map { uuid ->
mutexMap.computeIfAbsent(uuid) { ConcurrentHashMap() }.computeIfAbsent(type) { Mutex() }
}
return mutexes.foldRight(block) { mutex, acc -> { mutex.withLock { acc() } } }()
}
override suspend fun setPlayerShards(uuid: UUID, shards: Long, type: ShardType): Result<Unit> {
if (type == ShardType.TOTAL) return Result.failure(InvalidArgumentException())
if (economyDisabled) return Result.failure(EconomyDisabledException())
return withLock(uuid, type) {
postgreSQL.setPlayerShards(uuid, shards, type).getOrElse {
return@withLock Result.failure(it)
}
cache.setBalance(uuid, shards, type)
return@withLock Result.success(Unit)
}
}
private suspend fun addToPlayerShards(uuid: UUID, shards: Long, type: ShardType): Result<Unit> {
if (type == ShardType.TOTAL) return Result.failure(InvalidArgumentException())
if (economyDisabled) return Result.failure(EconomyDisabledException())
return withLock(uuid, type) {
val currentBalance =
postgreSQL.getShardTypeShards(uuid, type).getOrElse {
return@withLock Result.failure(it)
}
if (currentBalance + shards < 0) {
return@withLock Result.failure(InsufficientBalanceException(currentBalance))
}
val newBalance =
postgreSQL.addToPlayerShards(uuid, shards, type).getOrElse {
return@withLock Result.failure(it)
}
cache.setBalance(uuid, newBalance, type).getOrElse {
return@withLock Result.failure(it)
}
return@withLock Result.success(Unit)
}
}
override suspend fun addToBankShards(uuid: UUID, shards: Long): Result<Unit> =
addToPlayerShards(uuid, shards, ShardType.BANK)
override suspend fun subtractFromBankShards(uuid: UUID, shards: Long): Result<Unit> =
addToPlayerShards(uuid, -shards, ShardType.BANK)
override suspend fun transferBankShards(
sender: UUID,
receiver: UUID,
shardsToSubtractFromSender: Long,
shardsToAddToReceiver: Long,
): Result<Unit> {
require(shardsToSubtractFromSender >= 0) { "shardsToSubtractFromSender must not be negative" }
require(shardsToAddToReceiver >= 0) { "shardsToAddToReceiver must not be negative" }
require(sender != receiver) { "sender and receiver must not be equal" }
if (economyDisabled) return Result.failure(EconomyDisabledException())
return withLock(ShardType.BANK, sender, receiver) {
val currentBalanceFrom =
postgreSQL.getShardTypeShards(sender, ShardType.BANK).getOrElse {
return@withLock Result.failure(it)
}
if (currentBalanceFrom < shardsToSubtractFromSender) {
return@withLock Result.failure(InsufficientBalanceException(currentBalanceFrom))
}
postgreSQL
.transferBankShards(sender, receiver, shardsToSubtractFromSender, shardsToAddToReceiver)
.getOrElse {
return@withLock Result.failure(it)
}
.forEach { (uuid, shards) -> cache.setBalance(uuid, shards, ShardType.BANK) }
return@withLock Result.success(Unit)
}
}
override suspend fun getBankShards(uuid: UUID): Result<Long> = getShardTypeShards(uuid, ShardType.BANK)
override suspend fun getInventoryShards(uuid: UUID): Result<Long> = getShardTypeShards(uuid, ShardType.INVENTORY)
override suspend fun getEnderChestShards(uuid: UUID): Result<Long> = getShardTypeShards(uuid, ShardType.ENDER_CHEST)
override suspend fun getShardTypeShards(uuid: UUID, type: ShardType): Result<Long> {
val cacheBalance =
cache.getBalance(uuid, type).getOrElse {
return Result.failure(it)
}
if (cacheBalance != -1L) return Result.success(cacheBalance)
return withLock(uuid, type) {
if (debug) {
plugin.logger.info("Cache miss for $uuid!")
}
val dbBalance =
postgreSQL.getShardTypeShards(uuid, type).getOrElse {
return@withLock Result.failure(it)
}
cache.setBalance(uuid, dbBalance, type)
return@withLock Result.success(dbBalance)
}
}
override suspend fun getTotalShards(uuid: UUID): Result<Long> = getAllShards(uuid).map { it.total }
override suspend fun getAllShards(uuid: UUID): Result<PlayerShards> {
val cacheBankBalance =
cache.getBalance(uuid, ShardType.BANK).getOrElse {
return Result.failure(it)
}
val cacheInventoryBalance =
cache.getBalance(uuid, ShardType.INVENTORY).getOrElse {
return Result.failure(it)
}
val cacheEnderChestBalance =
cache.getBalance(uuid, ShardType.ENDER_CHEST).getOrElse {
return Result.failure(it)
}
if (cacheBankBalance != -1L && cacheInventoryBalance != -1L && cacheEnderChestBalance != -1L) {
return Result.success(PlayerShards(cacheBankBalance, cacheInventoryBalance, cacheEnderChestBalance))
}
return withLock(uuid, ShardType.BANK, ShardType.INVENTORY, ShardType.ENDER_CHEST) {
if (debug) {
plugin.logger.info("Cache miss for $uuid!")
}
val dbPlayerShards =
postgreSQL.getAllShards(uuid).getOrElse {
return@withLock Result.failure(it)
}
cache.setBalance(uuid, dbPlayerShards.bank, ShardType.BANK)
cache.setBalance(uuid, dbPlayerShards.inventory, ShardType.INVENTORY)
cache.setBalance(uuid, dbPlayerShards.enderChest, ShardType.ENDER_CHEST)
return@withLock Result.success(dbPlayerShards)
}
}
override suspend fun getBaltop(offset: Int): Result<Map<UUID?, Long>> {
require(offset >= 0) { "offset must not be negative" }
return postgreSQL.getBaltop(offset)
}
override suspend fun getBaltopWithUuid(uuid: UUID): Result<Pair<Map<UUID?, Long>, Long>> =
postgreSQL.getBaltopWithUuid(uuid)
override suspend fun getNumberOfRows(): Result<Long> = postgreSQL.getNumberOfRows()
override suspend fun insertTransactionLog(
playerUuid: UUID,
transferredShards: Long,
playerToUuid: UUID?,
transactionReason: String,
notes: String?,
): Result<Unit> =
postgreSQL.insertTransactionLog(playerUuid, transferredShards, playerToUuid, transactionReason, notes)
override suspend fun hasEntry(uuid: UUID): Result<Boolean> = postgreSQL.hasEntry(uuid)
override suspend fun cacheForPlayer(uuid: UUID) =
withLock(uuid, ShardType.BANK, ShardType.INVENTORY, ShardType.ENDER_CHEST) {
val playerShards =
postgreSQL.getAllShards(uuid).getOrElse {
return@withLock Result.failure(it)
}
cache.setBalance(uuid, playerShards.bank, ShardType.BANK)
cache.setBalance(uuid, playerShards.inventory, ShardType.INVENTORY)
cache.setBalance(uuid, playerShards.enderChest, ShardType.ENDER_CHEST)
return@withLock Result.success(Unit)
}
override suspend fun removeCacheForPlayer(uuid: UUID) =
withLock(uuid, ShardType.BANK, ShardType.INVENTORY, ShardType.ENDER_CHEST) { cache.removeAll(uuid) }
override fun shutdown() {
postgreSQL.disconnect().get()
}
}
@@ -1,105 +0,0 @@
package net.trueog.diamondbankog.balance.command
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import net.kyori.adventure.text.minimessage.MiniMessage
import net.trueog.diamondbankog.DiamondBankOG
import net.trueog.diamondbankog.DiamondBankOG.Companion.economyDisabled
import net.trueog.diamondbankog.balance.BalanceManager
import net.trueog.diamondbankog.config.Config
import net.trueog.diamondbankog.transaction.CommonOperations
import net.trueog.diamondbankog.util.PlayerPrefix.getPrefix
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
import org.bukkit.entity.Player
internal class Balance(
val config: Config = DiamondBankOG.config,
val balanceManager: BalanceManager = DiamondBankOG.balanceManager,
val mm: MiniMessage = DiamondBankOG.mm,
val scope: CoroutineScope = DiamondBankOG.scope,
) : CommandExecutor {
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>?): Boolean {
scope.launch {
if (economyDisabled) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>The economy is disabled. Please notify a staff member."
)
)
return@launch
}
if (!sender.hasPermission("diamondbank-og.balance")) {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>You do not have permission to use this command.")
)
return@launch
}
if (args == null) return@launch
if (args.isEmpty() && sender !is Player) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Please provide that name or UUID of the player that you want to check the balance of."
)
)
return@launch
}
val balancePlayer =
if (args.isEmpty()) {
sender as Player
} else {
if (!sender.hasPermission("diamondbank-og.balance.others")) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>You do not have permission to use this command in that way."
)
)
return@launch
}
val otherPlayer = CommonOperations.getPlayerUsingUuidOrName(args[0])
if (!otherPlayer.hasPlayedBefore()) {
sender.sendMessage(mm.deserialize("${config.prefix}<reset>: <red>That player doesn't exist."))
return@launch
}
otherPlayer
}
val balance =
balanceManager.getAllShards(balancePlayer.uniqueId).getOrElse {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Something went wrong while trying to get ${
if (if (sender is Player) sender.uniqueId == balancePlayer.uniqueId else false) "your" else "their"
} balance."
)
)
return@launch
}
val totalBalance = balance.bank + balance.inventory + balance.enderChest
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <green>${
if (if (sender is Player) sender.uniqueId == balancePlayer.uniqueId else false) "Your Balance" else "Balance of ${
getPrefix(
balancePlayer.uniqueId,
DiamondBankOG.luckPerms,
)
}${balancePlayer.name}"
}<reset><green>:\n" +
"Bank: ${CommonOperations.shardsToDiamondsFull(balance.bank)}\n" +
"<green>Inventory: ${CommonOperations.shardsToDiamondsFull(balance.inventory)}\n" +
"<green>Ender Chest: ${CommonOperations.shardsToDiamondsFull(balance.enderChest)}\n" +
"<bold><green>Total: ${CommonOperations.shardsToDiamondsFull(totalBalance)}"
)
)
return@launch
}
return true
}
}
@@ -1,175 +0,0 @@
package net.trueog.diamondbankog.balance.command
import kotlin.math.ceil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import net.kyori.adventure.text.minimessage.MiniMessage
import net.trueog.diamondbankog.DiamondBankOG
import net.trueog.diamondbankog.DiamondBankOG.Companion.economyDisabled
import net.trueog.diamondbankog.balance.BalanceManager
import net.trueog.diamondbankog.config.Config
import net.trueog.diamondbankog.transaction.CommonOperations
import net.trueog.diamondbankog.util.PlayerPrefix.getPrefix
import org.bukkit.Bukkit
import org.bukkit.OfflinePlayer
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
internal class Balancetop(
val config: Config = DiamondBankOG.config,
val balanceManager: BalanceManager = DiamondBankOG.balanceManager,
val mm: MiniMessage = DiamondBankOG.mm,
val scope: CoroutineScope = DiamondBankOG.scope,
) : CommandExecutor {
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>?): Boolean {
scope.launch {
if (economyDisabled) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>The economy is disabled. Please notify a staff member."
)
)
return@launch
}
if (!sender.hasPermission("diamondbank-og.balancetop")) {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>You do not have permission to use this command.")
)
return@launch
}
if (args == null) return@launch
if (args.size > 1) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Please (only) optionally provide the page number or the name or UUID of the player you want to see the position of."
)
)
return@launch
}
var offset = 0
var index = 1
var player: OfflinePlayer? = null
if (args.size == 1) {
try {
index = args[0].toInt()
offset = 9 * (index - 1)
} catch (_: Exception) {
player = CommonOperations.getPlayerUsingUuidOrName(args[0])
if (!player.hasPlayedBefore()) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>That player doesn't exist or hasn't joined this server before."
)
)
return@launch
}
}
}
if (player != null) {
val baltopWithUuid =
balanceManager.getBaltopWithUuid(player.uniqueId).getOrElse {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Something went wrong while trying to get the information for balancetop."
)
)
return@launch
}
val (baltop, offset) = baltopWithUuid
var baltopMessage = "${config.prefix} <aqua>Top Balances:<reset>"
baltop.forEach {
val uuid = it.key
if (uuid == null) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Something went wrong while trying to get the information for balancetop."
)
)
return@launch
}
val player = Bukkit.getPlayer(uuid) ?: Bukkit.getOfflinePlayer(uuid)
val playerName = player.name ?: player.uniqueId.toString()
val diamonds = CommonOperations.shardsToDiamonds(it.value)
baltopMessage +=
"\n<green>${baltopMessage.lines().size + offset}<reset>. ${if (playerName == player.name) "<italic>" else ""}${
getPrefix(
uuid,
DiamondBankOG.luckPerms,
)
}$playerName<reset>: <yellow>$diamonds <aqua>Diamonds"
}
sender.sendMessage(mm.deserialize(baltopMessage))
return@launch
}
val baltop =
balanceManager.getBaltop(offset).getOrElse {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Something went wrong while trying to get the information for balancetop."
)
)
return@launch
}
val numberOfRows =
balanceManager.getNumberOfRows().getOrElse {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Something went wrong while trying to get the information for balancetop."
)
)
return@launch
}
if (index > ceil(numberOfRows / 9.0)) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>The amount of pages only goes up to and including ${
ceil(
numberOfRows / 9.0
).toLong()
}."
)
)
return@launch
}
var baltopMessage =
"${config.prefix} <aqua>Top Balances <#2ec2ae>(Page $index/${ceil(numberOfRows / 9.0).toLong()})<aqua>:<reset>"
baltop.forEach {
val uuid = it.key
if (uuid == null) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Something went wrong while trying to get the information for balancetop."
)
)
return@launch
}
val player = Bukkit.getPlayer(uuid) ?: Bukkit.getOfflinePlayer(uuid)
val playerName = player.name ?: player.uniqueId.toString()
val diamonds = CommonOperations.shardsToDiamonds(it.value)
baltopMessage +=
"\n<green>${baltopMessage.lines().size + (9 * (index - 1))}<reset>. ${if (playerName == player.name) "<italic>" else ""}${
getPrefix(
uuid,
DiamondBankOG.luckPerms,
)
}$playerName<reset>: <yellow>$diamonds <aqua>Diamonds"
}
sender.sendMessage(mm.deserialize(baltopMessage))
}
return true
}
}
@@ -1,106 +0,0 @@
package net.trueog.diamondbankog.balance.command
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import net.kyori.adventure.text.minimessage.MiniMessage
import net.trueog.diamondbankog.DiamondBankOG
import net.trueog.diamondbankog.DiamondBankOG.Companion.economyDisabled
import net.trueog.diamondbankog.balance.BalanceManager
import net.trueog.diamondbankog.balance.shard.ShardType
import net.trueog.diamondbankog.config.Config
import net.trueog.diamondbankog.transaction.CommonOperations
import net.trueog.diamondbankog.util.ErrorHandler.handleError
import net.trueog.diamondbankog.util.PlayerPrefix.getPrefix
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
import org.bukkit.entity.Player
internal class SetBankBalance(
val config: Config = DiamondBankOG.config,
val balanceManager: BalanceManager = DiamondBankOG.balanceManager,
val mm: MiniMessage = DiamondBankOG.mm,
val scope: CoroutineScope = DiamondBankOG.scope,
) : CommandExecutor {
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>?): Boolean {
scope.launch {
if (economyDisabled) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>The economy is disabled. Please notify a staff member."
)
)
return@launch
}
if (!sender.hasPermission("diamondbank-og.setbankbalance")) {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>You do not have permission to use this command.")
)
return@launch
}
if (args.isNullOrEmpty()) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>You did not provide the name or the UUID of a player and the amount of <aqua>Shards<red>."
)
)
return@launch
}
if (args.size != 2) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Incorrect syntax. Usage: /setbankbal(ance) <player name or player uuid> <shards>."
)
)
return@launch
}
val player = CommonOperations.getPlayerUsingUuidOrName(args[0])
if (!player.hasPlayedBefore()) {
sender.sendMessage(mm.deserialize("${config.prefix}<reset>: <red>That player doesn't exist."))
return@launch
}
val balance: Long
try {
balance = args[1].toLong()
} catch (_: Exception) {
sender.sendMessage(mm.deserialize("${config.prefix}<reset>: <red>Invalid argument."))
return@launch
}
balanceManager.setPlayerShards(player.uniqueId, balance, ShardType.BANK).getOrElse {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Something went wrong while trying to set that player's balance."
)
)
return@launch
}
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <green>Successfully set the balance of ${
getPrefix(
player.uniqueId,
DiamondBankOG.luckPerms,
)
}${player.name}<reset> <green>to <yellow>$balance <aqua>${if (balance == 1L) "Shard" else "Shards"}<green>."
)
)
balanceManager
.insertTransactionLog(
player.uniqueId,
balance,
null,
"Set Bank Balance",
"Bank Balance set by ${if (sender is Player) "${sender.uniqueId}" else "console"}",
)
.getOrElse { handleError(it) }
}
return true
}
}
@@ -1,5 +0,0 @@
package net.trueog.diamondbankog.balance.shard
data class PlayerShards(val bank: Long, val inventory: Long, val enderChest: Long) {
val total = bank + inventory + enderChest
}
@@ -1,45 +0,0 @@
package net.trueog.diamondbankog.balance.shard
import net.trueog.diamondbankog.DiamondBankOG.Companion.mm
import org.bukkit.Bukkit
import org.bukkit.Material
import org.bukkit.NamespacedKey
import org.bukkit.enchantments.Enchantment
import org.bukkit.inventory.ItemFlag
import org.bukkit.inventory.ItemStack
import org.bukkit.inventory.ShapedRecipe
import org.bukkit.inventory.ShapelessRecipe
import org.bukkit.persistence.PersistentDataType
internal object Shard {
val namespacedKey = NamespacedKey("diamondbank-og", "diamondshard")
/** amount defaults to 1 */
fun createItemStack(amount: Int = 1): ItemStack {
val shard = ItemStack(Material.PRISMARINE_SHARD, amount)
val shardMeta = shard.itemMeta
shardMeta.displayName(mm.deserialize("<aqua>Diamond Shard"))
shardMeta.persistentDataContainer.set(namespacedKey, PersistentDataType.STRING, "")
shardMeta.addEnchant(Enchantment.DURABILITY, 1, false)
shardMeta.addItemFlags(ItemFlag.HIDE_ENCHANTS)
shard.itemMeta = shardMeta
return shard
}
fun createCraftingRecipes() {
val diamondToShardKey = NamespacedKey("diamondbank-og", "diamond_to_shard")
val diamondToShardRecipe = ShapelessRecipe(diamondToShardKey, createItemStack(9))
diamondToShardRecipe.addIngredient(Material.DIAMOND)
Bukkit.addRecipe(diamondToShardRecipe)
val shardToDiamondKey = NamespacedKey("diamondbank-og", "shard_to_diamond")
val shardToDiamondRecipe = ShapedRecipe(shardToDiamondKey, ItemStack(Material.DIAMOND))
shardToDiamondRecipe.shape("SSS", "SSS", "SSS")
shardToDiamondRecipe.setIngredient('S', createItemStack())
Bukkit.addRecipe(shardToDiamondRecipe)
}
fun isShardItem(it: ItemStack): Boolean {
return it.itemMeta.persistentDataContainer.has(namespacedKey)
}
}
@@ -1,8 +0,0 @@
package net.trueog.diamondbankog.balance.shard
enum class ShardType(val string: String) {
BANK("bank_shards"),
INVENTORY("inventory_shards"),
ENDER_CHEST("ender_chest_shards"),
TOTAL("bank_shards, inventory_shards, ender_chest_shards"),
}
@@ -0,0 +1,94 @@
package net.trueog.diamondbankog.commands
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import net.trueog.diamondbankog.Config
import net.trueog.diamondbankog.DiamondBankOG
import net.trueog.diamondbankog.PostgreSQL.BalanceType
import org.bukkit.Bukkit
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
import org.bukkit.entity.Player
import java.util.*
class Balance : CommandExecutor {
@OptIn(DelicateCoroutinesApi::class)
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>?): Boolean {
GlobalScope.launch {
if (DiamondBankOG.economyDisabled) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>The economy is disabled because of a severe error. Please notify a staff member."))
return@launch
}
if (!sender.hasPermission("diamondbank-og.balance")) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You do not have permission to use this command."))
return@launch
}
if (args == null) return@launch
if (sender !is Player) {
if (args.isEmpty()) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Please provide that name or UUID of the player that you want to check the balance of."))
return@launch
}
val otherPlayer = try {
Bukkit.getPlayer(UUID.fromString(args[0])) ?: Bukkit.getOfflinePlayer(UUID.fromString(args[0]))
} catch (_: Exception) {
Bukkit.getPlayer(args[0]) ?: Bukkit.getOfflinePlayer(args[0])
}
if (!otherPlayer.hasPlayedBefore()) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>That player doesn't exist."))
return@launch
}
val balance = DiamondBankOG.postgreSQL.getPlayerBalance(
otherPlayer.uniqueId,
BalanceType.ALL
)
if (balance.bankBalance == null || balance.inventoryBalance == null || balance.enderChestBalance == null) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Something went wrong while trying to get your balance."))
return@launch
}
val totalBalance = balance.bankBalance + balance.inventoryBalance + balance.enderChestBalance
sender.sendMessage(DiamondBankOG.mm.deserialize("<green>Balance of <red>${otherPlayer.name}<green>: <yellow>$totalBalance <aqua>${if (totalBalance == 1) "Diamond" else "Diamonds"} <white>(<red>Bank: <yellow>${balance.bankBalance}<white>, <red>Inventory: <yellow>${balance.inventoryBalance}<white>, <red>Ender Chest: <yellow>${balance.enderChestBalance}<white>)."))
return@launch
}
val balancePlayer = if (args.isEmpty()) {
sender
} else {
if (!sender.hasPermission("diamondbank-og.balance.others")) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You do not have permission to use this command."))
return@launch
}
val otherPlayer = try {
Bukkit.getPlayer(UUID.fromString(args[0])) ?: Bukkit.getOfflinePlayer(UUID.fromString(args[0]))
} catch (_: Exception) {
Bukkit.getPlayer(args[0]) ?: Bukkit.getOfflinePlayer(args[0])
}
if (!otherPlayer.hasPlayedBefore()) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>That player doesn't exist."))
return@launch
}
otherPlayer
}
val balance = DiamondBankOG.postgreSQL.getPlayerBalance(
balancePlayer.uniqueId,
BalanceType.ALL
)
if (balance.bankBalance == null || balance.inventoryBalance == null || balance.enderChestBalance == null) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Something went wrong while trying to get your balance."))
return@launch
}
val totalBalance = balance.bankBalance + balance.inventoryBalance + balance.enderChestBalance
sender.sendMessage(DiamondBankOG.mm.deserialize("<green>Balance${if (balancePlayer.uniqueId != sender.uniqueId) " of <red>${balancePlayer.name}" else ""}<green>: <yellow>$totalBalance <aqua>${if (totalBalance == 1) "Diamond" else "Diamonds"} <white>(<red>Bank: <yellow>${balance.bankBalance}<white>, <red>Inventory: <yellow>${balance.inventoryBalance}<white>, <red>Ender Chest: <yellow>${balance.enderChestBalance}<white>)."))
return@launch
}
return true
}
}
@@ -0,0 +1,74 @@
package net.trueog.diamondbankog.commands
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import net.trueog.diamondbankog.Config
import net.trueog.diamondbankog.DiamondBankOG
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
import kotlin.math.ceil
class Balancetop : CommandExecutor {
@OptIn(DelicateCoroutinesApi::class)
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>?): Boolean {
GlobalScope.launch {
if (DiamondBankOG.economyDisabled) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>The economy is disabled because of a severe error. Please notify a staff member."))
return@launch
}
if (!sender.hasPermission("diamondbank-og.balancetop")) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You do not have permission to use this command."))
return@launch
}
if (args == null) return@launch
if (args.size > 1) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Please (only) optionally provide the page number."))
return@launch
}
var offset = 0
var index = 1
if (args.size == 1) {
try {
index = args[0].toInt()
} catch (_: Exception) {
sender.sendMessage("Invalid argument")
return@launch
}
offset = 10 * (index - 1)
}
val baltop = DiamondBankOG.postgreSQL.getBaltop(offset)
if (baltop == null) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Something went wrong while trying to get the information for balancetop."))
return@launch
}
val numberOfRows = DiamondBankOG.postgreSQL.getNumberOfRows()
if (numberOfRows == null) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Something went wrong while trying to get the information for balancetop."))
return@launch
}
if (index > ceil(numberOfRows / 10.0)) {
sender.sendMessage("The amount of pages only goes up to $numberOfRows")
return@launch
}
var baltopMessage =
("<yellow>---- <gold>Balancetop <yellow>-- <gold>Page <red>$index<gold>/<red>${ceil(numberOfRows / 10.0).toLong()} <yellow>----<reset>")
baltop.forEach {
if (it.key == null) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Something went wrong while trying to get the information for balancetop."))
return@launch
}
baltopMessage += "\n<red>${baltopMessage.lines().size + (10 * (index - 1))}<reset>. ${if (it.key == sender.name) "<red>" else ""}${it.key}<reset>, ${it.value}"
}
sender.sendMessage(DiamondBankOG.mm.deserialize(baltopMessage))
}
return true
}
}
@@ -0,0 +1,115 @@
package net.trueog.diamondbankog.commands
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import net.trueog.diamondbankog.Config
import net.trueog.diamondbankog.DiamondBankOG
import net.trueog.diamondbankog.Helper
import net.trueog.diamondbankog.Helper.PostgresFunction
import net.trueog.diamondbankog.Helper.withdraw
import net.trueog.diamondbankog.PostgreSQL.BalanceType
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
import org.bukkit.entity.Player
class Deposit : CommandExecutor {
@OptIn(DelicateCoroutinesApi::class)
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>?): Boolean {
GlobalScope.launch {
if (DiamondBankOG.economyDisabled) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>The economy is disabled because of a severe error. Please notify a staff member."))
return@launch
}
if (sender !is Player) {
sender.sendMessage("You can only execute this command as a player.")
return@launch
}
if (DiamondBankOG.blockCommandsWithInventoryActionsFor.contains(sender.uniqueId)) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You are currently blocked from using /deposit."))
return@launch
}
val worldName = sender.world.name
if (worldName != "world" && worldName != "world_nether" && worldName != "world_the_end") {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You cannot use /deposit <aqua>Diamonds <red>when in a minigame."))
return@launch
}
if (!sender.hasPermission("diamondbank-og.deposit")) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You do not have permission to use this command."))
return@launch
}
if (args == null || args.isEmpty()) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You did not provide the amount of <aqua>Diamonds <red>that you want to deposit."))
return@launch
}
if (args.size != 1) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Please (only) provide the amount of <aqua>Diamonds <red>you want to deposit. Either a number or \"all\"."))
return@launch
}
val playerBalance =
DiamondBankOG.postgreSQL.getPlayerBalance(sender.uniqueId, BalanceType.INVENTORY_BALANCE)
if (playerBalance.inventoryBalance == null) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Something went wrong while trying to get your balance."))
return@launch
}
if (playerBalance.inventoryBalance == 0) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You don't have any <aqua>Diamonds <red>to deposit."))
return@launch
}
var amount = playerBalance.inventoryBalance
if (args[0] != "all") {
try {
amount = args[0].toInt()
if (amount < 0) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You cannot deposit a negative amount."))
return@launch
}
} catch (_: Exception) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Invalid argument."))
return@launch
}
if (amount > playerBalance.inventoryBalance) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You do not have <yellow>$amount <aqua>${if (amount == 1) "Diamond" else "Diamonds"} <red>in your inventory."))
return@launch
}
}
var error = sender.inventory.withdraw(amount, playerBalance)
if (error) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Something went wrong while trying to deposit."))
return@launch
}
error = DiamondBankOG.postgreSQL.addToPlayerBalance(
sender.uniqueId,
amount,
BalanceType.BANK_BALANCE
)
if (error) {
Helper.handleError(
sender.uniqueId,
PostgresFunction.ADD_TO_PLAYER_BALANCE,
amount,
BalanceType.BANK_BALANCE,
playerBalance,
"deposit"
)
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Something went wrong while trying to deposit."))
DiamondBankOG.blockInventoryFor.remove(sender.uniqueId)
return@launch
}
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <green>Successfully deposited <yellow>$amount <aqua>${if (amount == 1) "Diamond" else "Diamonds"} <green>into your bank account."))
}
return true
}
}
@@ -0,0 +1,45 @@
package net.trueog.diamondbankog.commands
import io.sentry.Sentry
import net.trueog.diamondbankog.Config
import net.trueog.diamondbankog.DiamondBankOG
import net.trueog.diamondbankog.PostgreSQL
import org.bukkit.Bukkit
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
class DiamondBankReload : CommandExecutor {
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>?): Boolean {
if (Config.load()) {
sender.sendMessage(DiamondBankOG.mm.deserialize("<red>Failed to reload the config. Check the console for more information."))
Bukkit.getPluginManager().disablePlugin(DiamondBankOG.plugin)
return true
}
if (Config.sentryEnabled) {
try {
Sentry.init { options ->
options.dsn = Config.sentryDsn
}
DiamondBankOG.sentryEnabled = true
} catch (e: Exception) {
DiamondBankOG.sentryEnabled = false
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Could not initialise Sentry. The Sentry(-compatible) DSN in your config might be invalid."))
}
}
DiamondBankOG.postgreSQL = PostgreSQL()
try {
DiamondBankOG.postgreSQL.initDB()
} catch (e: Exception) {
DiamondBankOG.plugin.logger.info(e.toString())
sender.sendMessage(DiamondBankOG.mm.deserialize("<red>Failed to reload the config. Check the console for more information."))
Bukkit.getPluginManager().disablePlugin(DiamondBankOG.plugin)
return true
}
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <green>Successfully reloaded the config."))
return true
}
}
@@ -0,0 +1,106 @@
package net.trueog.diamondbankog.commands
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import net.trueog.diamondbankog.Config
import net.trueog.diamondbankog.DiamondBankOG
import net.trueog.diamondbankog.Helper
import net.trueog.diamondbankog.Helper.PostgresFunction
import net.trueog.diamondbankog.PostgreSQL.BalanceType
import org.bukkit.Bukkit
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
import org.bukkit.entity.Player
import java.util.*
class Pay : CommandExecutor {
@OptIn(DelicateCoroutinesApi::class)
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>?): Boolean {
GlobalScope.launch {
if (DiamondBankOG.economyDisabled) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>The economy is disabled because of a severe error. Please notify a staff member."))
return@launch
}
if (sender !is Player) {
sender.sendMessage("You can only execute this command as a player.")
return@launch
}
if (DiamondBankOG.blockCommandsWithInventoryActionsFor.contains(sender.uniqueId)) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You are currently blocked from using /pay."))
return@launch
}
val worldName = sender.world.name
if (worldName != "world" && worldName != "world_nether" && worldName != "world_the_end") {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You cannot use /pay <aqua>Diamonds <red>when in a minigame."))
return@launch
}
if (!sender.hasPermission("diamondbank-og.pay")) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You do not have permission to use this command."))
return@launch
}
if (args == null || args.isEmpty()) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You did not provide the name or the UUID of a player and the amount of <aqua>Diamonds<red>."))
return@launch
}
if (args.size != 2) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Please (only) provide the name or the UUID of a player and the amount of <aqua>Diamonds<red>."))
return@launch
}
val receiver = try {
Bukkit.getPlayer(UUID.fromString(args[0])) ?: Bukkit.getOfflinePlayer(UUID.fromString(args[0]))
} catch (_: Exception) {
Bukkit.getPlayer(args[0]) ?: Bukkit.getOfflinePlayer(args[0])
}
if (!receiver.hasPlayedBefore()) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>That player doesn't exist."))
return@launch
}
var amount = -1
if (args[1] != "all") {
try {
amount = args[1].toInt()
if (amount < 0) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You cannot pay a negative amount."))
return@launch
}
} catch (_: Exception) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Invalid argument."))
return@launch
}
}
val withdrawnAmount = Helper.withdrawFromPlayer(sender, amount) ?: return@launch
val error = DiamondBankOG.postgreSQL.addToPlayerBalance(
receiver.uniqueId,
amount,
BalanceType.BANK_BALANCE
)
if (error) {
Helper.handleError(
sender.uniqueId,
PostgresFunction.ADD_TO_PLAYER_BALANCE, amount, BalanceType.BANK_BALANCE,
null, "pay"
)
}
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <green>Successfully paid <yellow>$withdrawnAmount <aqua>${if (withdrawnAmount == 1) "Diamond" else "Diamonds"} <green>to <red>${receiver.name}<green>."))
if (receiver.isOnline) {
val receiverPlayer = receiver.player ?: return@launch
receiverPlayer.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <green>${sender.name} has paid you <yellow>$withdrawnAmount <aqua>${if (withdrawnAmount == 1) "Diamond" else "Diamonds"}<green>."))
}
}
return true
}
}
@@ -0,0 +1,66 @@
package net.trueog.diamondbankog.commands
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import net.trueog.diamondbankog.Config
import net.trueog.diamondbankog.DiamondBankOG
import net.trueog.diamondbankog.PostgreSQL.BalanceType
import org.bukkit.Bukkit
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
import java.util.*
class SetBankBalance : CommandExecutor {
@OptIn(DelicateCoroutinesApi::class)
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>?): Boolean {
GlobalScope.launch {
if (DiamondBankOG.economyDisabled) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>The economy is disabled because of a severe error. Please notify a staff member."))
return@launch
}
if (!sender.hasPermission("diamondbank-og.setbankbalance")) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You do not have permission to use this command."))
return@launch
}
if (args == null || args.isEmpty()) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You did not provide the name or the UUID of a player and the amount of <aqua>Diamonds<red>."))
return@launch
}
if (args.size != 2) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Please (only) provide the name or the UUID of a player and the amount of <aqua>Diamonds<red>."))
return@launch
}
val player = try {
Bukkit.getPlayer(UUID.fromString(args[0])) ?: Bukkit.getOfflinePlayer(UUID.fromString(args[0]))
} catch (_: Exception) {
Bukkit.getPlayer(args[0]) ?: Bukkit.getOfflinePlayer(args[0])
}
if (!player.hasPlayedBefore()) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>That player doesn't exist."))
return@launch
}
val balance: Int
try {
balance = args[1].toInt()
} catch (_: Exception) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Invalid argument."))
return@launch
}
val error =
DiamondBankOG.postgreSQL.setPlayerBalance(player.uniqueId, balance, BalanceType.BANK_BALANCE)
if (error) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Something went wrong while trying to set that player's balance."))
return@launch
}
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <green>Successfully set the balance of <red>${player.name} <green>to <yellow>$balance <aqua>${if (balance == 1) "Diamond" else "Diamonds"}<green>."))
}
return true
}
}
@@ -0,0 +1,140 @@
package net.trueog.diamondbankog.commands
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import net.trueog.diamondbankog.Config
import net.trueog.diamondbankog.DiamondBankOG
import net.trueog.diamondbankog.Helper
import net.trueog.diamondbankog.Helper.PostgresFunction
import net.trueog.diamondbankog.PostgreSQL.BalanceType
import org.bukkit.Material
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
import org.bukkit.entity.Player
import org.bukkit.inventory.ItemStack
class Withdraw : CommandExecutor {
@OptIn(DelicateCoroutinesApi::class)
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>?): Boolean {
GlobalScope.launch {
if (DiamondBankOG.economyDisabled) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>The economy is disabled because of a severe error. Please notify a staff member."))
return@launch
}
if (sender !is Player) {
sender.sendMessage("You can only execute this command as a player.")
return@launch
}
if (DiamondBankOG.blockCommandsWithInventoryActionsFor.contains(sender.uniqueId)) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You are currently blocked from using /withdraw."))
return@launch
}
val worldName = sender.world.name
if (worldName != "world" && worldName != "world_nether" && worldName != "world_the_end") {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You cannot use /withdraw when in a minigame."))
return@launch
}
if (!sender.hasPermission("diamondbank-og.withdraw")) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You do not have permission to use this command."))
return@launch
}
if (args == null || args.isEmpty()) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You did not provide the amount of <aqua>Diamonds <red>that you want to withdraw."))
return@launch
}
if (args.size != 1) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Please (only) provide the amount of <aqua>Diamonds <red>you want to withdraw. Either a number or \"all\"."))
return@launch
}
var amount = -1
if (args[0] != "all") {
try {
amount = args[0].toInt()
if (amount < 0) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You cannot withdraw a negative amount."))
return@launch
}
} catch (_: Exception) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Invalid argument."))
return@launch
}
}
val playerBalance =
DiamondBankOG.postgreSQL.getPlayerBalance(sender.uniqueId, BalanceType.BANK_BALANCE)
if (playerBalance.bankBalance == null) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Something went wrong while trying to get your balance."))
return@launch
}
if (amount == -1) amount = playerBalance.bankBalance
if (amount > playerBalance.bankBalance) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Cannot withdraw <yellow>$amount <aqua>${if (amount == 1) "Diamond" else "Diamonds"} <red>because your bank only contains <yellow>${playerBalance.bankBalance} <aqua>${if (playerBalance.bankBalance == 1) "diamond" else "diamonds"}<red>."))
return@launch
}
DiamondBankOG.blockInventoryFor.add(sender.uniqueId)
val emptySlots = sender.inventory.storageContents.filter { it == null }.size * 64
val leftOverSpace = sender.inventory.storageContents.filterNotNull().filter { it.type == Material.DIAMOND }
.sumOf { 64 - it.amount }
if (amount > (emptySlots + leftOverSpace)) {
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>You don't have enough inventory space to withdraw <yellow>$amount <aqua>${if (amount == 1) "Diamond" else "Diamonds"}<red>."))
DiamondBankOG.blockInventoryFor.remove(sender.uniqueId)
return@launch
}
var error = DiamondBankOG.postgreSQL.subtractFromPlayerBalance(
sender.uniqueId,
amount,
BalanceType.BANK_BALANCE
)
if (error) {
Helper.handleError(
sender.uniqueId,
PostgresFunction.SUBTRACT_FROM_PLAYER_BALANCE,
amount,
BalanceType.BANK_BALANCE,
playerBalance,
"withdraw"
)
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Something went wrong while trying to withdraw."))
DiamondBankOG.blockInventoryFor.remove(sender.uniqueId)
return@launch
}
error = DiamondBankOG.postgreSQL.addToPlayerBalance(
sender.uniqueId,
amount,
BalanceType.INVENTORY_BALANCE
)
if (error) {
Helper.handleError(
sender.uniqueId,
PostgresFunction.ADD_TO_PLAYER_BALANCE,
amount,
BalanceType.INVENTORY_BALANCE,
playerBalance,
"withdraw"
)
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <red>Something went wrong while trying to withdraw."))
DiamondBankOG.blockInventoryFor.remove(sender.uniqueId)
return@launch
}
sender.inventory.addItem(ItemStack(Material.DIAMOND, amount))
DiamondBankOG.blockInventoryFor.remove(sender.uniqueId)
sender.sendMessage(DiamondBankOG.mm.deserialize("${Config.prefix}<reset>: <green>Successfully withdrew <yellow>$amount <aqua>${if (amount == 1) "Diamond" else "Diamonds"} <green>from your bank account."))
}
return true
}
}
@@ -1,15 +0,0 @@
package net.trueog.diamondbankog.config
interface Config {
val prefix: String
val postgresHost: String
val postgresPort: Int
val postgresDatabase: String
val postgresUser: String
val postgresPassword: String?
val postgresMaxActiveConnections: Int
val redisHost: String
val redisPort: Int
val redisDatabase: Int
val redisPassword: String?
}
@@ -1,5 +0,0 @@
package net.trueog.diamondbankog.config
interface ConfigFactory {
fun create(): Config?
}
@@ -1,79 +0,0 @@
package net.trueog.diamondbankog.config
import java.io.File
import net.trueog.diamondbankog.DiamondBankOG.Companion.plugin
import org.bukkit.configuration.file.YamlConfiguration
internal class YamlConfig
private constructor(
override val prefix: String,
override val postgresHost: String,
override val postgresPort: Int,
override val postgresDatabase: String,
override val postgresUser: String,
override val postgresPassword: String?,
override val postgresMaxActiveConnections: Int,
override val redisHost: String,
override val redisPort: Int,
override val redisDatabase: Int,
override val redisPassword: String?,
) : Config {
companion object : ConfigFactory {
override fun create(): YamlConfig? {
plugin.saveDefaultConfig()
val file = File(plugin.dataFolder, "config.yml")
val yamlConfig = YamlConfiguration.loadConfiguration(file)
val prefix = yamlConfig.parseKeyAs<String>("prefix")
val postgresHost = yamlConfig.parseKeyAs<String>("postgresHost")
val postgresPort = yamlConfig.parseKeyAs<Int>("postgresPort")
val postgresDatabase = yamlConfig.parseKeyAs<String>("postgresDatabase")
val postgresUser = yamlConfig.parseKeyAs<String>("postgresUser")
val postgresPassword = yamlConfig.parseKeyAs<String?>("postgresPassword")
val postgresMaxActiveConnections = yamlConfig.parseKeyAs<Int>("postgresMaxActiveConnections")
val redisHost = yamlConfig.parseKeyAs<String>("redisHost")
val redisPort = yamlConfig.parseKeyAs<Int>("redisPort")
val redisDatabase = yamlConfig.parseKeyAs<Int>("redisDatabase")
val redisPassword = yamlConfig.parseKeyAs<String?>("redisPassword")
if (
prefix == null ||
postgresHost == null ||
postgresPort == null ||
postgresDatabase == null ||
postgresUser == null ||
postgresMaxActiveConnections == null ||
redisHost == null ||
redisPort == null ||
redisDatabase == null
) {
return null
}
return YamlConfig(
prefix,
postgresHost,
postgresPort,
postgresDatabase,
postgresUser,
postgresPassword,
postgresMaxActiveConnections,
redisHost,
redisPort,
redisDatabase,
redisPassword,
)
}
inline fun <reified T> YamlConfiguration.parseKeyAs(key: String): T? {
if (!this.contains(key) && null is T) return null
return this.get(key) as? T
?: run {
plugin.logger.severe("Failed to parse config option \"$key\" as ${T::class.simpleName}")
return null
}
}
}
}
@@ -1,50 +0,0 @@
package net.trueog.diamondbankog.config.command
import net.trueog.diamondbankog.DiamondBankOG.Companion.balanceManager
import net.trueog.diamondbankog.DiamondBankOG.Companion.config
import net.trueog.diamondbankog.DiamondBankOG.Companion.mm
import net.trueog.diamondbankog.DiamondBankOG.Companion.plugin
import net.trueog.diamondbankog.balance.CachingBalanceManager
import net.trueog.diamondbankog.config.YamlConfig
import org.bukkit.Bukkit
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
internal class DiamondBankReload : CommandExecutor {
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>?): Boolean {
if (!sender.hasPermission("diamondbank-og.admin")) {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>You do not have permission to use this command.")
)
return true
}
config =
YamlConfig.create()
?: run {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Failed to reload the config. Check the console for more information."
)
)
Bukkit.getPluginManager().disablePlugin(plugin)
return true
}
balanceManager =
CachingBalanceManager.create()
?: run {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Failed to reload the config. Check the console for more information."
)
)
Bukkit.getPluginManager().disablePlugin(plugin)
return true
}
sender.sendMessage(mm.deserialize("${config.prefix}<reset>: <green>Successfully reloaded the config."))
return true
}
}
@@ -1,25 +0,0 @@
package net.trueog.diamondbankog.economy.command
import net.trueog.diamondbankog.DiamondBankOG.Companion.config
import net.trueog.diamondbankog.DiamondBankOG.Companion.economyDisabled
import net.trueog.diamondbankog.DiamondBankOG.Companion.mm
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
internal class DisableEconomy : CommandExecutor {
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>?): Boolean {
if (!sender.hasPermission("diamondbank-og.admin")) {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>You do not have permission to use this command.")
)
return true
}
economyDisabled = true
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <green>Successfully <red>disabled <green>the economy.")
)
return true
}
}
@@ -1,23 +0,0 @@
package net.trueog.diamondbankog.economy.command
import net.trueog.diamondbankog.DiamondBankOG.Companion.config
import net.trueog.diamondbankog.DiamondBankOG.Companion.economyDisabled
import net.trueog.diamondbankog.DiamondBankOG.Companion.mm
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
internal class EnableEconomy : CommandExecutor {
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>?): Boolean {
if (!sender.hasPermission("diamondbank-og.admin")) {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>You do not have permission to use this command.")
)
return true
}
economyDisabled = false
sender.sendMessage(mm.deserialize("${config.prefix}<reset>: <green>Successfully enabled the economy."))
return true
}
}
@@ -1,449 +0,0 @@
package net.trueog.diamondbankog.persistence
import com.github.jasync.sql.db.Connection
import com.github.jasync.sql.db.asSuspending
import com.github.jasync.sql.db.pool.ConnectionPool
import com.github.jasync.sql.db.postgresql.PostgreSQLConnection
import com.github.jasync.sql.db.postgresql.PostgreSQLConnectionBuilder
import java.util.*
import kotlinx.coroutines.future.await
import kotlinx.coroutines.future.future
import net.trueog.diamondbankog.DiamondBankException.*
import net.trueog.diamondbankog.DiamondBankOG
import net.trueog.diamondbankog.DiamondBankOG.Companion.config
import net.trueog.diamondbankog.DiamondBankOG.Companion.economyDisabled
import net.trueog.diamondbankog.DiamondBankOG.Companion.plugin
import net.trueog.diamondbankog.DiamondBankOG.Companion.scope
import net.trueog.diamondbankog.balance.shard.PlayerShards
import net.trueog.diamondbankog.balance.shard.ShardType
import org.flywaydb.core.Flyway
import org.postgresql.ds.PGSimpleDataSource
internal class PostgreSQL private constructor() {
private lateinit var pool: ConnectionPool<PostgreSQLConnection>
class NoRowsException : Exception()
companion object {
fun create(): PostgreSQL? {
val postgreSQL = PostgreSQL()
try {
val pg =
PGSimpleDataSource().apply {
serverNames = arrayOf(config.postgresHost)
portNumbers = intArrayOf(config.postgresPort)
databaseName = config.postgresDatabase
user = config.postgresUser
password = config.postgresPassword
}
val flyway = Flyway.configure(DiamondBankOG::class.java.classLoader).dataSource(pg).load()
flyway.migrate()
postgreSQL.pool =
PostgreSQLConnectionBuilder.createConnectionPool {
host = config.postgresHost
port = config.postgresPort
database = config.postgresDatabase
username = config.postgresUser
password = config.postgresPassword
maxActiveConnections = config.postgresMaxActiveConnections
}
return postgreSQL
} catch (e: Exception) {
economyDisabled = true
plugin.logger.severe(
"ECONOMY DISABLED! Something went wrong while trying to initialise PostgreSQL. Is PostgreSQL running? Are the PostgreSQL config variables correct?"
)
e.printStackTrace()
return null
}
}
}
fun disconnect() = pool.disconnect()
suspend fun setPlayerShards(uuid: UUID, shards: Long, type: ShardType): Result<Unit> {
if (type == ShardType.TOTAL) return Result.failure(InvalidArgumentException())
try {
val connection = pool.asSuspending.connect()
val playerShards =
connection
.inTransaction { conn ->
conn
.sendPreparedStatement(
"INSERT INTO diamond(uuid, ${type.string}) VALUES(?, ?) ON CONFLICT (uuid) DO UPDATE SET ${type.string} = excluded.${type.string} " +
"RETURNING bank_shards, inventory_shards, ender_chest_shards",
listOf(uuid, shards),
)
.thenApply { result ->
if (result.rows.isEmpty()) {
throw Exception()
}
val row = result.rows[0]
val bankShards = row.getLong("bank_shards")
val inventoryShards = row.getLong("inventory_shards")
val enderChestShards = row.getLong("ender_chest_shards")
if (bankShards == null || inventoryShards == null || enderChestShards == null) {
throw Exception()
}
PlayerShards(bankShards, inventoryShards, enderChestShards)
}
}
.await()
DiamondBankOG.eventManager.sendUpdate(uuid, playerShards)
return Result.success(Unit)
} catch (e: Exception) {
return Result.failure(DatabaseException(e.message ?: "Database exception"))
}
}
suspend fun addToPlayerShards(uuid: UUID, shards: Long, type: ShardType): Result<Long> {
if (type == ShardType.TOTAL) return Result.failure(InvalidArgumentException())
try {
val connection = pool.asSuspending.connect()
val playerShards =
connection
.inTransaction { conn -> scope.future { internalAddToPlayerShards(uuid, shards, type, conn) } }
.await()
DiamondBankOG.eventManager.sendUpdate(uuid, playerShards)
return when (type) {
ShardType.BANK -> Result.success(playerShards.bank)
ShardType.INVENTORY -> Result.success(playerShards.inventory)
ShardType.ENDER_CHEST -> Result.success(playerShards.enderChest)
else -> Result.failure(InvalidArgumentException())
}
} catch (e: Exception) {
if (e is InsufficientBalanceException) {
return Result.failure(e)
}
return Result.failure(DatabaseException(e.message ?: "Database exception"))
}
}
private suspend fun internalAddToPlayerShards(
uuid: UUID,
shards: Long,
type: ShardType,
conn: Connection,
): PlayerShards {
val result =
conn
.sendPreparedStatement(
"INSERT INTO diamond(uuid, ${type.string}) VALUES(?, ?) ON CONFLICT (uuid) DO UPDATE SET ${type.string} = diamond.${type.string} + excluded.${type.string} " +
"WHERE diamond.${type.string} + excluded.${type.string} >= 0 RETURNING bank_shards, inventory_shards, ender_chest_shards",
listOf(uuid, shards),
)
.await()
if (result.rows.isEmpty()) {
val shards = internalGetShardTypeShards(uuid, type, conn).getOrElse { throw it }
throw InsufficientBalanceException(shards)
}
val row = result.rows[0]
val bankShards = row.getLong("bank_shards")
val inventoryShards = row.getLong("inventory_shards")
val enderChestShards = row.getLong("ender_chest_shards")
if (bankShards == null || inventoryShards == null || enderChestShards == null) {
throw Exception()
}
val playerShards = PlayerShards(bankShards, inventoryShards, enderChestShards)
return playerShards
}
suspend fun transferBankShards(
sender: UUID,
receiver: UUID,
shardsToSubtractFromSender: Long,
shardsToAddToReceiver: Long,
): Result<Map<UUID, Long>> {
try {
val connection = pool.asSuspending.connect()
val playerShardsMap =
connection
.inTransaction { conn ->
scope.future {
mapOf<UUID, suspend () -> PlayerShards>(
sender to
{
internalAddToPlayerShards(
sender,
-shardsToSubtractFromSender,
ShardType.BANK,
conn,
)
},
receiver to
{
internalAddToPlayerShards(
receiver,
shardsToAddToReceiver,
ShardType.BANK,
conn,
)
},
)
.toSortedMap() // Sort to avoid deadlock
.mapValues { it.value() }
}
}
.await()
playerShardsMap.forEach { (uuid, playerShards) ->
DiamondBankOG.eventManager.sendUpdate(uuid, playerShards)
}
return Result.success(playerShardsMap.mapValues { (_, playerShards) -> playerShards.bank })
} catch (e: Exception) {
if (e is InsufficientBalanceException) {
return Result.failure(e)
}
return Result.failure(DatabaseException(e.message ?: "Database exception"))
}
}
suspend fun getTotalShards(uuid: UUID): Result<Long> {
var totalShards: Long?
try {
val connection = pool.asSuspending.connect()
val result =
connection
.inTransaction { conn ->
conn.sendPreparedStatement(
"SELECT total_shards FROM diamond WHERE uuid = ? LIMIT 1",
listOf(uuid),
)
}
.await()
totalShards =
if (result.rows.isNotEmpty()) {
val row = result.rows[0]
row.getLong("total_shards") ?: 0
} else {
0
}
} catch (e: Exception) {
plugin.logger.severe(e.toString())
return Result.failure(DatabaseException(e.message ?: "Database exception"))
}
return Result.success(totalShards)
}
suspend fun getAllShards(uuid: UUID): Result<PlayerShards> {
var bankShards: Long?
var inventoryShards: Long?
var enderChestShards: Long?
try {
val connection = pool.asSuspending.connect()
val result =
connection
.inTransaction { conn ->
conn.sendPreparedStatement(
"SELECT bank_shards, inventory_shards, ender_chest_shards FROM diamond WHERE uuid = ? LIMIT 1",
listOf(uuid),
)
}
.await()
if (result.rows.isNotEmpty()) {
val row = result.rows[0]
bankShards = row.getLong("bank_shards") ?: 0
inventoryShards = row.getLong("inventory_shards") ?: 0
enderChestShards = row.getLong("ender_chest_shards") ?: 0
} else {
bankShards = 0
inventoryShards = 0
enderChestShards = 0
}
} catch (e: Exception) {
plugin.logger.severe(e.toString())
return Result.failure(DatabaseException(e.message ?: "Database exception"))
}
return Result.success(PlayerShards(bankShards, inventoryShards, enderChestShards))
}
suspend fun getShardTypeShards(uuid: UUID, type: ShardType): Result<Long> {
try {
val connection = pool.asSuspending.connect()
return internalGetShardTypeShards(uuid, type, connection)
} catch (e: Exception) {
plugin.logger.severe(e.toString())
return Result.failure(DatabaseException(e.message ?: "Database exception"))
}
}
private suspend fun internalGetShardTypeShards(uuid: UUID, type: ShardType, conn: Connection): Result<Long> {
var shards: Long?
try {
val result =
conn
.inTransaction { conn ->
conn.sendPreparedStatement(
"SELECT ${type.string} FROM diamond WHERE uuid = ? LIMIT 1",
listOf(uuid),
)
}
.await()
shards =
if (result.rows.isNotEmpty()) {
val row = result.rows[0]
row.getLong(type.string) ?: 0
} else {
0
}
} catch (e: Exception) {
plugin.logger.severe(e.toString())
return Result.failure(DatabaseException(e.message ?: "Database exception"))
}
return Result.success(shards)
}
suspend fun getBaltop(offset: Int): Result<Map<UUID?, Long>> {
try {
val connection = pool.asSuspending.connect()
val result =
connection
.inTransaction { conn ->
conn.sendPreparedStatement(
"SELECT uuid, total_shards " +
"FROM diamond " +
"ORDER BY total_shards DESC, uuid DESC OFFSET ? LIMIT 9",
listOf(offset),
)
}
.await()
val baltop = mutableMapOf<UUID?, Long>()
result.rows.forEach {
val uuid = it.getAs<UUID>("uuid")
val totalShards = it.getLong("total_shards") ?: 0
baltop[uuid] = totalShards
}
return Result.success(baltop)
} catch (e: Exception) {
plugin.logger.severe(e.toString())
return Result.failure(DatabaseException(e.message ?: "Database exception"))
}
}
/**
* @return Pair with as the first value a map with the player name and total balance and as the second value the
* offset
*/
suspend fun getBaltopWithUuid(uuid: UUID): Result<Pair<Map<UUID?, Long>, Long>> {
try {
val connection = pool.asSuspending.connect()
val result =
connection
.inTransaction { conn ->
conn.sendPreparedStatement(
"""
WITH ranked AS (
SELECT uuid, total_shards, ROW_NUMBER() OVER (ORDER BY total_shards DESC, uuid DESC) AS rn
FROM diamond
),
target AS (
SELECT rn, ((rn - 1) / 9) * 9 AS page_offset FROM ranked WHERE uuid = ?)
paged AS
SELECT ranked.*, target.page_offset FROM ranked JOIN target ON true
WHERE ranked.rn > target.page_offset AND ranked.rn <= target.page_offset + 9
)
SELECT uuid, total_shards, page_offset FROM paged ORDER BY rn
"""
.trimIndent(),
listOf(uuid),
)
}
.await()
val baltop = mutableMapOf<UUID?, Long>()
var offset = 0L
result.rows.forEach {
val uuid = it.getAs<UUID>("uuid")
val totalShards = it.getLong("total_shards") ?: 0
offset = it.getLong("page_offset") ?: 0
baltop[uuid] = totalShards
}
return Result.success(Pair(baltop, offset))
} catch (e: Exception) {
plugin.logger.severe(e.toString())
return Result.failure(DatabaseException(e.message ?: "Database exception"))
}
}
suspend fun getNumberOfRows(): Result<Long> {
var number: Long?
try {
val connection = pool.asSuspending.connect()
val result =
connection
.inTransaction { conn -> conn.sendPreparedStatement("SELECT count(*) AS exact_count FROM diamond") }
.await()
if (result.rows.isNotEmpty()) {
val row = result.rows[0]
number = row.getLong(0) ?: 0
} else {
return Result.failure(NoRowsException())
}
return Result.success(number)
} catch (e: Exception) {
plugin.logger.severe(e.toString())
return Result.failure(DatabaseException(e.message ?: "Database exception"))
}
}
suspend fun insertTransactionLog(
playerUuid: UUID,
transferredShards: Long,
playerToUuid: UUID?,
transactionReason: String,
notes: String?,
): Result<Unit> {
try {
val connection = pool.asSuspending.connect()
connection
.inTransaction { conn ->
conn.sendPreparedStatement(
"INSERT INTO diamond_log(player_uuid, transferred_shards, player_to_uuid, transaction_reason, notes) " +
"VALUES(?, ?, ?, ?, ?)",
listOf(playerUuid, transferredShards, playerToUuid, transactionReason, notes),
)
}
.await()
} catch (e: Exception) {
return Result.failure(DatabaseException(e.message ?: "Database exception"))
}
return Result.success(Unit)
}
suspend fun hasEntry(uuid: UUID): Result<Boolean> {
try {
val connection = pool.asSuspending.connect()
val result =
connection
.inTransaction { conn ->
conn.sendPreparedStatement("SELECT 1 FROM diamond WHERE uuid = ?", listOf(uuid))
}
.await()
if (result.rows.size == 1) {
return Result.success(true)
}
} catch (e: Exception) {
return Result.failure(DatabaseException(e.message ?: "Database exception"))
}
return Result.success(false)
}
}
@@ -1,46 +0,0 @@
package net.trueog.diamondbankog.persistence
import io.lettuce.core.RedisClient
import io.lettuce.core.RedisConnectionException
import io.lettuce.core.RedisURI
import net.trueog.diamondbankog.DiamondBankOG.Companion.config
internal class Redis {
private val redisClient: RedisClient =
RedisClient.create(
RedisURI.Builder.redis(config.redisHost, config.redisPort)
.apply { config.redisPassword?.let { withPassword(it) } }
.withDatabase(config.redisDatabase)
.build()
)
/** @return True if failed */
fun testConnection(): Boolean {
try {
val connection = redisClient.connect()
connection.close()
return false
} catch (_: RedisConnectionException) {
return true
}
}
fun getValue(key: String): String? {
val connection = redisClient.connect()
val commands = connection.sync()
val value = commands.get(key)
connection.close()
return value
}
fun setValue(key: String, value: String) {
val connection = redisClient.connect()
val commands = connection.sync()
commands.set(key, value)
connection.close()
}
fun shutdown() {
redisClient.shutdown()
}
}
@@ -1,71 +0,0 @@
package net.trueog.diamondbankog.transaction
import java.util.*
import kotlin.math.floor
import net.kyori.adventure.text.minimessage.MiniMessage
import net.trueog.diamondbankog.DiamondBankException.InsufficientFundsException
import net.trueog.diamondbankog.DiamondBankOG
import net.trueog.diamondbankog.DiamondBankRuntimeException.MoreThanOneDecimalDigitRuntimeException
import net.trueog.diamondbankog.balance.BalanceManager
import net.trueog.diamondbankog.config.Config
import org.bukkit.Bukkit
import org.bukkit.OfflinePlayer
object CommonOperations {
/** @return Amount of shards that should be removed from player's bank balance */
suspend fun consume(
uuid: UUID,
shards: Long,
inventorySnapshot: InventorySnapshot,
config: Config = DiamondBankOG.config,
balanceManager: BalanceManager = DiamondBankOG.balanceManager,
mm: MiniMessage = DiamondBankOG.mm,
): Result<Long> {
val bankShards =
balanceManager.getBankShards(uuid).getOrElse {
return Result.failure(it)
}
val shardsToSubtract =
if (bankShards < shards) {
val toRemoveShards = shards - bankShards
val removedInShards =
InventorySnapshotUtils.removeShards(inventorySnapshot, toRemoveShards, config, balanceManager, mm)
.getOrElse {
return Result.failure(it)
}
.toLong()
if (removedInShards != toRemoveShards) {
val short = toRemoveShards - removedInShards
return Result.failure(InsufficientFundsException(short))
}
bankShards
} else {
shards
}
return Result.success(shardsToSubtract)
}
fun diamondsToShards(diamonds: Float): Result<Long> {
val split = diamonds.toString().split('.')
if (split[1].length > 1) {
return Result.failure(MoreThanOneDecimalDigitRuntimeException())
}
return Result.success((split[0].toLong() * 9) + split[1].toLong())
}
fun shardsToDiamonds(shards: Long) = String.format("%.1f", floor((shards / 9.0) * 10) / 10.0)
fun shardsToDiamondsFull(shards: Long): String {
val diamonds = shardsToDiamonds(shards)
return "<yellow>$diamonds <aqua>Diamond${if (diamonds != "1.0") "s" else ""}"
}
fun getPlayerUsingUuidOrName(value: String): OfflinePlayer {
return try {
val uuid = UUID.fromString(value)
Bukkit.getPlayer(uuid) ?: Bukkit.getOfflinePlayer(uuid)
} catch (_: Exception) {
Bukkit.getPlayer(value) ?: Bukkit.getOfflinePlayer(value)
}
}
}
@@ -1,41 +0,0 @@
package net.trueog.diamondbankog.transaction
import java.util.Collections
import java.util.UUID
import java.util.WeakHashMap
object InventoryLockExtensions {
val lockMap: MutableSet<UUID> = Collections.newSetFromMap(Collections.synchronizedMap(WeakHashMap()))
fun <T> UUID.withInventoryLock(block: () -> T): T {
this.lockInventory()
try {
return block()
} finally {
this.unlockInventory()
}
}
suspend fun <T> UUID.withInventoryLockSuspend(block: suspend () -> T): T {
this.lockInventory()
try {
return block()
} finally {
this.unlockInventory()
}
}
private fun UUID.lockInventory() {
val added = lockMap.add(this)
if (!added) throw IllegalStateException("Inventory was already locked")
}
private fun UUID.unlockInventory() {
val removed = lockMap.remove(this)
if (!removed) throw IllegalStateException("Inventory was not locked")
}
fun UUID.isInventoryLocked(): Boolean {
return this in lockMap
}
}
@@ -1,375 +0,0 @@
package net.trueog.diamondbankog.transaction
import java.util.*
import kotlin.UnsupportedOperationException
import kotlin.collections.HashMap
import kotlinx.coroutines.launch
import net.trueog.diamondbankog.DiamondBankOG.Companion.scope
import net.trueog.diamondbankog.balance.BalanceManager
import net.trueog.diamondbankog.balance.shard.ShardType
import net.trueog.diamondbankog.transaction.InventoryLockExtensions.isInventoryLocked
import net.trueog.diamondbankog.util.ErrorHandler.handleError
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countTotal
import net.trueog.utilitiesog.UtilitiesOG
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.entity.HumanEntity
import org.bukkit.event.inventory.InventoryType
import org.bukkit.inventory.EquipmentSlot
import org.bukkit.inventory.InventoryHolder
import org.bukkit.inventory.ItemStack
import org.bukkit.inventory.PlayerInventory
class InventorySnapshot
private constructor(
private var contents: Array<ItemStack?>,
private val heldItemSlot: Int,
private var helmet: ItemStack?,
private var chestplate: ItemStack?,
private var leggings: ItemStack?,
private var boots: ItemStack?,
val holder: UUID,
val balanceManager: BalanceManager,
) : PlayerInventory {
private val maxAmount = 64
companion object {
fun from(uuid: UUID, balanceManager: BalanceManager): InventorySnapshot {
if (!Bukkit.isPrimaryThread()) {
throw IllegalStateException("This method should only be called on the main thread")
}
if (!uuid.isInventoryLocked()) {
throw IllegalStateException("Can only take a snapshot from a locked inventory")
}
val player = Bukkit.getPlayer(uuid)
val contents =
player?.inventory?.contents?.map { it?.clone() }?.toTypedArray()
?: UtilitiesOG.getInventoryData(uuid).map { it?.clone() }.toTypedArray()
return InventorySnapshot(
contents.filterIndexed { i, _ -> i !in 36..39 }.toTypedArray(),
player?.inventory?.heldItemSlot ?: UtilitiesOG.getHeldItemSlot(uuid),
contents[39],
contents[38],
contents[37],
contents[36],
uuid,
balanceManager,
)
}
}
fun restoreTo(uuid: UUID) {
if (!Bukkit.isPrimaryThread()) {
throw IllegalStateException("This method should only be called on the main thread")
}
if (!uuid.isInventoryLocked()) {
throw IllegalStateException("Can only restore to a locked inventory")
}
val player = Bukkit.getPlayer(uuid)
val newContents = arrayOfNulls<ItemStack>(41)
contents.take(36).toTypedArray().copyInto(newContents)
newContents[39] = helmet
newContents[38] = chestplate
newContents[37] = leggings
newContents[36] = boots
newContents[40] = contents[36]
if (player == null) {
UtilitiesOG.setInventoryData(uuid, newContents.map { it?.clone() }.toTypedArray())
} else {
player.inventory.contents = newContents.map { it?.clone() }.toTypedArray()
}
val inventoryShards = this.countTotal()
scope.launch {
balanceManager.setPlayerShards(uuid, inventoryShards, ShardType.INVENTORY).getOrElse { handleError(it) }
}
}
private fun firstPartial(item: ItemStack?, inventory: Array<ItemStack?>): Int {
if (item == null) return -1
return inventory.indexOfFirst { it != null && it.amount < 64 && it.isSimilar(item) }
}
override fun getSize() = contents.size
override fun getMaxStackSize() = maxAmount
override fun setMaxStackSize(size: Int) {
throw UnsupportedOperationException()
}
override fun getItem(index: Int) = contents[index]
override fun getArmorContents(): Array<out ItemStack?> {
throw UnsupportedOperationException()
}
override fun getExtraContents(): Array<out ItemStack?> {
throw UnsupportedOperationException()
}
override fun getHelmet(): ItemStack? {
throw UnsupportedOperationException()
}
override fun getChestplate(): ItemStack? {
throw UnsupportedOperationException()
}
override fun getLeggings(): ItemStack? {
throw UnsupportedOperationException()
}
override fun getBoots(): ItemStack? {
throw UnsupportedOperationException()
}
override fun setItem(index: Int, item: ItemStack?) = contents.set(index, item)
override fun setItem(slot: EquipmentSlot, item: ItemStack?) {
throw UnsupportedOperationException()
}
override fun getItem(slot: EquipmentSlot): ItemStack {
throw UnsupportedOperationException()
}
override fun setArmorContents(items: Array<out ItemStack?>?) {
throw UnsupportedOperationException()
}
override fun setExtraContents(items: Array<out ItemStack?>?) {
throw UnsupportedOperationException()
}
override fun setHelmet(helmet: ItemStack?) {
throw UnsupportedOperationException()
}
override fun setChestplate(chestplate: ItemStack?) {
throw UnsupportedOperationException()
}
override fun setLeggings(leggings: ItemStack?) {
throw UnsupportedOperationException()
}
override fun setBoots(boots: ItemStack?) {
throw UnsupportedOperationException()
}
override fun getItemInMainHand() = contents[heldItemSlot] ?: ItemStack(Material.AIR)
override fun setItemInMainHand(item: ItemStack?) = contents.set(heldItemSlot, item)
override fun getItemInOffHand() = contents[36] ?: ItemStack(Material.AIR)
override fun setItemInOffHand(item: ItemStack?) = contents.set(36, item)
@Deprecated("Deprecated in Java")
override fun getItemInHand(): ItemStack {
throw UnsupportedOperationException()
}
@Deprecated("Deprecated in Java")
override fun setItemInHand(stack: ItemStack?) {
throw UnsupportedOperationException()
}
override fun getHeldItemSlot(): Int {
throw UnsupportedOperationException()
}
override fun setHeldItemSlot(slot: Int) {
throw UnsupportedOperationException()
}
override fun addItem(vararg items: ItemStack): HashMap<Int, ItemStack> {
val leftover = HashMap<Int, ItemStack>()
items.forEachIndexed { index, item ->
if (item.type.isAir) return@forEachIndexed
while (item.amount > 0) {
val firstPartial = firstPartial(item, contents)
if (firstPartial != -1) {
val stack = contents[firstPartial]!!
val space = maxAmount - stack.amount
val amount = minOf(item.amount, space)
stack.amount += amount
item.amount -= amount
continue
}
val empty = firstEmpty()
if (empty == -1) {
leftover[index] = item.clone()
break
}
val amount = minOf(item.amount, maxAmount)
contents[empty] = item.clone().apply { this.amount = amount }
item.amount -= amount
}
}
return leftover
}
override fun removeItem(vararg items: ItemStack): HashMap<Int, ItemStack> {
val leftover = HashMap<Int, ItemStack>()
items.forEachIndexed { index, item ->
if (item.type.isAir) return@forEachIndexed
var toDelete = item.amount
while (toDelete > 0) {
val firstSlot = first(item)
if (firstSlot == -1) {
item.amount = toDelete
leftover[index] = item
toDelete = 0
} else {
val itemStack = contents[firstSlot]!!
val removed = minOf(itemStack.amount, toDelete)
itemStack.amount -= removed
toDelete -= removed
if (itemStack.amount == 0) {
setItem(firstSlot, null)
}
}
}
}
return leftover
}
override fun removeItemAnySlot(vararg items: ItemStack): HashMap<Int?, ItemStack?> {
throw UnsupportedOperationException()
}
override fun getContents(): Array<out ItemStack?> {
return (contents.dropLast(1) + boots + leggings + chestplate + helmet + contents.takeLast(1)).toTypedArray()
}
override fun setContents(items: Array<out ItemStack?>) {
throw UnsupportedOperationException()
}
override fun getStorageContents(): Array<out ItemStack?> = contents
override fun setStorageContents(items: Array<out ItemStack?>) {
throw UnsupportedOperationException()
}
override fun contains(material: Material): Boolean {
throw UnsupportedOperationException()
}
override fun contains(item: ItemStack?): Boolean {
throw UnsupportedOperationException()
}
override fun contains(material: Material, amount: Int): Boolean {
throw UnsupportedOperationException()
}
override fun contains(item: ItemStack?, amount: Int): Boolean {
throw UnsupportedOperationException()
}
override fun containsAtLeast(item: ItemStack?, amount: Int): Boolean {
throw UnsupportedOperationException()
}
override fun all(material: Material): HashMap<Int, out ItemStack?> =
contents
.dropLast(1) // Bukkit's implementation doesn't include the offhand slot
.withIndex()
.filter { (_, value) -> value?.type == material }
.associateTo(HashMap()) { (index, value) -> index to value }
override fun all(item: ItemStack?): HashMap<Int?, out ItemStack?> {
throw UnsupportedOperationException()
}
override fun first(material: Material): Int {
throw UnsupportedOperationException()
}
override fun first(item: ItemStack): Int {
return contents.indexOfFirst { it != null && item.isSimilar(it) }
}
override fun firstEmpty(): Int {
return contents.indexOfFirst { it == null }
}
override fun isEmpty(): Boolean {
throw UnsupportedOperationException()
}
override fun remove(material: Material) {
contents.forEachIndexed { index, item ->
if (item?.type == material) {
contents[index] = null
}
}
}
override fun remove(itemToRemove: ItemStack) {
contents.forEachIndexed { index, item ->
if (item?.equals(itemToRemove) == true) {
contents[index] = null
}
}
}
override fun clear(index: Int) {
throw UnsupportedOperationException()
}
override fun clear() {
throw UnsupportedOperationException()
}
override fun close(): Int {
throw UnsupportedOperationException()
}
override fun getViewers(): List<HumanEntity?> {
throw UnsupportedOperationException()
}
override fun getType(): InventoryType {
throw UnsupportedOperationException()
}
override fun getHolder(): HumanEntity? {
throw UnsupportedOperationException()
}
override fun getHolder(useSnapshot: Boolean): InventoryHolder? {
throw UnsupportedOperationException()
}
override fun iterator(): MutableListIterator<ItemStack?> {
throw UnsupportedOperationException()
}
override fun iterator(index: Int): ListIterator<ItemStack?> {
throw UnsupportedOperationException()
}
override fun getLocation(): Location? {
throw UnsupportedOperationException()
}
}
@@ -1,124 +0,0 @@
package net.trueog.diamondbankog.transaction
import kotlin.math.ceil
import net.kyori.adventure.text.minimessage.MiniMessage
import net.trueog.diamondbankog.balance.BalanceManager
import net.trueog.diamondbankog.balance.shard.Shard
import net.trueog.diamondbankog.config.Config
import net.trueog.diamondbankog.util.InventoryExtensions.removeItemWithSameKey
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countDiamondBlocks
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countDiamonds
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countShards
import org.bukkit.Bukkit
import org.bukkit.Material
import org.bukkit.inventory.ItemStack
object InventorySnapshotUtils {
/** @return the amount of shards that could be removed */
suspend fun removeShards(
inventory: InventorySnapshot,
shards: Long,
config: Config,
balanceManager: BalanceManager,
mm: MiniMessage,
): Result<Int> {
require(shards > 0)
val shards = shards.toInt()
val notRemovedShards = inventory.removeItemWithSameKey(Shard.createItemStack(shards)).values.sumOf { it.amount }
if (notRemovedShards == 0) return Result.success(shards)
val diamondsToBeRemoved = ceil(notRemovedShards / 9.0).toInt()
val diamondsRemainder = notRemovedShards % 9
val shardsChange = if (diamondsRemainder == 0) 0 else 9 - diamondsRemainder
val notRemovedDiamonds =
inventory.removeItem(ItemStack(Material.DIAMOND, diamondsToBeRemoved)).values.sumOf { it.amount }
if (notRemovedDiamonds == 0) {
val addMap = inventory.addItem(Shard.createItemStack(shardsChange))
if (addMap.values.sumOf { it.amount } != 0) {
val excessShards = addMap.values.sumOf { it.amount }.toLong()
balanceManager.addToBankShards(inventory.holder, excessShards).getOrElse {
return Result.failure(it)
}
val player = Bukkit.getPlayer(inventory.holder)
player?.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <yellow>The excess change of ${
CommonOperations.shardsToDiamondsFull(
excessShards
)
} <yellow>has been deposited into your bank account"
)
)
}
return Result.success(shards)
}
val diamondBlocksToBeRemoved = ceil(notRemovedDiamonds / 9.0).toInt()
val diamondBlocksRemainder = notRemovedDiamonds % 9
val diamondsChange = if (diamondBlocksRemainder == 0) 0 else 9 - diamondBlocksRemainder
val notRemovedDiamondBlocks =
inventory.removeItem(ItemStack(Material.DIAMOND_BLOCK, diamondBlocksToBeRemoved)).values.sumOf { it.amount }
if (notRemovedDiamondBlocks == 0) {
val shardsAddMap = inventory.addItem(Shard.createItemStack(shardsChange))
val excessShards = shardsAddMap.values.sumOf { it.amount }.toLong()
if (shardsAddMap.values.sumOf { it.amount } != 0) {
balanceManager.addToBankShards(inventory.holder, excessShards).getOrElse {
return Result.failure(it)
}
}
val diamondsAddMap = inventory.addItem(ItemStack(Material.DIAMOND, diamondsChange))
val excessDiamondsInShards = diamondsAddMap.values.sumOf { it.amount }.toLong() * 9
if (diamondsAddMap.values.sumOf { it.amount } != 0) {
balanceManager.addToBankShards(inventory.holder, excessDiamondsInShards).getOrElse {
return Result.failure(it)
}
}
if (shardsAddMap.values.sumOf { it.amount } != 0 || diamondsAddMap.values.sumOf { it.amount } != 0) {
val player = Bukkit.getPlayer(inventory.holder)
player?.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <yellow>The excess change of ${
CommonOperations.shardsToDiamondsFull(
excessShards + excessDiamondsInShards
)
} <yellow>has been deposited into your bank account"
)
)
}
return Result.success(shards)
}
return Result.success(shards - (notRemovedDiamondBlocks * 81 - diamondsChange * 9 - shardsChange))
}
/** @return the amount of currency items that could be removed in shards */
fun removeAll(inventory: InventorySnapshot): Int {
val removedShards = removeAllShards(inventory)
val removedDiamonds = removeAllDiamonds(inventory)
val removedDiamondBlocks = removeAllDiamondBlocks(inventory)
return removedShards + (removedDiamonds * 9) + (removedDiamondBlocks * 81)
}
/** @return the amount of shards that could be removed */
fun removeAllShards(inventory: InventorySnapshot): Int {
val shards = inventory.countShards().toInt()
inventory.removeItemWithSameKey(Shard.createItemStack(shards))
return shards
}
/** @return the amount of diamonds that could be removed */
fun removeAllDiamonds(inventory: InventorySnapshot): Int {
val diamonds = inventory.countDiamonds().toInt()
inventory.remove(Material.DIAMOND)
return diamonds
}
/** @return the amount of diamond blocks that could be removed */
fun removeAllDiamondBlocks(inventory: InventorySnapshot): Int {
val diamondBlocks = inventory.countDiamondBlocks().toInt()
inventory.remove(Material.DIAMOND_BLOCK)
return diamondBlocks
}
}
@@ -1,69 +0,0 @@
package net.trueog.diamondbankog.transaction
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Semaphore
internal class TransactionLock {
private val locks = ConcurrentHashMap<UUID, Semaphore>()
sealed class LockResult<out T> {
data class Acquired<T>(val result: T) : LockResult<T>()
object Failed : LockResult<Nothing>()
}
fun <T> withLock(uuid: UUID, block: () -> T): T {
val lock = locks.computeIfAbsent(uuid) { Semaphore(1, true) }
lock.acquire()
try {
return block()
} finally {
lock.release()
}
}
suspend fun <T> withLockSuspend(uuid: UUID, block: suspend () -> T): T {
val lock = locks.computeIfAbsent(uuid) { Semaphore(1, true) }
lock.acquire()
try {
return block()
} finally {
lock.release()
}
}
fun <T> tryWithLock(uuid: UUID, block: () -> T): LockResult<T> {
val lock = locks.computeIfAbsent(uuid) { Semaphore(1, true) }
return if (lock.tryAcquire()) {
try {
return LockResult.Acquired(block())
} finally {
lock.release()
}
} else {
LockResult.Failed
}
}
suspend fun <T> tryWithLockSuspend(uuid: UUID, block: suspend () -> T): LockResult<T> {
val lock = locks.computeIfAbsent(uuid) { Semaphore(1, true) }
return if (lock.tryAcquire()) {
try {
LockResult.Acquired(block())
} finally {
lock.release()
}
} else {
LockResult.Failed
}
}
fun isLocked(uuid: UUID): Boolean {
return locks[uuid]?.availablePermits() == 0
}
fun removeAllLocks() {
locks.clear()
}
}
@@ -1,270 +0,0 @@
package net.trueog.diamondbankog.transaction.command
import kotlin.math.abs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import net.kyori.adventure.text.minimessage.MiniMessage
import net.trueog.diamondbankog.*
import net.trueog.diamondbankog.balance.BalanceManager
import net.trueog.diamondbankog.balance.shard.Shard
import net.trueog.diamondbankog.config.Config
import net.trueog.diamondbankog.transaction.InventoryLockExtensions.withInventoryLockSuspend
import net.trueog.diamondbankog.transaction.InventorySnapshot
import net.trueog.diamondbankog.transaction.TransactionLock
import net.trueog.diamondbankog.util.CommonCommandInterlude
import net.trueog.diamondbankog.util.InventoryExtensions.countDiamondBlocks
import net.trueog.diamondbankog.util.InventoryExtensions.countDiamonds
import net.trueog.diamondbankog.util.InventoryExtensions.countShards
import net.trueog.diamondbankog.util.InventoryExtensions.removeItemWithSameKey
import net.trueog.diamondbankog.util.MainThreadBlock.runOnMainThread
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countDiamondBlocks
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countDiamonds
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countShards
import org.bukkit.Material
import org.bukkit.block.ShulkerBox
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
import org.bukkit.inventory.ItemStack
import org.bukkit.inventory.PlayerInventory
import org.bukkit.inventory.meta.BlockStateMeta
internal class Compress(
val config: Config = DiamondBankOG.config,
val balanceManager: BalanceManager = DiamondBankOG.balanceManager,
val mm: MiniMessage = DiamondBankOG.mm,
val scope: CoroutineScope = DiamondBankOG.scope,
val transactionLock: TransactionLock = DiamondBankOG.transactionLock,
) : CommandExecutor {
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>?): Boolean {
if (CommonCommandInterlude.run(sender, "compress", config, mm)) {
return true
}
scope.launch {
var isShulkerBox = false
var finalShards: Int
var finalDiamonds: Int
var finalDiamondBlocks: Int
when (
transactionLock.tryWithLockSuspend(sender.uniqueId) {
val summaryString =
sender.uniqueId
.withInventoryLockSuspend {
val inventorySnapshot = runOnMainThread {
InventorySnapshot.from(sender.uniqueId, balanceManager)
}
val (inventory, blockStateMeta, blockState) =
if (!args.isNullOrEmpty()) {
if (args.size > 1) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Do not provide more arguments than \"yes\" if you want to compress the items in the shulker box you're holding."
)
)
return@withInventoryLockSuspend Result.failure(Exception())
}
if (args[0] != "yes") {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>Invalid argument.")
)
return@withInventoryLockSuspend Result.failure(Exception())
}
val itemInMainHand = inventorySnapshot.itemInMainHand
if (itemInMainHand.type != Material.SHULKER_BOX) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>You are not holding a shulker box."
)
)
return@withInventoryLockSuspend Result.failure(Exception())
}
val blockStateMeta = itemInMainHand.itemMeta as BlockStateMeta
val blockState = blockStateMeta.blockState as ShulkerBox
isShulkerBox = true
Triple(blockState.inventory, blockStateMeta, blockState)
} else {
Triple(inventorySnapshot, null, null)
}
if (inventorySnapshot.itemInMainHand.type == Material.SHULKER_BOX && !isShulkerBox) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <#FFA500>Are you sure you want to compress the Diamond currency items in the shulker box you're holding? If so, run \"/compress yes\""
)
)
return@withInventoryLockSuspend Result.failure(Exception())
}
// Make sure we use the PlayerInventory implementations if it is one
val playerInventory = inventory as? PlayerInventory
val shardsInInventory =
(playerInventory?.countShards() ?: inventory.countShards()).toInt()
val diamondsInInventory =
(playerInventory?.countDiamonds() ?: inventory.countDiamonds()).toInt()
val diamondBlocksInInventory =
(playerInventory?.countDiamondBlocks() ?: inventory.countDiamondBlocks()).toInt()
finalShards = shardsInInventory
finalDiamonds = diamondsInInventory
finalDiamondBlocks = diamondBlocksInInventory
finalDiamonds += finalShards / 9
if (finalDiamonds - diamondsInInventory != 0) {
val remainder = finalShards % 9
finalShards = remainder
}
finalDiamondBlocks += finalDiamonds / 9
if (finalDiamondBlocks - diamondBlocksInInventory != 0) {
val remainder = finalDiamonds % 9
finalDiamonds = remainder
}
val changeInShards = finalShards - shardsInInventory
val changeInDiamonds = finalDiamonds - diamondsInInventory
val changeInDiamondBlocks = finalDiamondBlocks - diamondBlocksInInventory
if (changeInDiamonds > 0) {
val emptySlots = inventory.storageContents.filter { it == null }.size * 64
val leftOverSpaceDiamonds =
inventory.storageContents
.filterNotNull()
.filter { it.type == Material.DIAMOND }
.sumOf { 64 - it.amount }
if (changeInDiamonds > emptySlots + leftOverSpaceDiamonds) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>You do not have enough space in your ${if (isShulkerBox) "shulker box" else "inventory"} to compress all the Diamond currency items (<green>+$changeInDiamonds <aqua>Diamonds<red>)."
)
)
return@withInventoryLockSuspend Result.failure(Exception())
}
}
if (changeInDiamondBlocks > 0) {
val emptySlots = inventory.storageContents.filter { it == null }.size * 64
val leftOverSpaceDiamondBlocks =
inventory.storageContents
.filterNotNull()
.filter { it.type == Material.DIAMOND_BLOCK }
.sumOf { 64 - it.amount }
if (changeInDiamondBlocks > emptySlots + leftOverSpaceDiamondBlocks) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>You do not have enough space in your ${if (isShulkerBox) "shulker box" else "inventory"} to compress all the Diamond currency items (<green>+$changeInDiamondBlocks <aqua>Diamond Blocks<red>)."
)
)
return@withInventoryLockSuspend Result.failure(Exception())
}
}
val summaryStringBuilder = StringBuilder("Compression Summary:")
if (changeInShards < 0) {
val removeMap =
inventory.removeItemWithSameKey(Shard.createItemStack(abs(changeInShards)))
if (removeMap.isNotEmpty()) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Something went wrong while trying to compress the Diamond currency items in your ${if (isShulkerBox) "shulker box" else "inventory"}."
)
)
return@withInventoryLockSuspend Result.failure(Exception())
}
summaryStringBuilder.append(
"\n<red>$changeInShards Diamond Shard${if (changeInShards != -1) "s" else ""}"
)
}
if (changeInDiamonds > 0) {
val addMap = inventory.addItem(ItemStack(Material.DIAMOND, changeInDiamonds))
if (addMap.isNotEmpty()) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Something went wrong while trying to compress the Diamond currency items in your ${if (isShulkerBox) "shulker box" else "inventory"}."
)
)
return@withInventoryLockSuspend Result.failure(Exception())
}
summaryStringBuilder.append(
"\n<green>+$changeInDiamonds Diamond${if (changeInDiamonds != 1) "s" else ""}"
)
} else if (changeInDiamonds < 0) {
val removeMap =
inventory.removeItem(ItemStack(Material.DIAMOND, abs(changeInDiamonds)))
if (removeMap.isNotEmpty()) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Something went wrong while trying to compress the Diamond currency items in your ${if (isShulkerBox) "shulker box" else "inventory"}."
)
)
return@withInventoryLockSuspend Result.failure(Exception())
}
summaryStringBuilder.append(
"\n<red>$changeInDiamonds Diamond${if (changeInDiamonds != -1) "s" else ""}"
)
}
if (changeInDiamondBlocks > 0) {
val addMap =
inventory.addItem(ItemStack(Material.DIAMOND_BLOCK, changeInDiamondBlocks))
if (addMap.isNotEmpty()) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Something went wrong while trying to compress the Diamond currency items in your ${if (isShulkerBox) "shulker box" else "inventory"}."
)
)
return@withInventoryLockSuspend Result.failure(Exception())
}
summaryStringBuilder.append(
"\n<green>+$changeInDiamondBlocks Diamond Block${if (changeInDiamondBlocks != 1) "s" else ""}"
)
}
if (blockStateMeta != null && blockState != null) {
blockStateMeta.blockState = blockState
inventorySnapshot.itemInMainHand.itemMeta = blockStateMeta
}
if (changeInShards == 0 && changeInDiamonds == 0 && changeInDiamondBlocks == 0) {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <#FFA500>Nothing found to compress.")
)
return@withInventoryLockSuspend Result.failure(Exception())
}
runOnMainThread { inventorySnapshot.restoreTo(sender.uniqueId) }
Result.success(summaryStringBuilder.toString())
}
.getOrElse {
return@tryWithLockSuspend
}
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <green>Successfully compressed all the Diamond currency items in your ${if (isShulkerBox) "shulker box" else "inventory"}!"
)
)
sender.sendMessage(mm.deserialize("${config.prefix}<reset>: $summaryString"))
}
) {
is TransactionLock.LockResult.Failed -> {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>You are currently blocked from using /compress.")
)
return@launch
}
else -> {}
}
}
return true
}
}
@@ -1,165 +0,0 @@
package net.trueog.diamondbankog.transaction.command
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import net.kyori.adventure.text.minimessage.MiniMessage
import net.trueog.diamondbankog.*
import net.trueog.diamondbankog.balance.BalanceManager
import net.trueog.diamondbankog.config.Config
import net.trueog.diamondbankog.transaction.CommonOperations
import net.trueog.diamondbankog.transaction.InventoryLockExtensions.withInventoryLockSuspend
import net.trueog.diamondbankog.transaction.InventorySnapshot
import net.trueog.diamondbankog.transaction.InventorySnapshotUtils
import net.trueog.diamondbankog.transaction.TransactionLock
import net.trueog.diamondbankog.util.CommonCommandInterlude
import net.trueog.diamondbankog.util.ErrorHandler.handleError
import net.trueog.diamondbankog.util.MainThreadBlock.runOnMainThread
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
internal class Deposit(
val config: Config = DiamondBankOG.config,
val balanceManager: BalanceManager = DiamondBankOG.balanceManager,
val mm: MiniMessage = DiamondBankOG.mm,
val scope: CoroutineScope = DiamondBankOG.scope,
val transactionLock: TransactionLock = DiamondBankOG.transactionLock,
) : CommandExecutor {
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>?): Boolean {
if (CommonCommandInterlude.run(sender, "deposit", config, mm)) {
return true
}
if (args.isNullOrEmpty()) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>You did not provide the amount of <aqua>Diamonds <red>that you want to deposit."
)
)
return true
}
if (args.size != 1) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Please (only) provide the amount of <aqua>Diamonds <red>you want to deposit. Either a number or \"all\"."
)
)
return true
}
var shards: Long = -1L
if (args[0] != "all") {
val amount: Float
try {
amount = args[0].toFloat()
if (amount <= 0) {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>You cannot deposit a negative or zero amount.")
)
return true
}
} catch (_: Exception) {
sender.sendMessage(mm.deserialize("${config.prefix}<reset>: <red>Invalid argument."))
return true
}
shards =
CommonOperations.diamondsToShards(amount).getOrElse {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <aqua>Diamonds<red> can only have one decimal digit. Issue /diamondbankhelp for more information."
)
)
return true
}
}
scope.launch {
when (
transactionLock.tryWithLockSuspend(sender.uniqueId) {
val removedInShards =
sender.uniqueId
.withInventoryLockSuspend {
val inventorySnapshot = runOnMainThread {
InventorySnapshot.from(sender.uniqueId, balanceManager)
}
val removedInShards: Long =
if (shards == -1L) {
InventorySnapshotUtils.removeAll(inventorySnapshot).toLong()
} else {
InventorySnapshotUtils.removeShards(
inventorySnapshot,
shards,
config,
balanceManager,
mm,
)
.getOrElse {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Something went wrong."
)
)
handleError(it)
return@withInventoryLockSuspend Result.failure(Exception())
}
.toLong()
}
if (shards != -1L && removedInShards != shards) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>You do not have ${
CommonOperations.shardsToDiamondsFull(
shards
)
} <red>to deposit."
)
)
return@withInventoryLockSuspend Result.failure(Exception())
}
balanceManager.addToBankShards(sender.uniqueId, removedInShards).getOrElse {
handleError(it)
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Something went wrong while trying to add to your balance."
)
)
return@withInventoryLockSuspend Result.failure(Exception())
}
runOnMainThread { inventorySnapshot.restoreTo(sender.uniqueId) }
Result.success(removedInShards)
}
.getOrElse {
return@tryWithLockSuspend
}
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <green>Successfully deposited ${
CommonOperations.shardsToDiamondsFull(
removedInShards
)
} <green>into your bank account."
)
)
balanceManager.insertTransactionLog(sender.uniqueId, shards, null, "Deposit", null).getOrElse {
handleError(it)
}
}
) {
is TransactionLock.LockResult.Failed -> {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>You are currently blocked from using /deposit.")
)
return@launch
}
else -> {}
}
}
return true
}
}
@@ -1,208 +0,0 @@
package net.trueog.diamondbankog.transaction.command
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import net.kyori.adventure.text.minimessage.MiniMessage
import net.luckperms.api.LuckPerms
import net.trueog.diamondbankog.*
import net.trueog.diamondbankog.balance.BalanceManager
import net.trueog.diamondbankog.config.Config
import net.trueog.diamondbankog.transaction.CommonOperations
import net.trueog.diamondbankog.transaction.InventoryLockExtensions.withInventoryLockSuspend
import net.trueog.diamondbankog.transaction.InventorySnapshot
import net.trueog.diamondbankog.transaction.TransactionLock
import net.trueog.diamondbankog.util.CommonCommandInterlude
import net.trueog.diamondbankog.util.ErrorHandler.handleError
import net.trueog.diamondbankog.util.MainThreadBlock.runOnMainThread
import net.trueog.diamondbankog.util.PlayerPrefix.getPrefix
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
internal class Pay(
val config: Config = DiamondBankOG.config,
val balanceManager: BalanceManager = DiamondBankOG.balanceManager,
val mm: MiniMessage = DiamondBankOG.mm,
val scope: CoroutineScope = DiamondBankOG.scope,
val luckPerms: LuckPerms = DiamondBankOG.luckPerms,
val transactionLock: TransactionLock = DiamondBankOG.transactionLock,
) : CommandExecutor {
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>?): Boolean {
if (CommonCommandInterlude.run(sender, "pay", config, mm)) {
return true
}
if (args.isNullOrEmpty()) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>You did not provide the name or the UUID of a player and the amount of <aqua>Diamonds<red>."
)
)
return true
}
if (args.size != 2) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Please (only) provide the name or the UUID of a player and the amount of <aqua>Diamonds<red>."
)
)
return true
}
val receiver = CommonOperations.getPlayerUsingUuidOrName(args[0])
if (sender.uniqueId == receiver.uniqueId) {
sender.sendMessage(mm.deserialize("${config.prefix}<reset>: <red>You cannot pay yourself."))
return true
}
if (!receiver.hasPlayedBefore()) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>That player doesn't exist or hasn't joined this server before."
)
)
return true
}
val amount: Float
try {
amount = args[1].toFloat()
if (amount <= 0) {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>You cannot pay a negative or zero amount.")
)
return true
}
} catch (_: Exception) {
sender.sendMessage(mm.deserialize("${config.prefix}<reset>: <red>Invalid argument."))
return true
}
val shards =
CommonOperations.diamondsToShards(amount).getOrElse {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <aqua>Diamonds<red> can only have one decimal digit. Issue /diamondbankhelp for more information."
)
)
return true
}
scope.launch {
when (
transactionLock.tryWithLockSuspend(sender.uniqueId) {
sender.uniqueId
.withInventoryLockSuspend {
val inventorySnapshot = runOnMainThread {
InventorySnapshot.from(sender.uniqueId, balanceManager)
}
val shardsToSubtractFromSender =
CommonOperations.consume(
sender.uniqueId,
shards,
inventorySnapshot,
config,
balanceManager,
mm,
)
.getOrElse {
when (it) {
is DiamondBankException.InsufficientFundsException -> {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>You are ${
CommonOperations.shardsToDiamondsFull(
it.short
)
} <red>short for that payment."
)
)
return@withInventoryLockSuspend Result.failure(Exception())
}
else -> {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>A severe error has occurred. Please notify a staff member."
)
)
return@withInventoryLockSuspend Result.failure(Exception())
}
}
}
balanceManager
.transferBankShards(
sender.uniqueId,
receiver.uniqueId,
shardsToSubtractFromSender,
shards,
)
.getOrElse {
handleError(it)
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>A severe error has occurred. Please notify a staff member."
)
)
return@withInventoryLockSuspend Result.failure(Exception())
}
runOnMainThread { inventorySnapshot.restoreTo(sender.uniqueId) }
Result.success(Unit)
}
.getOrElse {
return@tryWithLockSuspend
}
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <green>Successfully paid ${
CommonOperations.shardsToDiamondsFull(
shards
)
} <green>to ${
getPrefix(
receiver.uniqueId,
luckPerms,
)
}${receiver.name}<reset><green>."
)
)
if (receiver.isOnline) {
val receiverPlayer = receiver.player ?: return@tryWithLockSuspend
receiverPlayer.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <green>${
getPrefix(
sender.uniqueId,
luckPerms,
)
}${sender.name}<reset> <green>has paid you ${
CommonOperations.shardsToDiamondsFull(
shards
)
}<green>."
)
)
}
balanceManager
.insertTransactionLog(sender.uniqueId, shards, receiver.uniqueId, "Pay", null)
.getOrElse { handleError(it) }
}
) {
is TransactionLock.LockResult.Failed -> {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>You are currently blocked from using /pay.")
)
return@launch
}
else -> {}
}
}
return true
}
}
@@ -1,187 +0,0 @@
package net.trueog.diamondbankog.transaction.command
import kotlin.math.floor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import net.kyori.adventure.text.minimessage.MiniMessage
import net.trueog.diamondbankog.*
import net.trueog.diamondbankog.balance.BalanceManager
import net.trueog.diamondbankog.balance.shard.Shard
import net.trueog.diamondbankog.config.Config
import net.trueog.diamondbankog.transaction.CommonOperations
import net.trueog.diamondbankog.transaction.InventoryLockExtensions.withInventoryLockSuspend
import net.trueog.diamondbankog.transaction.InventorySnapshot
import net.trueog.diamondbankog.transaction.TransactionLock
import net.trueog.diamondbankog.util.CommonCommandInterlude
import net.trueog.diamondbankog.util.ErrorHandler.handleError
import net.trueog.diamondbankog.util.MainThreadBlock.runOnMainThread
import org.bukkit.Material
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
import org.bukkit.inventory.ItemStack
internal class Withdraw(
val config: Config = DiamondBankOG.config,
val balanceManager: BalanceManager = DiamondBankOG.balanceManager,
val mm: MiniMessage = DiamondBankOG.mm,
val scope: CoroutineScope = DiamondBankOG.scope,
val transactionLock: TransactionLock = DiamondBankOG.transactionLock,
) : CommandExecutor {
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>?): Boolean {
if (CommonCommandInterlude.run(sender, "withdraw", config, mm)) {
return true
}
if (args.isNullOrEmpty()) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>You did not provide the amount of <aqua>Diamonds <red>that you want to withdraw."
)
)
return true
}
if (args.size != 1) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Please (only) provide the amount of <aqua>Diamonds <red>you want to withdraw. Either a number or \"all\"."
)
)
return true
}
var shards = -1L
if (args[0] != "all") {
val amount: Float
try {
amount = args[0].toFloat()
if (amount <= 0) {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>You cannot withdraw a negative or zero amount.")
)
return true
}
} catch (_: Exception) {
sender.sendMessage(mm.deserialize("${config.prefix}<reset>: <red>Invalid argument."))
return true
}
shards =
CommonOperations.diamondsToShards(amount).getOrElse {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <aqua>Diamonds<red> can only have one decimal digit. Issue /diamondbankhelp for more information."
)
)
return true
}
}
scope.launch {
when (
transactionLock.tryWithLockSuspend(sender.uniqueId) {
val shardsToWithdraw =
sender.uniqueId
.withInventoryLockSuspend {
val inventorySnapshot = runOnMainThread {
InventorySnapshot.from(sender.uniqueId, balanceManager)
}
val bankShards =
balanceManager.getBankShards(sender.uniqueId).getOrElse {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Something went wrong while trying to get your balance."
)
)
return@withInventoryLockSuspend Result.failure(Exception())
}
val shardsToWithdraw =
if (shards == -1L) {
bankShards
} else {
shards
}
if (shards != -1L && shards > bankShards) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>Cannot withdraw ${
CommonOperations.shardsToDiamondsFull(
shardsToWithdraw
)
} <red>because your bank only contains ${
CommonOperations.shardsToDiamondsFull(
bankShards
)
}<red>."
)
)
return@withInventoryLockSuspend Result.failure(Exception())
}
val diamondsToAdd = floor(shardsToWithdraw / 9.0).toInt()
val shardsChange = shardsToWithdraw.toInt() % 9
if (
inventorySnapshot
.addItem(ItemStack(Material.DIAMOND, diamondsToAdd))
.isNotEmpty() ||
inventorySnapshot.addItem(Shard.createItemStack(shardsChange)).isNotEmpty()
) {
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>You don't have enough inventory space to withdraw ${
CommonOperations.shardsToDiamondsFull(
shardsToWithdraw
)
}<red>."
)
)
return@withInventoryLockSuspend Result.failure(Exception())
}
balanceManager.subtractFromBankShards(sender.uniqueId, shardsToWithdraw).getOrElse {
handleError(it)
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <red>A severe error has occurred. Please notify a staff member."
)
)
return@withInventoryLockSuspend Result.failure(Exception())
}
runOnMainThread { inventorySnapshot.restoreTo(sender.uniqueId) }
Result.success(shardsToWithdraw)
}
.getOrElse {
return@tryWithLockSuspend
}
sender.sendMessage(
mm.deserialize(
"${config.prefix}<reset>: <green>Successfully withdrew ${
CommonOperations.shardsToDiamondsFull(
shardsToWithdraw
)
} <green>from your bank account."
)
)
balanceManager.insertTransactionLog(sender.uniqueId, shards, null, "Withdraw", null).getOrElse {
handleError(it)
}
}
) {
is TransactionLock.LockResult.Failed -> {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>You are currently blocked from using /withdraw.")
)
}
else -> {}
}
}
return true
}
}
@@ -1,44 +0,0 @@
package net.trueog.diamondbankog.util
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
import net.kyori.adventure.text.minimessage.MiniMessage
import net.trueog.diamondbankog.DiamondBankOG.Companion.economyDisabled
import net.trueog.diamondbankog.config.Config
import org.bukkit.command.CommandSender
import org.bukkit.entity.Player
object CommonCommandInterlude {
/** @return should return */
@OptIn(ExperimentalContracts::class)
fun run(sender: CommandSender, command: String, config: Config, mm: MiniMessage): Boolean {
contract { returns(false) implies (sender is Player) }
if (economyDisabled) {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>The economy is disabled. Please notify a staff member.")
)
return true
}
if (sender !is Player) {
sender.sendMessage("You can only execute this command as a player.")
return true
}
val worldName = sender.world.name
if (worldName != "world" && worldName != "world_nether" && worldName != "world_the_end") {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>You cannot use /$command when in a minigame.")
)
return true
}
if (!sender.hasPermission("diamondbank-og.$command")) {
sender.sendMessage(
mm.deserialize("${config.prefix}<reset>: <red>You do not have permission to use this command.")
)
return true
}
return false
}
}
@@ -1,13 +0,0 @@
package net.trueog.diamondbankog.util
import java.util.logging.Level
import net.trueog.diamondbankog.DiamondBankOG.Companion.economyDisabled
import net.trueog.diamondbankog.DiamondBankOG.Companion.plugin
internal object ErrorHandler {
/** Handles the error by throwing, disables the economy unless specified that it shouldn't */
fun handleError(exception: Throwable, dontDisableEconomy: Boolean = false) {
if (!dontDisableEconomy) economyDisabled = true
plugin.logger.log(Level.SEVERE, "Economy error", exception)
}
}
@@ -1,72 +0,0 @@
package net.trueog.diamondbankog.util
import net.trueog.diamondbankog.balance.shard.Shard
import org.bukkit.Material
import org.bukkit.block.ShulkerBox
import org.bukkit.inventory.Inventory
import org.bukkit.inventory.ItemStack
import org.bukkit.inventory.meta.BlockStateMeta
internal object InventoryExtensions {
fun Inventory.countTotal(): Long {
return this.countShards() +
this.countDiamonds() * 9 +
this.countDiamondBlocks() * 81 +
this.all(Material.SHULKER_BOX).values.sumOf {
((it.itemMeta as BlockStateMeta).blockState as ShulkerBox).inventory.countTotal()
}
}
fun Inventory.countShards(): Long {
val inventoryShards =
this.all(Material.PRISMARINE_SHARD).values.filter { Shard.isShardItem(it) }.sumOf { it.amount }
return inventoryShards.toLong()
}
fun Inventory.countDiamonds(): Long {
val inventoryDiamonds = this.all(Material.DIAMOND).values.sumOf { it.amount }
return inventoryDiamonds.toLong()
}
fun Inventory.countDiamondBlocks(): Long {
val inventoryDiamondBlocks = this.all(Material.DIAMOND_BLOCK).values.sumOf { it.amount }
return inventoryDiamondBlocks.toLong()
}
fun Inventory.removeItemWithSameKey(vararg items: ItemStack): HashMap<Int, ItemStack> {
val leftover = HashMap<Int, ItemStack>()
items.forEachIndexed { index, item ->
if (item.type.isAir) return@forEachIndexed
var toDelete = item.amount
while (toDelete > 0) {
val firstSlot = firstWithSameKey(item)
if (firstSlot == -1) {
item.amount = toDelete
leftover[index] = item
toDelete = 0
} else {
val itemStack = contents[firstSlot]!!
val removed = minOf(itemStack.amount, toDelete)
itemStack.amount -= removed
toDelete -= removed
if (itemStack.amount == 0) {
setItem(firstSlot, null)
}
}
}
}
return leftover
}
fun Inventory.firstWithSameKey(item: ItemStack): Int {
return contents.indexOfFirst {
it != null && item.itemMeta.persistentDataContainer.keys == it.itemMeta.persistentDataContainer.keys
}
}
}
@@ -1,29 +0,0 @@
package net.trueog.diamondbankog.util
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine
import net.trueog.diamondbankog.DiamondBankOG.Companion.plugin
import org.bukkit.Bukkit
internal object MainThreadBlock {
suspend fun <T> runOnMainThread(block: () -> T): T {
return if (Bukkit.isPrimaryThread()) {
block()
} else {
suspendCancellableCoroutine { cont ->
Bukkit.getScheduler()
.runTask(
plugin,
Runnable {
try {
cont.resume(block())
} catch (e: Throwable) {
cont.resumeWithException(e)
}
},
)
}
}
}
}
@@ -1,34 +0,0 @@
package net.trueog.diamondbankog.util
import net.trueog.diamondbankog.balance.shard.Shard
import org.bukkit.Material
import org.bukkit.inventory.ItemStack
import org.bukkit.inventory.PlayerInventory
internal object PlayerInventoryExtensions {
private fun PlayerInventory.allValues(material: Material): List<ItemStack> =
this.all(material).values + listOfNotNull(this.itemInOffHand.takeIf { it.type == material })
fun PlayerInventory.countTotal(): Long {
return this.countShards() + this.countDiamonds() * 9 + this.countDiamondBlocks() * 81
// this.allValues(Material.SHULKER_BOX).sumOf {
// ((it.itemMeta as BlockStateMeta).blockState as ShulkerBox).inventory.countTotal()
// }
}
fun PlayerInventory.countShards(): Long {
val inventoryShards =
this.allValues(Material.PRISMARINE_SHARD).filter { Shard.isShardItem(it) }.sumOf { it.amount }
return inventoryShards.toLong()
}
fun PlayerInventory.countDiamonds(): Long {
val inventoryDiamonds = this.allValues(Material.DIAMOND).sumOf { it.amount }
return inventoryDiamonds.toLong()
}
fun PlayerInventory.countDiamondBlocks(): Long {
val inventoryDiamondBlocks = this.allValues(Material.DIAMOND_BLOCK).sumOf { it.amount }
return inventoryDiamondBlocks.toLong()
}
}
@@ -1,47 +0,0 @@
package net.trueog.diamondbankog.util
import java.util.UUID
import kotlinx.coroutines.future.await
import net.luckperms.api.LuckPerms
internal object PlayerPrefix {
private val legacyToMmMap =
mapOf(
"0" to "<black>",
"1" to "<dark_blue>",
"2" to "<dark_green>",
"3" to "<dark_aqua>",
"4" to "<dark_red>",
"5" to "<dark_purple>",
"6" to "<gold>",
"7" to "<gray>",
"8" to "<dark_gray>",
"9" to "<blue>",
"a" to "<green>",
"b" to "<aqua>",
"c" to "<red>",
"d" to "<light_purple>",
"e" to "<yellow>",
"f" to "<white>",
"k" to "<obfuscated>",
"l" to "<bold>",
"m" to "<strikethrough>",
"n" to "<underlined>",
"o" to "<italic>",
"r" to "<reset>",
"*" to "<rainbow>",
)
val legacyRegex = Regex("[§&]([0-9a-fk-or*])", RegexOption.IGNORE_CASE)
fun legacyToMm(text: String): String {
return legacyRegex.replace(text) { legacyToMmMap[it.groupValues[1].lowercase()] ?: it.value }
}
suspend fun getPrefix(uuid: UUID, luckPerms: LuckPerms): String {
val user = luckPerms.userManager.getUser(uuid) ?: luckPerms.userManager.loadUser(uuid).await()
val prefixNode = user.nodes.singleOrNull { node -> node.key.startsWith("prefix.1.") }
if (prefixNode == null) return ""
return legacyToMm(prefixNode.key.split(".").last()) + " "
}
}
+9 -13
View File
@@ -1,17 +1,13 @@
# ---General---
# MiniMessage-formatted prefix (https://docs.advntr.dev/minimessage/format.html)
prefix: <gray>[<aqua>DiamondBank<white>-<red>OG<gray>]
prefix: <dark_gray>[<aqua>DiamondBank<white>-<dark_red>OG<dark_gray>]
# ---PostgreSQL---
postgresHost: localhost
postgresPort: 5432
postgresDatabase: diamond
postgresUser: ^{DIAMONDBANKOG_POSTGRES_USER}
postgresPassword: ^{DIAMONDBANKOG_POSTGRES_PASSWORD}
postgresMaxActiveConnections: 10
# ---Sentry (or compatible services like GlitchTip)---
sentryEnabled: false
sentryDsn: Sentry(-compatible) DSN URL
# ---Redis(-compatible) key value store---
redisHost: 127.0.0.1
redisPort: 6379
redisDatabase: 0
redisPassword: ^{KEYDB_PASSWORD}
# ---Postgres---
postgresUrl: jdbc:postgresql://localhost:5432/diamond
postgresUser: postgres
postgresPassword: postgresPassword
postgresTable: diamond
@@ -1,30 +0,0 @@
CREATE TABLE diamond(
uuid UUID PRIMARY KEY, bank_shards BIGINT DEFAULT 0, inventory_shards BIGINT DEFAULT 0, ender_chest_shards BIGINT DEFAULT 0,
total_shards BIGINT GENERATED ALWAYS AS ( COALESCE(bank_shards, 0) + COALESCE(inventory_shards, 0) + COALESCE(ender_chest_shards, 0) ) STORED
);
CREATE INDEX idx_total_shards ON diamond(total_shards DESC);
CREATE TABLE diamond_log(
id SERIAL PRIMARY KEY, player_uuid UUID NOT NULL, transferred_shards BIGINT NOT NULL, player_to_uuid UUID,
transaction_reason TEXT, notes TEXT, timestamp TIMESTAMPTZ DEFAULT NOW()
);
CREATE FUNCTION fifo_limit_trigger() RETURNS TRIGGER AS $$
DECLARE
max_rows CONSTANT BIGINT := 500;
BEGIN
DELETE FROM diamond_log
WHERE id <= (
SELECT id FROM diamond_log
ORDER BY id DESC
OFFSET max_rows LIMIT 1
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER fifo_limit_trigger
AFTER INSERT ON diamond_log
FOR EACH STATEMENT
EXECUTE FUNCTION fifo_limit_trigger();
+8 -29
View File
@@ -5,25 +5,25 @@ api-version: '${apiVersion}'
commands:
deposit:
description: Deposit diamonds into your bank
usage: /deposit <diamonds> or /deposit all
usage: /deposit <amount> or /deposit all
withdraw:
description: Withdraw diamonds from your bank
usage: /withdraw <diamonds> or /withdraw all
usage: /withdraw <amount> or /withdraw all
setbankbalance:
description: Set the bank balance of a player
usage: /setbankbalance <player name or player uuid> <shards>
usage: /setbankbalance <player name or player uuid> <amount>
setbankbal:
description: Set the bank balance of a player
usage: /setbankbal <player name or player uuid> <shards>
usage: /setbankbal <player name or player uuid> <amount>
pay:
description: Pay a player
usage: /pay <player name or player uuid> <diamonds>
usage: /pay <player name or player uuid> <amount>
balancetop:
description: Show the balancetop
usage: /balancetop <page number (optional)> or /balancetop <player name or player uuid>
usage: /balancetop <page number (optional)>
baltop:
description: Show the balancetop
usage: /baltop <page number (optional)> or /baltop <player name or player uuid>
usage: /baltop <page number (optional)>
balance:
description: Get your or someone else's balance
usage: /balance or /balance <player name or player uuid>
@@ -33,27 +33,6 @@ commands:
diamondbankreload:
description: Reload the config
usage: /diamondbankreload
diamondbankhelp:
description: DiamondBank help
usage: /diamondbankhelp
enableeconomy:
description: Enable the economy
usage: /enableeconomy
disableeconomy:
description: Disable the economy
usage: /disableeconomy
compress:
description: Compresses every Diamond-related item into the highest value form
usage: /compress
autocompress:
description: Toggles auto-compress, automatically compresses all Diamond currency items in your inventory into the highest value form to save space, mutually exclusive with auto-deposit
usage: /autocompress
autodeposit:
description: Toggles auto-deposit, automatically deposits picked up Diamond currency items, mutually exclusive with auto-compress
usage: /autodeposit
dbogtoggledebug:
description: Toggles debug mode
usage: /dbtoggledebug
# So we can overwrite Essentials' commands
softdepend: [Essentials-OG, Utilities-OG]
softdepend: [ Essentials ]
@@ -1,279 +0,0 @@
package net.trueog.diamondbankog
import io.mockk.*
import net.kyori.adventure.text.Component
import net.trueog.diamondbankog.Utils.allImpl
import org.bukkit.Bukkit
import org.bukkit.Material
import org.bukkit.NamespacedKey
import org.bukkit.Server
import org.bukkit.block.ShulkerBox
import org.bukkit.inventory.Inventory
import org.bukkit.inventory.InventoryHolder
import org.bukkit.inventory.ItemStack
import org.bukkit.inventory.meta.BlockStateMeta
import org.bukkit.inventory.meta.Damageable
import org.bukkit.inventory.meta.ItemMeta
import org.bukkit.persistence.PersistentDataType
object BukkitMock {
private fun first(item: ItemStack?, inventory: Array<ItemStack?>): Int {
if (item == null) return -1
return inventory.indexOfFirst { it != null && item.isSimilar(it) }
}
private fun firstPartial(item: ItemStack?, inventory: Array<ItemStack?>): Int {
if (item == null) return -1
return inventory.indexOfFirst { it != null && it.amount < 64 && it.isSimilar(item) }
}
private fun firstEmpty(inventory: Array<ItemStack?>): Int {
return inventory.indexOfFirst { it == null }
}
const val MAX_AMOUNT = 64
fun mockBukkit(): Server {
val server = mockk<Server>()
mockkStatic(Bukkit::class)
val serverField = Bukkit::class.java.getDeclaredField("server")
serverField.isAccessible = true
serverField.set(null, server)
every { Bukkit.getServer() } returns server
every { server.isPrimaryThread } returns true
every { Bukkit.isPrimaryThread() } returns true
every { Bukkit.getItemFactory() } answers { server.itemFactory }
every { Bukkit.createInventory(anyNullable<InventoryHolder>(), any<Int>()) } answers
{
server.createInventory(firstArg<InventoryHolder?>(), secondArg<Int>())
}
val sizeSlot = slot<Int>()
every { server.createInventory(any(), capture(sizeSlot)) } returns createInventory()
every { server.itemFactory.getItemMeta(any()) } answers
{
val material = firstArg<Material>()
val itemMeta =
if (material == Material.SHULKER_BOX) {
mockk<BlockStateMeta>()
} else {
mockk<Damageable>()
}
val persistentDataContainerKeys = mutableSetOf<NamespacedKey>()
var displayName: Component? = null
every { itemMeta.clone() } returns itemMeta
every { itemMeta.displayName(any()) } answers { displayName = firstArg<Component>() }
every { itemMeta.displayName() } answers { displayName }
every { itemMeta.addEnchant(any(), any(), any()) } returns true
every { itemMeta.addItemFlags(any()) } just Runs
every { itemMeta.persistentDataContainer.set(any(), PersistentDataType.STRING, any()) } answers
{
val key = firstArg<NamespacedKey>()
persistentDataContainerKeys.add(key)
}
every { itemMeta.persistentDataContainer.has(any()) } answers
{
val key = firstArg<NamespacedKey>()
persistentDataContainerKeys.contains(key)
}
every { itemMeta.persistentDataContainer.keys } answers { persistentDataContainerKeys }
every { itemMeta.persistentDataContainer.isEmpty } answers { persistentDataContainerKeys.isEmpty() }
if (itemMeta is BlockStateMeta) {
val blockState = mockk<ShulkerBox>()
var inventory = createInventory()
every { itemMeta.blockState } returns blockState
every { itemMeta.blockState = any() } answers { inventory = firstArg<ShulkerBox>().inventory }
every { blockState.inventory } returns inventory
}
if (itemMeta is Damageable) {
every { itemMeta.damage } returns 0
}
itemMeta
}
every { server.itemFactory.isApplicable(any(), Material.PRISMARINE_SHARD) } returns true
every { server.itemFactory.isApplicable(any(), Material.SHULKER_BOX) } returns true
every { server.itemFactory.asMetaFor(any(), any<Material>()) } answers
{
val itemMeta = firstArg<ItemMeta>()
val persistentDataContainerKeys = mutableSetOf<NamespacedKey>()
var displayName = itemMeta.displayName()
itemMeta.persistentDataContainer.keys.forEach { persistentDataContainerKeys.add(it) }
val material = secondArg<Material>()
val newItemMeta =
if (material == Material.SHULKER_BOX) {
mockk<BlockStateMeta>()
} else {
mockk<Damageable>()
}
every { newItemMeta.clone() } returns newItemMeta
every { newItemMeta.displayName() } answers { displayName }
every { newItemMeta.displayName(any()) } answers { displayName = firstArg<Component>() }
every { newItemMeta.persistentDataContainer.has(any()) } answers
{
val key = firstArg<NamespacedKey>()
persistentDataContainerKeys.contains(key)
}
every { newItemMeta.persistentDataContainer.isEmpty } answers { persistentDataContainerKeys.isEmpty() }
every { newItemMeta.persistentDataContainer.keys } answers { persistentDataContainerKeys }
if (newItemMeta is BlockStateMeta) {
val blockState = mockk<ShulkerBox>()
var inventory = ((itemMeta as BlockStateMeta).blockState as ShulkerBox).inventory
every { newItemMeta.blockState } returns blockState
every { newItemMeta.blockState = any() } answers { inventory = firstArg<ShulkerBox>().inventory }
every { blockState.inventory } returns inventory
}
if (newItemMeta is Damageable) {
every { newItemMeta.damage } returns 0
}
newItemMeta
}
every { server.itemFactory.equals(any(), any()) } answers
{
val meta1 = firstArg<ItemMeta?>()
val meta2 = secondArg<ItemMeta?>()
if (meta1 == meta2) return@answers true
if (meta1 == null) {
return@answers (meta2!!.displayName() == null &&
!meta2.hasEnchants() &&
meta2.persistentDataContainer.isEmpty)
}
if (meta2 == null) {
return@answers (meta1.displayName() == null &&
!meta1.hasEnchants() &&
meta1.persistentDataContainer.isEmpty)
}
meta1.displayName() == meta2.displayName() &&
meta1.persistentDataContainer.keys == meta2.persistentDataContainer.keys
}
every { @Suppress("DEPRECATION") server.itemFactory.updateMaterial(any(), Material.PRISMARINE_SHARD) } returns
Material.PRISMARINE_SHARD
every { @Suppress("DEPRECATION") server.itemFactory.updateMaterial(any(), Material.SHULKER_BOX) } returns
Material.SHULKER_BOX
return server
}
fun createInventory(): Inventory {
val inventory = mockk<Inventory>()
var contents = arrayOfNulls<ItemStack>(36)
every { inventory.contents = any<Array<ItemStack?>>() } answers { contents = firstArg<Array<ItemStack?>>() }
every { inventory.contents } answers { contents }
every { inventory.setItem(any<Int>(), anyNullable<ItemStack?>()) } answers
{
contents[firstArg()] = secondArg()
}
every { inventory.storageContents } answers { contents }
every { inventory.all(any<Material>()) } answers
{
val material = firstArg<Material>()
allImpl(contents, material)
}
every { inventory.addItem(any()) } answers { addToInventory(firstArg<Array<out ItemStack?>>(), contents) }
every { inventory.removeItem(any()) } answers
{
val items = firstArg<Array<out ItemStack?>>()
val leftover = HashMap<Int, ItemStack>()
items.forEachIndexed { index, item ->
requireNotNull(item) { "ItemStack cannot be null" }
if (item.type.isAir) return@forEachIndexed
var toDelete = item.amount
while (toDelete > 0) {
val first = first(item, contents)
if (first == -1) {
item.amount = toDelete
leftover[index] = item
toDelete = 0
} else {
val itemStack = contents[first]!!
val removed = minOf(itemStack.amount, toDelete)
itemStack.amount -= removed
toDelete -= removed
if (itemStack.amount == 0) {
contents[first] = null
} else {
contents[first] = itemStack
}
}
}
}
leftover
}
every { inventory.remove(anyNullable<Material>()) } answers
{
val material = firstArg<Material?>() ?: throw IllegalArgumentException("Material cannot be null")
contents.forEachIndexed { index, item ->
if (item?.type == material) {
contents[index] = null
}
}
}
every { inventory.getItem(any<Int>()) } answers { contents[firstArg()] }
return inventory
}
fun addToInventory(items: Array<out ItemStack?>, contents: Array<ItemStack?>): HashMap<Int, ItemStack> {
val leftover = HashMap<Int, ItemStack>()
items.forEachIndexed { index, item ->
requireNotNull(item) { "ItemStack cannot be null" }
if (item.type.isAir) return@forEachIndexed
while (item.amount > 0) {
val firstPartial = firstPartial(item, contents)
if (firstPartial != -1) {
val stack = contents[firstPartial]!!
val space = MAX_AMOUNT - stack.amount
val amount = minOf(item.amount, space)
stack.amount += amount
item.amount -= amount
continue
}
val empty = firstEmpty(contents)
if (empty == -1) {
leftover[index] = item.clone()
break
}
val amount = minOf(item.amount, MAX_AMOUNT)
contents[empty] = item.clone().apply { this.amount = amount }
item.amount -= amount
}
}
return leftover
}
}
@@ -1,91 +0,0 @@
package net.trueog.diamondbankog
import java.util.*
import net.trueog.diamondbankog.balance.Cache
import net.trueog.diamondbankog.balance.shard.ShardType
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
class CacheTest {
@ParameterizedTest(name = "{0}")
@DisplayName("Cache Set")
@CsvSource("Bank Shards, BANK", "Inventory Shards, INVENTORY", "Ender Chest Shards, ENDER_CHEST")
fun cacheSet(@Suppress("UNUSED_PARAMETER") name: String, enumName: String) {
val shardType = ShardType.valueOf(enumName)
val cache = Cache()
val uuid = UUID.randomUUID()
val result = cache.setBalance(uuid, 3, shardType)
assertAll(
{ assertFalse(result.isFailure, "setBalance result should not be a failure") },
{ assertEquals(3, cache.getBalance(uuid, shardType).getOrNull()) },
)
}
@Test
@DisplayName("Cache Set TOTAL should fail")
fun cacheSetTotalFail() {
val cache = Cache()
val uuid = UUID.randomUUID()
val result = cache.setBalance(uuid, 3, ShardType.TOTAL)
assertTrue(result.isFailure, "setBalance result should be a failure with ShardType.TOTAL")
}
@ParameterizedTest(name = "{0}")
@DisplayName("Cache Get (after set)")
@CsvSource("Bank Shards, BANK", "Inventory Shards, INVENTORY", "Ender Chest Shards, ENDER_CHEST")
fun cacheGetAfterSet(@Suppress("UNUSED_PARAMETER") name: String, enumName: String) {
val shardType = ShardType.valueOf(enumName)
val cache = Cache()
val uuid = UUID.randomUUID()
cache.setBalance(uuid, 3, if (shardType != ShardType.TOTAL) shardType else ShardType.BANK)
val result = cache.getBalance(uuid, shardType)
assertEquals(3, result.getOrNull())
}
@ParameterizedTest(name = "{0}")
@DisplayName("Cache Get")
@CsvSource("Bank Shards, BANK", "Inventory Shards, INVENTORY", "Ender Chest Shards, ENDER_CHEST")
fun cacheGet(@Suppress("UNUSED_PARAMETER") name: String, enumName: String) {
val shardType = ShardType.valueOf(enumName)
val cache = Cache()
val uuid = UUID.randomUUID()
val result = cache.getBalance(uuid, shardType)
assertEquals(-1, result.getOrNull())
}
@Test
@DisplayName("Cache Get TOTAL should fail")
fun cacheGetTotalFail() {
val cache = Cache()
val uuid = UUID.randomUUID()
val result = cache.getBalance(uuid, ShardType.TOTAL)
assertTrue(result.isFailure)
}
@ParameterizedTest(name = "{0}")
@DisplayName("Cache Remove")
@CsvSource("Bank Shards, BANK", "Inventory Shards, INVENTORY", "Ender Chest Shards, ENDER_CHEST")
fun cacheRemove(@Suppress("UNUSED_PARAMETER") name: String, enumName: String) {
val shardType = ShardType.valueOf(enumName)
val cache = Cache()
val uuid = UUID.randomUUID()
cache.setBalance(uuid, 3, shardType)
val result = cache.removeBalance(uuid, shardType)
assertAll(
{ assertFalse(result.isFailure, "removeBalance result should not be a failure") },
{ assertEquals(-1, cache.getBalance(uuid, shardType).getOrNull()) },
)
}
@Test
@DisplayName("Cache Remove TOTAL should fail")
fun cacheRemoveTotalFail() {
val cache = Cache()
val uuid = UUID.randomUUID()
val result = cache.removeBalance(uuid, ShardType.TOTAL)
assertTrue(result.isFailure, "removeBalance result should be a failure with ShardType.TOTAL")
}
}
@@ -1,321 +0,0 @@
package net.trueog.diamondbankog
import io.mockk.*
import io.mockk.impl.annotations.MockK
import io.mockk.impl.annotations.SpyK
import kotlinx.coroutines.test.runTest
import net.trueog.diamondbankog.Constants.otherPlayerUuid
import net.trueog.diamondbankog.Constants.playerUuid
import net.trueog.diamondbankog.DiamondBankOG.Companion.economyDisabled
import net.trueog.diamondbankog.balance.Cache
import net.trueog.diamondbankog.balance.CachingBalanceManager
import net.trueog.diamondbankog.balance.shard.PlayerShards
import net.trueog.diamondbankog.balance.shard.ShardType
import net.trueog.diamondbankog.persistence.PostgreSQL
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
class CachingBalanceManagerTest {
@MockK private lateinit var postgreSQL: PostgreSQL
@SpyK private var cache = Cache()
private lateinit var manager: CachingBalanceManager
@BeforeEach
fun setup() {
MockKAnnotations.init(this)
// Constructor is private, only reachable through the factory (which opens a real DB
// connection). Reflection bypasses that so we can inject a mocked PostgreSQL instead.
val constructor = CachingBalanceManager::class.java.getDeclaredConstructor()
constructor.isAccessible = true
manager = constructor.newInstance()
val postgreSQL = manager::class.java.getDeclaredField("postgreSQL")
postgreSQL.isAccessible = true
postgreSQL.set(manager, this.postgreSQL)
val cache = manager::class.java.getDeclaredField("cache")
cache.isAccessible = true
cache.set(manager, this.cache)
}
@ParameterizedTest(name = "{0}")
@DisplayName("Set Player Shards")
@CsvSource("Bank Shards, BANK", "Inventory Shards, INVENTORY", "Ender Chest Shards, ENDER_CHEST")
fun setPlayerShards(@Suppress("UNUSED_PARAMETER") name: String, enumName: String) = runTest {
val shardType = ShardType.valueOf(enumName)
coEvery { postgreSQL.setPlayerShards(playerUuid, 10, shardType) } returns Result.success(Unit)
val result = manager.setPlayerShards(playerUuid, 10, shardType)
assertAll(
{ assertFalse(result.isFailure, "setPlayerShards result should not be a failure") },
{ assertEquals(10, cache.getBalance(playerUuid, shardType).getOrNull()) },
{ coVerify { postgreSQL.setPlayerShards(playerUuid, 10, shardType) } },
)
}
@Test
@DisplayName("Set Player Shards TOTAL should fail")
fun setPlayerShardsTotalFail() = runTest {
val result = manager.setPlayerShards(playerUuid, 10, ShardType.TOTAL)
assertTrue(result.isFailure, "setPlayerShards result should be a failure with ShardType.TOTAL")
}
@Test
@DisplayName("Set Player Shards fails when economy disabled")
fun setPlayerShardsEconomyDisabled() = runTest {
economyDisabled = true
try {
val result = manager.setPlayerShards(playerUuid, 10, ShardType.BANK)
assertTrue(result.isFailure, "setPlayerShards result should be a failure when economy disabled")
} finally {
economyDisabled = false
}
}
@Test
@DisplayName("Set Player Shards DB failure propagates, cache untouched")
fun setPlayerShardsDbFail() = runTest {
coEvery { postgreSQL.setPlayerShards(playerUuid, 10, ShardType.BANK) } returns
Result.failure(RuntimeException("db error"))
val result = manager.setPlayerShards(playerUuid, 10, ShardType.BANK)
assertAll(
{ assertTrue(result.isFailure, "setPlayerShards result should be a failure") },
{ assertEquals(-1, cache.getBalance(playerUuid, ShardType.BANK).getOrNull()) },
)
}
@ParameterizedTest(name = "{0}")
@DisplayName("Add To Bank Shards")
@CsvSource("No existing balance, 5, 0", "Existing balance, 10, 5")
fun addToBankShards(@Suppress("UNUSED_PARAMETER") name: String, toRemove: Long, existingBalance: Long) = runTest {
coEvery { postgreSQL.getShardTypeShards(playerUuid, ShardType.BANK) } returns Result.success(existingBalance)
coEvery { postgreSQL.addToPlayerShards(playerUuid, toRemove, ShardType.BANK) } returns
Result.success(toRemove - existingBalance)
val result = manager.addToBankShards(playerUuid, toRemove)
assertAll(
{ assertFalse(result.isFailure, "addToBankShards result should not be a failure") },
{ assertEquals(toRemove - existingBalance, cache.getBalance(playerUuid, ShardType.BANK).getOrNull()) },
{ coVerify { postgreSQL.addToPlayerShards(playerUuid, toRemove, ShardType.BANK) } },
)
}
@Test
@DisplayName("Subtract From Bank Shards")
fun subtractFromBankShards() = runTest {
coEvery { postgreSQL.getShardTypeShards(playerUuid, ShardType.BANK) } returns Result.success(10)
coEvery { postgreSQL.addToPlayerShards(playerUuid, -4, ShardType.BANK) } returns Result.success(6)
val result = manager.subtractFromBankShards(playerUuid, 4)
assertAll(
{ assertFalse(result.isFailure, "subtractFromBankShards result should not be a failure") },
{ assertEquals(6, cache.getBalance(playerUuid, ShardType.BANK).getOrNull()) },
{ coVerify { postgreSQL.addToPlayerShards(playerUuid, -4, ShardType.BANK) } },
)
}
@Test
@DisplayName("Subtract From Bank Shards (insufficient balance)")
fun subtractFromBankShardsInsufficientBalance() = runTest {
cache.setBalance(playerUuid, 2, ShardType.BANK)
coEvery { postgreSQL.getShardTypeShards(playerUuid, ShardType.BANK) } returns Result.success(2)
coEvery { postgreSQL.addToPlayerShards(playerUuid, -4, ShardType.BANK) } returns Result.failure(Exception())
val result = manager.subtractFromBankShards(playerUuid, 4)
assertAll(
{
assertTrue(
result.exceptionOrNull() is DiamondBankException.InsufficientBalanceException,
"subtractFromBankShards result should be an InsufficientBalanceException",
)
},
{ assertEquals(2, cache.getBalance(playerUuid, ShardType.BANK).getOrNull()) },
{ coVerify(exactly = 0) { postgreSQL.addToPlayerShards(playerUuid, -4, ShardType.BANK) } },
)
}
@Test
@DisplayName("Add To Bank Shards DB failure propagates")
fun addToBankShardsDbFail() = runTest {
coEvery { postgreSQL.getShardTypeShards(playerUuid, ShardType.BANK) } returns Result.success(5)
coEvery { postgreSQL.addToPlayerShards(playerUuid, 5, ShardType.BANK) } returns
Result.failure(RuntimeException("db error"))
val result = manager.addToBankShards(playerUuid, 5)
assertTrue(result.isFailure, "addToBankShards result should be a failure")
}
@ParameterizedTest(name = "{0}")
@DisplayName("Transfer Bank Shards")
@CsvSource("No existing balance, 5, 0", "Existing balance, 5, 5")
fun transferBankShards(
@Suppress("UNUSED_PARAMETER") name: String,
toTransfer: Long,
receiverExistingBalance: Long,
) = runTest {
val senderExistingBalance = 10L
val newSenderBalance = senderExistingBalance - toTransfer
val newReceiverBalance = receiverExistingBalance + toTransfer
coEvery { postgreSQL.getShardTypeShards(playerUuid, ShardType.BANK) } returns
Result.success(senderExistingBalance)
coEvery { postgreSQL.getShardTypeShards(otherPlayerUuid, ShardType.BANK) } returns
Result.success(receiverExistingBalance)
coEvery { postgreSQL.transferBankShards(playerUuid, otherPlayerUuid, toTransfer, toTransfer) } returns
Result.success(mapOf(playerUuid to newSenderBalance, otherPlayerUuid to newReceiverBalance))
val result = manager.transferBankShards(playerUuid, otherPlayerUuid, toTransfer, toTransfer)
assertAll(
{ assertFalse(result.isFailure, "transferBankShards result should not be a failure") },
{ assertEquals(newSenderBalance, cache.getBalance(playerUuid, ShardType.BANK).getOrNull()) },
{ assertEquals(newReceiverBalance, cache.getBalance(otherPlayerUuid, ShardType.BANK).getOrNull()) },
{ coVerify { postgreSQL.transferBankShards(playerUuid, otherPlayerUuid, toTransfer, toTransfer) } },
)
}
@Test
@DisplayName("Transfer Bank Shards (insufficient balance)")
fun transferBankShardsInsufficientBalance() = runTest {
cache.setBalance(playerUuid, 2, ShardType.BANK)
cache.setBalance(otherPlayerUuid, 0, ShardType.BANK)
coEvery { postgreSQL.getShardTypeShards(playerUuid, ShardType.BANK) } returns Result.success(2)
coEvery { postgreSQL.transferBankShards(playerUuid, otherPlayerUuid, 4, 4) } returns Result.failure(Exception())
val result = manager.transferBankShards(playerUuid, otherPlayerUuid, 4, 4)
assertAll(
{
assertTrue(
result.exceptionOrNull() is DiamondBankException.InsufficientBalanceException,
"transferBankShards result should be an InsufficientBalanceException",
)
},
{ assertEquals(2, cache.getBalance(playerUuid, ShardType.BANK).getOrNull()) },
{ assertEquals(0, cache.getBalance(otherPlayerUuid, ShardType.BANK).getOrNull()) },
{ coVerify(exactly = 0) { postgreSQL.transferBankShards(playerUuid, otherPlayerUuid, 4, 4) } },
)
}
@Test
@DisplayName("Transfer Bank Shards fails for negative shardsToSubtractFromSender")
fun transferBankShardsNegativeSubtract() = runTest {
assertThrows<IllegalArgumentException> { manager.transferBankShards(playerUuid, otherPlayerUuid, -1, 5) }
}
@Test
@DisplayName("Transfer Bank Shards fails for negative shardsToAddToReceiver")
fun transferBankShardsNegativeAdd() = runTest {
assertThrows<IllegalArgumentException> { manager.transferBankShards(playerUuid, otherPlayerUuid, 5, -1) }
}
@Test
@DisplayName("Transfer Bank Shards fails for equal sender and receiver")
fun transferBankShardsEqualSenderReceiver() = runTest {
assertThrows<IllegalArgumentException> { manager.transferBankShards(playerUuid, playerUuid, 5, 5) }
}
@ParameterizedTest(name = "{0}")
@DisplayName("Get Shard Type Shards (cache miss then hit)")
@CsvSource("Bank Shards, BANK", "Inventory Shards, INVENTORY", "Ender Chest Shards, ENDER_CHEST")
fun getShardTypeShardsCacheMissThenHit(@Suppress("UNUSED_PARAMETER") name: String, enumName: String) = runTest {
val shardType = ShardType.valueOf(enumName)
coEvery { postgreSQL.getShardTypeShards(playerUuid, shardType) } returns Result.success(7)
val first = manager.getShardTypeShards(playerUuid, shardType)
val second = manager.getShardTypeShards(playerUuid, shardType)
assertAll(
{ assertEquals(7, first.getOrNull()) },
{ assertEquals(7, second.getOrNull()) },
{ coVerify(exactly = 1) { postgreSQL.getShardTypeShards(playerUuid, shardType) } },
)
}
@Test
@DisplayName("Get Total Shards (cache miss then hit)")
fun getTotalShardsCacheMissThenHit() = runTest {
coEvery { postgreSQL.getAllShards(playerUuid) } returns Result.success(PlayerShards(5, 6, 4))
val first = manager.getTotalShards(playerUuid)
val second = manager.getTotalShards(playerUuid)
assertAll(
{ assertEquals(15, first.getOrNull()) },
{ assertEquals(15, second.getOrNull()) },
{ coVerify(exactly = 1) { postgreSQL.getAllShards(playerUuid) } },
)
}
@Test
@DisplayName("Get All Shards (cache miss then hit)")
fun getAllShardsCacheMissThenHit() = runTest {
coEvery { postgreSQL.getAllShards(playerUuid) } returns Result.success(PlayerShards(1, 2, 3))
val first = manager.getAllShards(playerUuid)
val second = manager.getAllShards(playerUuid)
assertAll(
{ assertEquals(PlayerShards(1, 2, 3), first.getOrNull()) },
{ assertEquals(PlayerShards(1, 2, 3), second.getOrNull()) },
{ coVerify(exactly = 1) { postgreSQL.getAllShards(playerUuid) } },
)
}
@Test
@DisplayName("Get All Shards partial cache miss still hits DB")
fun getAllShardsPartialCacheMiss() = runTest {
cache.setBalance(playerUuid, 1, ShardType.BANK)
coEvery { postgreSQL.getAllShards(playerUuid) } returns Result.success(PlayerShards(1, 2, 3))
val result = manager.getAllShards(playerUuid)
assertAll(
{ assertEquals(PlayerShards(1, 2, 3), result.getOrNull()) },
{ coVerify(exactly = 1) { postgreSQL.getAllShards(playerUuid) } },
)
}
@Test
@DisplayName("Cache For Player")
fun cacheForPlayer() = runTest {
coEvery { postgreSQL.getAllShards(playerUuid) } returns Result.success(PlayerShards(1, 2, 3))
val result = manager.cacheForPlayer(playerUuid)
assertAll(
{ assertFalse(result.isFailure, "cacheForPlayer result should not be a failure") },
{ assertEquals(1, cache.getBalance(playerUuid, ShardType.BANK).getOrNull()) },
{ assertEquals(2, cache.getBalance(playerUuid, ShardType.INVENTORY).getOrNull()) },
{ assertEquals(3, cache.getBalance(playerUuid, ShardType.ENDER_CHEST).getOrNull()) },
)
}
@Test
@DisplayName("Remove Cache For Player")
suspend fun removeCacheForPlayer() {
cache.setBalance(playerUuid, 1, ShardType.BANK)
cache.setBalance(playerUuid, 2, ShardType.INVENTORY)
cache.setBalance(playerUuid, 3, ShardType.ENDER_CHEST)
manager.removeCacheForPlayer(playerUuid)
assertAll(
{ assertEquals(-1, cache.getBalance(playerUuid, ShardType.BANK).getOrNull()) },
{ assertEquals(-1, cache.getBalance(playerUuid, ShardType.INVENTORY).getOrNull()) },
{ assertEquals(-1, cache.getBalance(playerUuid, ShardType.ENDER_CHEST).getOrNull()) },
)
}
}
@@ -1,8 +0,0 @@
package net.trueog.diamondbankog
import java.util.*
object Constants {
val playerUuid: UUID = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
val otherPlayerUuid: UUID = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
}
@@ -1,40 +0,0 @@
package net.trueog.diamondbankog
import io.mockk.every
import io.mockk.mockk
import java.util.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import org.bukkit.Material
import org.bukkit.entity.Player
import org.bukkit.inventory.ItemStack
import org.bukkit.inventory.PlayerInventory
object Utils {
suspend fun waitForCoroutines(scope: CoroutineScope) {
scope.coroutineContext[Job]?.children?.forEach { it.join() }
}
fun allImpl(contents: Array<ItemStack?>, material: Material): HashMap<Int, out ItemStack> =
HashMap(
contents.mapIndexedNotNull { index, item -> if (item?.type == material) index to item else null }.toMap()
)
fun mockPlayerInventory(player: Player, uuid: UUID, contents: Array<ItemStack?>): PlayerInventory {
val playerInventory = mockk<PlayerInventory>()
var contents = contents.copyOf(41)
every { playerInventory.contents } answers { contents }
every { playerInventory.contents = any() } answers { contents = firstArg() }
every { playerInventory.armorContents } answers { contents.dropLast(1).takeLast(4).toTypedArray() }
every { playerInventory.itemInOffHand } answers { contents[40] ?: ItemStack(Material.AIR) }
every { playerInventory.all(any<Material>()) } answers
{
val material = firstArg<Material>()
allImpl(contents.take(36).toTypedArray(), material)
}
every { playerInventory.heldItemSlot } returns 0
every { playerInventory.holder!!.uniqueId } returns uuid
every { player.inventory } returns playerInventory
return playerInventory
}
}
@@ -1,98 +0,0 @@
package net.trueog.diamondbankog.commands
import io.mockk.MockKAnnotations
import io.mockk.Runs
import io.mockk.confirmVerified
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.just
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.test.runTest
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.minimessage.MiniMessage
import net.trueog.diamondbankog.BukkitMock
import net.trueog.diamondbankog.Constants.playerUuid
import net.trueog.diamondbankog.Utils.waitForCoroutines
import net.trueog.diamondbankog.balance.BalanceManager
import net.trueog.diamondbankog.balance.command.Balance
import net.trueog.diamondbankog.config.Config
import org.bukkit.Bukkit
import org.bukkit.Server
import org.bukkit.command.Command
import org.bukkit.entity.Player
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertAll
class BalanceTest {
@MockK private lateinit var config: Config
@MockK private lateinit var balanceManager: BalanceManager
@MockK private lateinit var mm: MiniMessage
@MockK private lateinit var player: Player
@MockK private lateinit var command: Command
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
@BeforeEach
fun setup() {
MockKAnnotations.init(this)
every { config.prefix } returns "DiamondBank-OG"
val inputSlot = slot<String>()
every { mm.deserialize(capture(inputSlot)) } answers { Component.text(inputSlot.captured) }
every { player.uniqueId } returns playerUuid
every { player.world.name } returns "world"
every { player.hasPermission("diamondbank-og.balance") } returns true
every { Bukkit.getPlayer("player") } returns player
every { server.getPlayer("player") } returns player
every { Bukkit.getPlayer(playerUuid) } returns player
every { server.getPlayer(playerUuid) } returns player
every { player.sendMessage(any<Component>()) } just Runs
}
companion object {
lateinit var server: Server
@JvmStatic
@BeforeAll
fun setupBukkit() {
server = BukkitMock.mockBukkit()
}
}
@Test
@DisplayName("Balance with no permission should fail")
fun balanceNoPermission() = runTest {
every { player.hasPermission("diamondbank-og.balance") } returns false
val balance = Balance(config, balanceManager, mm, scope)
balance.onCommand(player, command, "balance", arrayOf())
waitForCoroutines(scope)
assertAll(
{ verify { player.hasPermission("diamondbank-og.balance") } },
{
verify {
player.sendMessage(
Component.text("DiamondBank-OG<reset>: <red>You do not have permission to use this command.")
)
}
},
{ confirmVerified(player) },
)
}
}
@@ -1,98 +0,0 @@
package net.trueog.diamondbankog.commands
import io.mockk.MockKAnnotations
import io.mockk.Runs
import io.mockk.confirmVerified
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.just
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.test.runTest
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.minimessage.MiniMessage
import net.trueog.diamondbankog.BukkitMock
import net.trueog.diamondbankog.Constants.playerUuid
import net.trueog.diamondbankog.Utils.waitForCoroutines
import net.trueog.diamondbankog.balance.BalanceManager
import net.trueog.diamondbankog.balance.command.Balancetop
import net.trueog.diamondbankog.config.Config
import org.bukkit.Bukkit
import org.bukkit.Server
import org.bukkit.command.Command
import org.bukkit.entity.Player
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertAll
class BalancetopTest {
@MockK private lateinit var config: Config
@MockK private lateinit var balanceManager: BalanceManager
@MockK private lateinit var mm: MiniMessage
@MockK private lateinit var player: Player
@MockK private lateinit var command: Command
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
@BeforeEach
fun setup() {
MockKAnnotations.init(this)
every { config.prefix } returns "DiamondBank-OG"
val inputSlot = slot<String>()
every { mm.deserialize(capture(inputSlot)) } answers { Component.text(inputSlot.captured) }
every { player.uniqueId } returns playerUuid
every { player.world.name } returns "world"
every { player.hasPermission("diamondbank-og.balance") } returns true
every { Bukkit.getPlayer("player") } returns player
every { server.getPlayer("player") } returns player
every { Bukkit.getPlayer(playerUuid) } returns player
every { server.getPlayer(playerUuid) } returns player
every { player.sendMessage(any<Component>()) } just Runs
}
companion object {
lateinit var server: Server
@JvmStatic
@BeforeAll
fun setupBukkit() {
server = BukkitMock.mockBukkit()
}
}
@Test
@DisplayName("Balancetop with no permission should fail")
fun balancetopNoPermission() = runTest {
every { player.hasPermission("diamondbank-og.balancetop") } returns false
val balancetop = Balancetop(config, balanceManager, mm, scope)
balancetop.onCommand(player, command, "balancetop", arrayOf())
waitForCoroutines(scope)
assertAll(
{ verify { player.hasPermission("diamondbank-og.balancetop") } },
{
verify {
player.sendMessage(
Component.text("DiamondBank-OG<reset>: <red>You do not have permission to use this command.")
)
}
},
{ confirmVerified(player) },
)
}
}
@@ -1,410 +0,0 @@
package net.trueog.diamondbankog.commands
import io.mockk.MockKAnnotations
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.impl.annotations.SpyK
import io.mockk.just
import io.mockk.slot
import io.mockk.verify
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.test.runTest
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.minimessage.MiniMessage
import net.trueog.diamondbankog.BukkitMock
import net.trueog.diamondbankog.Constants.playerUuid
import net.trueog.diamondbankog.Utils.mockPlayerInventory
import net.trueog.diamondbankog.Utils.waitForCoroutines
import net.trueog.diamondbankog.balance.BalanceManager
import net.trueog.diamondbankog.balance.shard.Shard
import net.trueog.diamondbankog.balance.shard.ShardType
import net.trueog.diamondbankog.config.Config
import net.trueog.diamondbankog.transaction.InventoryLockExtensions.isInventoryLocked
import net.trueog.diamondbankog.transaction.TransactionLock
import net.trueog.diamondbankog.transaction.command.Compress
import net.trueog.diamondbankog.util.InventoryExtensions.countDiamondBlocks
import net.trueog.diamondbankog.util.InventoryExtensions.countDiamonds
import net.trueog.diamondbankog.util.InventoryExtensions.countShards
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countDiamondBlocks
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countDiamonds
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countShards
import org.bukkit.Bukkit
import org.bukkit.Material
import org.bukkit.Server
import org.bukkit.block.ShulkerBox
import org.bukkit.command.Command
import org.bukkit.entity.Player
import org.bukkit.inventory.ItemStack
import org.bukkit.inventory.meta.BlockStateMeta
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertAll
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
class CompressTest {
@MockK private lateinit var config: Config
@MockK private lateinit var balanceManager: BalanceManager
@MockK private lateinit var mm: MiniMessage
@MockK private lateinit var player: Player
@MockK private lateinit var command: Command
@SpyK private var transactionLock = TransactionLock()
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
@BeforeEach
fun setup() {
MockKAnnotations.init(this)
every { config.prefix } returns "DiamondBank-OG"
val inputSlot = slot<String>()
every { mm.deserialize(capture(inputSlot)) } answers { Component.text(inputSlot.captured) }
every { player.uniqueId } returns playerUuid
every { player.world.name } returns "world"
every { player.hasPermission("diamondbank-og.compress") } returns true
every { Bukkit.getPlayer("player") } returns player
every { server.getPlayer("player") } returns player
every { Bukkit.getPlayer(playerUuid) } returns player
every { server.getPlayer(playerUuid) } returns player
every { player.sendMessage(any<Component>()) } just Runs
coEvery { balanceManager.setPlayerShards(any(), any(), ShardType.INVENTORY) } returns Result.success(Unit)
}
companion object {
lateinit var server: Server
@JvmStatic
@BeforeAll
fun setupBukkit() {
server = BukkitMock.mockBukkit()
}
@JvmStatic
fun compressData() =
listOf(
Arguments.of("Nothing", arrayOf<ItemStack>(), "<#FFA500>Nothing found to compress.", 0, 0, 0),
Arguments.of(
"9 shards to 1 diamond",
arrayOf(Shard.createItemStack(9)),
"Compression Summary:\n<red>-9 Diamond Shards\n<green>+1 Diamond",
0,
1,
0,
),
Arguments.of(
"9 diamonds to 1 diamond block",
arrayOf(ItemStack(Material.DIAMOND, 9)),
"Compression Summary:\n<red>-9 Diamonds\n<green>+1 Diamond Block",
0,
0,
1,
),
Arguments.of(
"9 diamonds and 9 shards to 1 diamond block and 1 diamond",
arrayOf(Shard.createItemStack(9), ItemStack(Material.DIAMOND, 9)),
"Compression Summary:\n<red>-9 Diamond Shards\n<red>-8 Diamonds\n<green>+1 Diamond Block",
0,
1,
1,
),
)
@JvmStatic
fun overflowCompressData() =
listOf(
Arguments.of(
"10 shards",
Array(36) { ItemStack(Material.DIRT, 1) } +
Array(4) { ItemStack(Material.AIR, 1) } +
arrayOf(Shard.createItemStack(10)),
"You do not have enough space in your inventory to compress all the Diamond currency items (<green>+1 <aqua>Diamonds<red>).",
10,
0,
0,
),
Arguments.of(
"10 diamonds",
Array(36) { ItemStack(Material.DIRT, 1) } +
Array(4) { ItemStack(Material.AIR, 1) } +
arrayOf(ItemStack(Material.DIAMOND, 10)),
"You do not have enough space in your inventory to compress all the Diamond currency items (<green>+1 <aqua>Diamond Blocks<red>).",
0,
10,
0,
),
)
}
@ParameterizedTest(name = "{0}")
@DisplayName("Successful Compress")
@MethodSource("compressData")
fun successfulCompress(
@Suppress("UNUSED_PARAMETER") name: String,
inventoryContents: Array<ItemStack?>,
compressionSummary: String,
invShardCount: Long,
invDiamondCount: Long,
invDiamondBlockCount: Long,
) = runTest {
val inventory = mockPlayerInventory(player, playerUuid, inventoryContents)
val compress = Compress(config, balanceManager, mm, scope, transactionLock)
compress.onCommand(player, command, "compress", arrayOf())
waitForCoroutines(scope)
assertAll(
{ verify { player.sendMessage(Component.text("DiamondBank-OG<reset>: $compressionSummary")) } },
{ assertEquals(invShardCount, inventory.countShards(), "Shard count") },
{ assertEquals(invDiamondCount, inventory.countDiamonds(), "Diamond count") },
{ assertEquals(invDiamondBlockCount, inventory.countDiamondBlocks(), "Diamond block count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@ParameterizedTest(name = "{0}")
@DisplayName("Shulker box Successful Compress")
@MethodSource("compressData")
fun successfulShulkerBoxCompress(
@Suppress("UNUSED_PARAMETER") name: String,
inventoryContents: Array<ItemStack>,
compressionSummary: String,
invShardCount: Long,
invDiamondCount: Long,
invDiamondBlockCount: Long,
) = runTest {
val shulkerBox = ItemStack(Material.SHULKER_BOX)
val blockStateMeta = shulkerBox.itemMeta as BlockStateMeta
val shulkerBoxState = blockStateMeta.blockState as ShulkerBox
inventoryContents.forEach { shulkerBoxState.inventory.addItem(it) }
blockStateMeta.blockState = shulkerBoxState
shulkerBox.itemMeta = blockStateMeta
mockPlayerInventory(player, playerUuid, arrayOf(shulkerBox))
val compress = Compress(config, balanceManager, mm, scope, transactionLock)
compress.onCommand(player, command, "compress", arrayOf("yes"))
waitForCoroutines(scope)
assertAll(
{ verify { player.sendMessage(Component.text("DiamondBank-OG<reset>: $compressionSummary")) } },
{ assertEquals(invShardCount, shulkerBoxState.inventory.countShards(), "Shard count") },
{ assertEquals(invDiamondCount, shulkerBoxState.inventory.countDiamonds(), "Diamond count") },
{
assertEquals(
invDiamondBlockCount,
shulkerBoxState.inventory.countDiamondBlocks(),
"Diamond block count",
)
},
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@ParameterizedTest(name = "{0}")
@DisplayName("Overflow Compress")
@MethodSource("overflowCompressData")
fun overflowCompress(
@Suppress("UNUSED_PARAMETER") name: String,
inventoryContents: Array<ItemStack?>,
error: String,
invShardCount: Long,
invDiamondCount: Long,
invDiamondBlockCount: Long,
) = runTest {
val inventory = mockPlayerInventory(player, playerUuid, inventoryContents)
val compress = Compress(config, balanceManager, mm, scope, transactionLock)
compress.onCommand(player, command, "compress", arrayOf())
waitForCoroutines(scope)
assertAll(
{ verify { player.sendMessage(Component.text("DiamondBank-OG<reset>: <red>$error")) } },
{ assertEquals(invShardCount, inventory.countShards(), "Shard count") },
{ assertEquals(invDiamondCount, inventory.countDiamonds(), "Diamond count") },
{ assertEquals(invDiamondBlockCount, inventory.countDiamondBlocks(), "Diamond block count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Compress while in minigame should fail")
fun compressWhileInMinigame() = runTest {
every { player.world.name } returns "minigame_world"
val inventory = mockPlayerInventory(player, playerUuid, arrayOf(ItemStack(Material.DIAMOND, 9)))
val compress = Compress(config, balanceManager, mm, scope, transactionLock)
compress.onCommand(player, command, "compress", arrayOf("all"))
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text("DiamondBank-OG<reset>: <red>You cannot use /compress when in a minigame.")
)
}
},
{ assertEquals(9, inventory.countDiamonds(), "Diamond count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Compress while locked should fail")
fun compressWhileLocked() = runTest {
val inventory = mockPlayerInventory(player, playerUuid, arrayOf(ItemStack(Material.DIAMOND, 9)))
coEvery { transactionLock.tryWithLockSuspend<Unit>(any(), any()) } returns TransactionLock.LockResult.Failed
val compress = Compress(config, balanceManager, mm, scope, transactionLock)
compress.onCommand(player, command, "compress", arrayOf("all"))
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text("DiamondBank-OG<reset>: <red>You are currently blocked from using /compress.")
)
}
},
{ assertEquals(9, inventory.countDiamonds(), "Diamond count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Compress too many arguments should fail")
fun compressTooManyArgs() = runTest {
val inventory = mockPlayerInventory(player, playerUuid, arrayOf(ItemStack(Material.DIAMOND, 9)))
val compress = Compress(config, balanceManager, mm, scope, transactionLock)
compress.onCommand(player, command, "compress", arrayOf("1", "2"))
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text(
"DiamondBank-OG<reset>: <red>Do not provide more arguments than \"yes\" if you want to compress the items in the shulker box you're holding."
)
)
}
},
{ assertEquals(9, inventory.countDiamonds(), "Diamond count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Compress with invalid argument should fail")
fun compressInvalidArg() = runTest {
val inventory = mockPlayerInventory(player, playerUuid, arrayOf(ItemStack(Material.DIAMOND, 9)))
val compress = Compress(config, balanceManager, mm, scope, transactionLock)
compress.onCommand(player, command, "compress", arrayOf("invalid"))
waitForCoroutines(scope)
assertAll(
{ verify { player.sendMessage(Component.text("DiamondBank-OG<reset>: <red>Invalid argument.")) } },
{ assertEquals(9, inventory.countDiamonds(), "Diamond count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Compress with no permission should fail")
fun compressNoPermission() = runTest {
every { player.hasPermission("diamondbank-og.compress") } returns false
val inventory = mockPlayerInventory(player, playerUuid, arrayOf(ItemStack(Material.DIAMOND, 9)))
val compress = Compress(config, balanceManager, mm, scope, transactionLock)
compress.onCommand(player, command, "compress", arrayOf("1"))
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text("DiamondBank-OG<reset>: <red>You do not have permission to use this command.")
)
}
},
{ assertEquals(9, inventory.countDiamonds(), "Diamond count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Compress with yes while not holding a shulker box should fail")
fun compressYesNotHoldingShulkerBox() = runTest {
val inventory = mockPlayerInventory(player, playerUuid, arrayOf(ItemStack(Material.DIAMOND, 9)))
val compress = Compress(config, balanceManager, mm, scope, transactionLock)
compress.onCommand(player, command, "compress", arrayOf("yes"))
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(Component.text("DiamondBank-OG<reset>: <red>You are not holding a shulker box."))
}
},
{ assertEquals(9, inventory.countDiamonds(), "Diamond count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Compress without yes while holding a shulker box should fail")
fun compressNoYesHoldingShulkerBox() = runTest {
mockPlayerInventory(player, playerUuid, arrayOf(ItemStack(Material.SHULKER_BOX)))
val compress = Compress(config, balanceManager, mm, scope, transactionLock)
compress.onCommand(player, command, "compress", arrayOf())
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text(
"DiamondBank-OG<reset>: <#FFA500>Are you sure you want to compress the Diamond currency items in the shulker box you're holding? If so, run \"/compress yes\""
)
)
}
},
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
}
@@ -1,432 +0,0 @@
package net.trueog.diamondbankog.commands
import io.mockk.*
import io.mockk.impl.annotations.MockK
import io.mockk.impl.annotations.SpyK
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.test.runTest
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.minimessage.MiniMessage
import net.trueog.diamondbankog.BukkitMock
import net.trueog.diamondbankog.Constants.playerUuid
import net.trueog.diamondbankog.Utils.mockPlayerInventory
import net.trueog.diamondbankog.Utils.waitForCoroutines
import net.trueog.diamondbankog.balance.BalanceManager
import net.trueog.diamondbankog.balance.shard.Shard
import net.trueog.diamondbankog.balance.shard.ShardType
import net.trueog.diamondbankog.config.Config
import net.trueog.diamondbankog.transaction.InventoryLockExtensions.isInventoryLocked
import net.trueog.diamondbankog.transaction.TransactionLock
import net.trueog.diamondbankog.transaction.command.Deposit
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countDiamondBlocks
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countDiamonds
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countShards
import org.bukkit.Bukkit
import org.bukkit.Material
import org.bukkit.Server
import org.bukkit.command.Command
import org.bukkit.entity.Player
import org.bukkit.inventory.ItemStack
import org.junit.jupiter.api.*
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.MethodSource
class DepositTest {
@MockK private lateinit var config: Config
@MockK private lateinit var balanceManager: BalanceManager
@MockK private lateinit var mm: MiniMessage
@MockK private lateinit var player: Player
@MockK private lateinit var command: Command
@SpyK private var transactionLock = TransactionLock()
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
@BeforeEach
fun setup() {
MockKAnnotations.init(this)
every { config.prefix } returns "DiamondBank-OG"
val inputSlot = slot<String>()
every { mm.deserialize(capture(inputSlot)) } answers { Component.text(inputSlot.captured) }
every { player.uniqueId } returns playerUuid
every { player.world.name } returns "world"
every { player.hasPermission("diamondbank-og.deposit") } returns true
every { Bukkit.getPlayer("player") } returns player
every { server.getPlayer("player") } returns player
every { Bukkit.getPlayer(playerUuid) } returns player
every { server.getPlayer(playerUuid) } returns player
coEvery { balanceManager.addToBankShards(any(), any()) } returns Result.success(Unit)
coEvery { balanceManager.setPlayerShards(any(), any(), ShardType.INVENTORY) } returns Result.success(Unit)
coEvery { balanceManager.insertTransactionLog(any(), any(), any(), any(), any()) } returns Result.success(Unit)
every { player.sendMessage(any<Component>()) } just Runs
}
companion object {
lateinit var server: Server
@JvmStatic
@BeforeAll
fun setupBukkit() {
server = BukkitMock.mockBukkit()
}
@JvmStatic
fun depositData() =
listOf(
Arguments.of(
"1 Diamond",
"1.0 <aqua>Diamond",
arrayOf(ItemStack(Material.DIAMOND, 1)),
"1",
arrayOf<Long>(9),
0,
0,
0,
),
Arguments.of(
"2 Diamonds",
"2.0 <aqua>Diamonds",
arrayOf(ItemStack(Material.DIAMOND, 3)),
"2",
arrayOf<Long>(2 * 9),
0,
1,
0,
),
Arguments.of(
"2.2 Diamonds only diamonds",
"2.2 <aqua>Diamonds",
arrayOf(ItemStack(Material.DIAMOND, 3)),
"2.2",
arrayOf<Long>(20),
7,
0,
0,
),
Arguments.of(
"2.2 Diamonds",
"2.2 <aqua>Diamonds",
arrayOf(Shard.createItemStack(2), ItemStack(Material.DIAMOND, 3)),
"2.2",
arrayOf<Long>(20),
0,
1,
0,
),
Arguments.of(
"2.2 Diamonds with blocks",
"2.2 <aqua>Diamonds",
arrayOf(ItemStack(Material.DIAMOND_BLOCK, 1)),
"2.2",
arrayOf<Long>(20),
7,
6,
0,
),
Arguments.of(
"2.9 Diamonds",
"3.0 <aqua>Diamonds",
arrayOf(ItemStack(Material.DIAMOND, 4)),
"2.9",
arrayOf<Long>(27),
0,
1,
0,
),
Arguments.of(
"all with 5 Diamonds",
"5.0 <aqua>Diamonds",
arrayOf(ItemStack(Material.DIAMOND, 5)),
"all",
arrayOf<Long>(45),
0,
0,
0,
),
Arguments.of(
"all with 5 Diamonds and 8 shards",
"5.8 <aqua>Diamonds",
arrayOf(Shard.createItemStack(8), ItemStack(Material.DIAMOND, 5)),
"all",
arrayOf<Long>(53),
0,
0,
0,
),
Arguments.of(
"1.8 Diamonds with overflow",
"1.8 <aqua>Diamonds",
Array(36) { ItemStack(Material.DIRT, 1) } +
Array(4) { ItemStack(Material.AIR, 1) } +
arrayOf(ItemStack(Material.DIAMOND, 3)),
"1.8",
arrayOf<Long>(17, 1),
0,
1,
0,
),
Arguments.of(
"1.1 Diamonds with diamond block with overflow",
"1.1 <aqua>Diamonds",
Array(36) { ItemStack(Material.DIRT, 1) } +
Array(4) { ItemStack(Material.AIR, 1) } +
arrayOf(ItemStack(Material.DIAMOND_BLOCK, 2)),
"1.1",
arrayOf<Long>(10, 63, 8),
0,
0,
1,
),
Arguments.of(
"2 renamed Diamond Shards",
"0.2 <aqua>Diamonds",
arrayOf(
Shard.createItemStack(2).apply {
val meta = itemMeta
meta.displayName(Component.text("Renamed Diamond Shard"))
itemMeta = meta
}
),
"0.2",
arrayOf<Long>(2),
0,
0,
0,
),
)
}
@ParameterizedTest(name = "{0}")
@DisplayName("Successful Deposit")
@MethodSource("depositData")
fun successfulDeposit(
@Suppress("UNUSED_PARAMETER") name: String,
successDiamondText: String,
inventoryContents: Array<ItemStack?>,
commandArg: String,
addedShards: Array<Long>,
invShardCount: Long,
invDiamondCount: Long,
invDiamondBlockCount: Long,
) = runTest {
val inventory = mockPlayerInventory(player, playerUuid, inventoryContents)
val deposit = Deposit(config, balanceManager, mm, scope, transactionLock)
deposit.onCommand(player, command, "deposit", arrayOf(commandArg))
waitForCoroutines(scope)
assertAll(
buildList {
add {
verify {
player.sendMessage(
Component.text(
"DiamondBank-OG<reset>: <green>Successfully deposited <yellow>$successDiamondText <green>into your bank account."
)
)
}
}
addedShards.forEach { add { coVerify { balanceManager.addToBankShards(playerUuid, it) } } }
add { assertEquals(invShardCount, inventory.countShards(), "Shard count") }
add { assertEquals(invDiamondCount, inventory.countDiamonds(), "Diamond count") }
add { assertEquals(invDiamondBlockCount, inventory.countDiamondBlocks(), "Diamond block count") }
add { assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") }
add { assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") }
}
)
}
@Test
@DisplayName("Deposit 2 Diamonds while only having 1")
fun depositWholeDiamondsWhileShort() = runTest {
val inventory = mockPlayerInventory(player, playerUuid, arrayOf(ItemStack(Material.DIAMOND, 1)))
val deposit = Deposit(config, balanceManager, mm, scope, transactionLock)
deposit.onCommand(player, command, "deposit", arrayOf("2"))
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text(
"DiamondBank-OG<reset>: <red>You do not have <yellow>2.0 <aqua>Diamonds <red>to deposit."
)
)
}
},
{ coVerify(exactly = 0) { balanceManager.addToBankShards(any(), any()) } },
{ assertEquals(1, inventory.countDiamonds(), "Diamond count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Deposit while in minigame should fail")
fun depositWhileInMinigame() = runTest {
every { player.world.name } returns "minigame_world"
val inventory = mockPlayerInventory(player, playerUuid, arrayOf(ItemStack(Material.DIAMOND, 5)))
val deposit = Deposit(config, balanceManager, mm, scope, transactionLock)
deposit.onCommand(player, command, "deposit", arrayOf("all"))
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text("DiamondBank-OG<reset>: <red>You cannot use /deposit when in a minigame.")
)
}
},
{ coVerify(exactly = 0) { balanceManager.addToBankShards(any(), any()) } },
{ assertEquals(5, inventory.countDiamonds(), "Diamond count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Deposit while locked should fail")
fun depositWhileLocked() = runTest {
val inventory = mockPlayerInventory(player, playerUuid, arrayOf(ItemStack(Material.DIAMOND, 5)))
coEvery { transactionLock.tryWithLockSuspend<Unit>(any(), any()) } returns TransactionLock.LockResult.Failed
val deposit = Deposit(config, balanceManager, mm, scope, transactionLock)
deposit.onCommand(player, command, "deposit", arrayOf("all"))
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text("DiamondBank-OG<reset>: <red>You are currently blocked from using /deposit.")
)
}
},
{ coVerify(exactly = 0) { balanceManager.addToBankShards(any(), any()) } },
{ assertEquals(5, inventory.countDiamonds(), "Diamond count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Deposit no arguments should fail")
fun depositNoArgs() = runTest {
val inventory = mockPlayerInventory(player, playerUuid, arrayOf(ItemStack(Material.DIAMOND, 5)))
val deposit = Deposit(config, balanceManager, mm, scope, transactionLock)
deposit.onCommand(player, command, "deposit", arrayOf())
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text(
"DiamondBank-OG<reset>: <red>You did not provide the amount of <aqua>Diamonds <red>that you want to deposit."
)
)
}
},
{ coVerify(exactly = 0) { balanceManager.addToBankShards(any(), any()) } },
{ assertEquals(5, inventory.countDiamonds(), "Diamond count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Deposit too many arguments should fail")
fun depositTooManyArgs() = runTest {
val inventory = mockPlayerInventory(player, playerUuid, arrayOf(ItemStack(Material.DIAMOND, 5)))
val deposit = Deposit(config, balanceManager, mm, scope, transactionLock)
deposit.onCommand(player, command, "deposit", arrayOf("1", "2"))
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text(
"DiamondBank-OG<reset>: <red>Please (only) provide the amount of <aqua>Diamonds <red>you want to deposit. Either a number or \"all\"."
)
)
}
},
{ coVerify(exactly = 0) { balanceManager.addToBankShards(any(), any()) } },
{ assertEquals(5, inventory.countDiamonds(), "Diamond count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@ParameterizedTest(name = "{0}")
@DisplayName("Deposit with invalid argument should fail")
@CsvSource(
"Not a number, abc, <red>Invalid argument",
"Negative, -1, <red>You cannot deposit a negative or zero amount",
"Zero, 0, <red>You cannot deposit a negative or zero amount",
"More than one decimal digit, 1.11, <aqua>Diamonds<red> can only have one decimal digit. Issue /diamondbankhelp for more information",
)
fun depositInvalidArg(@Suppress("UNUSED_PARAMETER") name: String, argument: String, errorMessage: String) =
runTest {
val inventory = mockPlayerInventory(player, playerUuid, arrayOf(ItemStack(Material.DIAMOND, 5)))
val deposit = Deposit(config, balanceManager, mm, scope, transactionLock)
deposit.onCommand(player, command, "deposit", arrayOf(argument))
waitForCoroutines(scope)
assertAll(
{ verify { player.sendMessage(Component.text("DiamondBank-OG<reset>: $errorMessage.")) } },
{ coVerify(exactly = 0) { balanceManager.addToBankShards(any(), any()) } },
{ assertEquals(5, inventory.countDiamonds(), "Diamond count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Deposit with no permission should fail")
fun depositNoPermission() = runTest {
every { player.hasPermission("diamondbank-og.deposit") } returns false
val inventory = mockPlayerInventory(player, playerUuid, arrayOf(ItemStack(Material.DIAMOND, 5)))
val deposit = Deposit(config, balanceManager, mm, scope, transactionLock)
deposit.onCommand(player, command, "deposit", arrayOf("1"))
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text("DiamondBank-OG<reset>: <red>You do not have permission to use this command.")
)
}
},
{ coVerify(exactly = 0) { balanceManager.addToBankShards(any(), any()) } },
{ assertEquals(5, inventory.countDiamonds(), "Diamond count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
}
@@ -1,498 +0,0 @@
package net.trueog.diamondbankog.commands
import io.mockk.*
import io.mockk.impl.annotations.MockK
import io.mockk.impl.annotations.SpyK
import java.util.*
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.test.runTest
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.minimessage.MiniMessage
import net.luckperms.api.LuckPerms
import net.luckperms.api.model.user.User
import net.luckperms.api.node.Node
import net.trueog.diamondbankog.BukkitMock
import net.trueog.diamondbankog.Constants.otherPlayerUuid
import net.trueog.diamondbankog.Constants.playerUuid
import net.trueog.diamondbankog.Utils.mockPlayerInventory
import net.trueog.diamondbankog.Utils.waitForCoroutines
import net.trueog.diamondbankog.balance.BalanceManager
import net.trueog.diamondbankog.balance.shard.Shard
import net.trueog.diamondbankog.balance.shard.ShardType
import net.trueog.diamondbankog.config.Config
import net.trueog.diamondbankog.transaction.InventoryLockExtensions.isInventoryLocked
import net.trueog.diamondbankog.transaction.TransactionLock
import net.trueog.diamondbankog.transaction.command.Pay
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countDiamondBlocks
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countDiamonds
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countShards
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countTotal
import org.bukkit.Bukkit
import org.bukkit.Material
import org.bukkit.Server
import org.bukkit.command.Command
import org.bukkit.entity.Player
import org.bukkit.inventory.ItemStack
import org.junit.jupiter.api.*
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.MethodSource
class PayTest {
@MockK private lateinit var config: Config
@MockK private lateinit var balanceManager: BalanceManager
@MockK private lateinit var mm: MiniMessage
@MockK private lateinit var player: Player
@MockK private lateinit var otherPlayer: Player
@MockK private lateinit var command: Command
@MockK private lateinit var luckPerms: LuckPerms
@SpyK private var transactionLock = TransactionLock()
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
@BeforeEach
fun setup() {
MockKAnnotations.init(this)
every { config.prefix } returns "DiamondBank-OG"
val inputSlot = slot<String>()
every { mm.deserialize(capture(inputSlot)) } answers { Component.text(inputSlot.captured) }
every { player.uniqueId } returns playerUuid
every { player.name } returns "Player"
every { player.world.name } returns "world"
every { player.hasPermission("diamondbank-og.pay") } returns true
every { otherPlayer.uniqueId } returns otherPlayerUuid
every { otherPlayer.name } returns "OtherPlayer"
every { otherPlayer.isOnline } returns true
every { otherPlayer.player } returns otherPlayer
every { Bukkit.getPlayer("player") } returns player
every { server.getPlayer("player") } returns player
every { Bukkit.getPlayer(playerUuid) } returns player
every { server.getPlayer(playerUuid) } returns player
every { Bukkit.getPlayer("otherplayer") } returns otherPlayer
every { server.getPlayer("otherplayer") } returns otherPlayer
every { Bukkit.getPlayer(otherPlayerUuid) } returns otherPlayer
every { server.getPlayer(otherPlayerUuid) } returns otherPlayer
every { otherPlayer.hasPlayedBefore() } returns true
coEvery { balanceManager.transferBankShards(any(), any(), any(), any()) } returns Result.success(Unit)
coEvery { balanceManager.addToBankShards(any(), any()) } returns Result.success(Unit)
coEvery { balanceManager.setPlayerShards(any(), any(), ShardType.INVENTORY) } returns Result.success(Unit)
coEvery { balanceManager.subtractFromBankShards(any(), any()) } returns Result.success(Unit)
coEvery { balanceManager.insertTransactionLog(any(), any(), any(), any(), any()) } returns Result.success(Unit)
every { player.sendMessage(any<Component>()) } just Runs
every { otherPlayer.sendMessage(any<Component>()) } just Runs
}
companion object {
lateinit var server: Server
@JvmStatic
@BeforeAll
fun setupBukkit() {
server = BukkitMock.mockBukkit()
}
@JvmStatic
fun payData() =
listOf(
Arguments.of(
"1 Diamond from inventory",
"1.0 <aqua>Diamond",
arrayOf(ItemStack(Material.DIAMOND, 1)),
0,
0,
"1",
arrayOf<Long>(),
9,
0,
0,
0,
),
Arguments.of(
"2 Diamonds from inventory",
"2.0 <aqua>Diamonds",
arrayOf(ItemStack(Material.DIAMOND, 3)),
0,
0,
"2",
arrayOf<Long>(),
2 * 9,
0,
1,
0,
),
Arguments.of(
"2.2 Diamonds from inventory only diamonds",
"2.2 <aqua>Diamonds",
arrayOf(ItemStack(Material.DIAMOND, 3)),
0,
0,
"2.2",
arrayOf<Long>(),
20,
7,
0,
0,
),
Arguments.of(
"2.2 Diamonds from inventory",
"2.2 <aqua>Diamonds",
arrayOf(Shard.createItemStack(2), ItemStack(Material.DIAMOND, 3)),
0,
0,
"2.2",
arrayOf<Long>(),
20,
0,
1,
0,
),
Arguments.of(
"2.2 Diamonds from inventory with blocks",
"2.2 <aqua>Diamonds",
arrayOf(ItemStack(Material.DIAMOND_BLOCK, 1)),
0,
0,
"2.2",
arrayOf<Long>(),
20,
7,
6,
0,
),
Arguments.of(
"2.9 Diamonds from inventory",
"3.0 <aqua>Diamonds",
arrayOf(ItemStack(Material.DIAMOND, 4)),
0,
0,
"2.9",
arrayOf<Long>(),
27,
0,
1,
0,
),
Arguments.of(
"1.8 Diamonds from inventory with overflow",
"1.8 <aqua>Diamonds",
Array(36) { ItemStack(Material.DIRT, 1) } +
Array(4) { ItemStack(Material.AIR, 1) } +
arrayOf(ItemStack(Material.DIAMOND, 3)),
0,
0,
"1.8",
arrayOf<Long>(1),
17,
0,
1,
0,
),
Arguments.of(
"1.1 Diamonds from inventory with diamond block with overflow",
"1.1 <aqua>Diamonds",
Array(36) { ItemStack(Material.DIRT, 1) } +
Array(4) { ItemStack(Material.AIR, 1) } +
arrayOf(ItemStack(Material.DIAMOND_BLOCK, 2)),
0,
0,
"1.1",
arrayOf<Long>(63, 8),
10,
0,
0,
1,
),
Arguments.of(
"1 Diamond: 0.5 Diamonds from bank and 1 Diamond in inventory",
"1.0 <aqua>Diamond",
arrayOf(ItemStack(Material.DIAMOND, 1)),
5,
5,
"1",
arrayOf<Long>(),
9,
5,
0,
0,
),
Arguments.of(
"5 Diamonds from bank",
"5.0 <aqua>Diamonds",
arrayOf<ItemStack>(),
45,
45,
"5",
arrayOf<Long>(),
45,
0,
0,
0,
),
)
}
@ParameterizedTest(name = "{0}")
@DisplayName("Successful Pay")
@MethodSource("payData")
fun successfulPay(
@Suppress("UNUSED_PARAMETER") name: String,
successDiamondText: String,
inventoryContents: Array<ItemStack?>,
bankShards: Long,
bankShardsRemoved: Long,
commandArg: String,
addedShardsPlayer: Array<Long>,
addedShardsOtherPlayer: Long,
invShardCount: Long,
invDiamondCount: Long,
invDiamondBlockCount: Long,
) = runTest {
val inventory = mockPlayerInventory(player, playerUuid, inventoryContents)
coEvery { balanceManager.getBankShards(playerUuid) } returns Result.success(bankShards)
val user = mockk<User>()
every { user.nodes } returns mutableListOf<Node>()
every { luckPerms.userManager.getUser(any<UUID>()) } returns user
val pay = Pay(config, balanceManager, mm, scope, luckPerms, transactionLock)
pay.onCommand(player, command, "pay", arrayOf("otherplayer", commandArg))
waitForCoroutines(scope)
assertAll(
buildList {
add {
verify {
player.sendMessage(
Component.text(
"DiamondBank-OG<reset>: <green>Successfully paid <yellow>$successDiamondText <green>to OtherPlayer<reset><green>."
)
)
}
}
add {
verify {
otherPlayer.sendMessage(
Component.text(
"DiamondBank-OG<reset>: <green>Player<reset> <green>has paid you <yellow>$successDiamondText<green>."
)
)
}
}
addedShardsPlayer.forEach { add { coVerify { balanceManager.addToBankShards(playerUuid, it) } } }
add {
coVerify {
balanceManager.transferBankShards(
playerUuid,
otherPlayerUuid,
bankShardsRemoved,
addedShardsOtherPlayer,
)
}
}
add { assertEquals(invShardCount, inventory.countShards(), "Shard count") }
add { assertEquals(invDiamondCount, inventory.countDiamonds(), "Diamond count") }
add { assertEquals(invDiamondBlockCount, inventory.countDiamondBlocks(), "Diamond block count") }
add { assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") }
add { assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") }
}
)
}
@Test
@DisplayName("Pay while in minigame should fail")
fun payWhileInMinigame() = runTest {
every { player.world.name } returns "minigame_world"
val inventory = mockPlayerInventory(player, playerUuid, emptyArray())
val pay = Pay(config, balanceManager, mm, scope, luckPerms, transactionLock)
pay.onCommand(player, command, "pay", arrayOf("otherplayer", "1"))
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text("DiamondBank-OG<reset>: <red>You cannot use /pay when in a minigame.")
)
}
},
{ coVerify(exactly = 0) { balanceManager.subtractFromBankShards(any(), any()) } },
{ assertEquals(0, inventory.countTotal(), "Total count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Pay while locked should fail")
fun payWhileLocked() = runTest {
val inventory = mockPlayerInventory(player, playerUuid, emptyArray())
coEvery { transactionLock.tryWithLockSuspend<Unit>(any(), any()) } returns TransactionLock.LockResult.Failed
val pay = Pay(config, balanceManager, mm, scope, luckPerms, transactionLock)
pay.onCommand(player, command, "pay", arrayOf("otherplayer", "1"))
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text("DiamondBank-OG<reset>: <red>You are currently blocked from using /pay.")
)
}
},
{ coVerify(exactly = 0) { balanceManager.subtractFromBankShards(any(), any()) } },
{ assertEquals(0, inventory.countTotal(), "Total count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Pay no arguments should fail")
fun payNoArgs() = runTest {
val inventory = mockPlayerInventory(player, playerUuid, emptyArray())
val pay = Pay(config, balanceManager, mm, scope, luckPerms, transactionLock)
pay.onCommand(player, command, "pay", arrayOf())
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text(
"DiamondBank-OG<reset>: <red>You did not provide the name or the UUID of a player and the amount of <aqua>Diamonds<red>."
)
)
}
},
{ coVerify(exactly = 0) { balanceManager.subtractFromBankShards(any(), any()) } },
{ assertEquals(0, inventory.countTotal(), "Total count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Pay too few arguments should fail")
fun payTooFewArgs() = runTest {
val inventory = mockPlayerInventory(player, playerUuid, emptyArray())
val pay = Pay(config, balanceManager, mm, scope, luckPerms, transactionLock)
pay.onCommand(player, command, "pay", arrayOf("1"))
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text(
"DiamondBank-OG<reset>: <red>Please (only) provide the name or the UUID of a player and the amount of <aqua>Diamonds<red>."
)
)
}
},
{ coVerify(exactly = 0) { balanceManager.subtractFromBankShards(any(), any()) } },
{ assertEquals(0, inventory.countTotal(), "Total count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Pay too many arguments should fail")
fun payTooManyArgs() = runTest {
val inventory = mockPlayerInventory(player, playerUuid, emptyArray())
val pay = Pay(config, balanceManager, mm, scope, luckPerms, transactionLock)
pay.onCommand(player, command, "pay", arrayOf("1", "2", "3"))
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text(
"DiamondBank-OG<reset>: <red>Please (only) provide the name or the UUID of a player and the amount of <aqua>Diamonds<red>."
)
)
}
},
{ coVerify(exactly = 0) { balanceManager.subtractFromBankShards(any(), any()) } },
{ assertEquals(0, inventory.countTotal(), "Total count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@ParameterizedTest(name = "{0}")
@DisplayName("Pay with invalid argument should fail")
@CsvSource(
"Not a number, abc, <red>Invalid argument",
"Negative, -1, <red>You cannot pay a negative or zero amount",
"Zero, 0, <red>You cannot pay a negative or zero amount",
"More than one decimal digit, 1.11, <aqua>Diamonds<red> can only have one decimal digit. Issue /diamondbankhelp for more information",
)
fun payInvalidArg(@Suppress("UNUSED_PARAMETER") name: String, argument: String, errorMessage: String) = runTest {
val inventory = mockPlayerInventory(player, playerUuid, emptyArray())
val pay = Pay(config, balanceManager, mm, scope, luckPerms, transactionLock)
pay.onCommand(player, command, "pay", arrayOf("otherplayer", argument))
waitForCoroutines(scope)
assertAll(
{ verify { player.sendMessage(Component.text("DiamondBank-OG<reset>: $errorMessage.")) } },
{ coVerify(exactly = 0) { balanceManager.subtractFromBankShards(any(), any()) } },
{ assertEquals(0, inventory.countTotal(), "Total count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Pay with no permission should fail")
fun payNoPermission() = runTest {
every { player.hasPermission("diamondbank-og.pay") } returns false
val inventory = mockPlayerInventory(player, playerUuid, emptyArray())
val pay = Pay(config, balanceManager, mm, scope, luckPerms, transactionLock)
pay.onCommand(player, command, "pay", arrayOf("1"))
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text("DiamondBank-OG<reset>: <red>You do not have permission to use this command.")
)
}
},
{ coVerify(exactly = 0) { balanceManager.subtractFromBankShards(any(), any()) } },
{ assertEquals(0, inventory.countTotal(), "Total count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
}
@@ -1,98 +0,0 @@
package net.trueog.diamondbankog.commands
import io.mockk.MockKAnnotations
import io.mockk.Runs
import io.mockk.coVerify
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.just
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.test.runTest
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.minimessage.MiniMessage
import net.trueog.diamondbankog.BukkitMock
import net.trueog.diamondbankog.Constants.playerUuid
import net.trueog.diamondbankog.Utils.waitForCoroutines
import net.trueog.diamondbankog.balance.BalanceManager
import net.trueog.diamondbankog.balance.command.SetBankBalance
import net.trueog.diamondbankog.config.Config
import org.bukkit.Bukkit
import org.bukkit.Server
import org.bukkit.command.Command
import org.bukkit.entity.Player
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertAll
class SetBankBalanceTest {
@MockK private lateinit var config: Config
@MockK private lateinit var balanceManager: BalanceManager
@MockK private lateinit var mm: MiniMessage
@MockK private lateinit var player: Player
@MockK private lateinit var command: Command
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
@BeforeEach
fun setup() {
MockKAnnotations.init(this)
every { config.prefix } returns "DiamondBank-OG"
val inputSlot = slot<String>()
every { mm.deserialize(capture(inputSlot)) } answers { Component.text(inputSlot.captured) }
every { player.uniqueId } returns playerUuid
every { player.world.name } returns "world"
every { player.hasPermission("diamondbank-og.setbankbalance") } returns true
every { Bukkit.getPlayer("player") } returns player
every { server.getPlayer("player") } returns player
every { Bukkit.getPlayer(playerUuid) } returns player
every { server.getPlayer(playerUuid) } returns player
every { player.sendMessage(any<Component>()) } just Runs
}
companion object {
lateinit var server: Server
@JvmStatic
@BeforeAll
fun setupBukkit() {
server = BukkitMock.mockBukkit()
}
}
@Test
@DisplayName("SetBankBalance with no permission should fail")
fun setBankBalanceNoPermission() = runTest {
every { player.hasPermission("diamondbank-og.setbankbalance") } returns false
val setBankBalance = SetBankBalance(config, balanceManager, mm, scope)
setBankBalance.onCommand(player, command, "setbankbalance", arrayOf())
waitForCoroutines(scope)
assertAll(
{ verify { player.hasPermission("diamondbank-og.setbankbalance") } },
{
verify {
player.sendMessage(
Component.text("DiamondBank-OG<reset>: <red>You do not have permission to use this command.")
)
}
},
{ coVerify(exactly = 0) { balanceManager.setPlayerShards(any(), any(), any()) } },
)
}
}
@@ -1,333 +0,0 @@
package net.trueog.diamondbankog.commands
import io.mockk.*
import io.mockk.impl.annotations.MockK
import io.mockk.impl.annotations.SpyK
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.test.runTest
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.minimessage.MiniMessage
import net.trueog.diamondbankog.BukkitMock
import net.trueog.diamondbankog.Constants.playerUuid
import net.trueog.diamondbankog.Utils.mockPlayerInventory
import net.trueog.diamondbankog.Utils.waitForCoroutines
import net.trueog.diamondbankog.balance.BalanceManager
import net.trueog.diamondbankog.balance.shard.ShardType
import net.trueog.diamondbankog.config.Config
import net.trueog.diamondbankog.transaction.InventoryLockExtensions.isInventoryLocked
import net.trueog.diamondbankog.transaction.TransactionLock
import net.trueog.diamondbankog.transaction.command.Withdraw
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countDiamonds
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countShards
import net.trueog.diamondbankog.util.PlayerInventoryExtensions.countTotal
import org.bukkit.Bukkit
import org.bukkit.Material
import org.bukkit.Server
import org.bukkit.command.Command
import org.bukkit.entity.Player
import org.bukkit.inventory.ItemStack
import org.junit.jupiter.api.*
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
class WithdrawTest {
@MockK private lateinit var config: Config
@MockK private lateinit var balanceManager: BalanceManager
@MockK private lateinit var mm: MiniMessage
@MockK private lateinit var player: Player
@MockK private lateinit var command: Command
@SpyK private var transactionLock = TransactionLock()
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
@BeforeEach
fun setup() {
MockKAnnotations.init(this)
every { config.prefix } returns "DiamondBank-OG"
val inputSlot = slot<String>()
every { mm.deserialize(capture(inputSlot)) } answers { Component.text(inputSlot.captured) }
every { player.uniqueId } returns playerUuid
every { player.world.name } returns "world"
every { player.hasPermission("diamondbank-og.withdraw") } returns true
every { Bukkit.getPlayer("player") } returns player
every { server.getPlayer("player") } returns player
every { server.getPlayer(playerUuid) } returns player
coEvery { balanceManager.addToBankShards(any(), any()) } returns Result.success(Unit)
coEvery { balanceManager.setPlayerShards(any(), any(), ShardType.INVENTORY) } returns Result.success(Unit)
coEvery { balanceManager.subtractFromBankShards(any(), any()) } returns Result.success(Unit)
coEvery { balanceManager.insertTransactionLog(any(), any(), any(), any(), any()) } returns Result.success(Unit)
every { player.sendMessage(any<Component>()) } just Runs
}
companion object {
lateinit var server: Server
@JvmStatic
@BeforeAll
fun setupBukkit() {
server = BukkitMock.mockBukkit()
}
}
@ParameterizedTest(name = "{0}")
@DisplayName("Successful withdraw")
@CsvSource(
"2 Diamonds, 2.0 <aqua>Diamonds, 2, 0, 2, 18",
"2.2 Diamonds, 2.2 <aqua>Diamonds, 2.2, 2, 2, 20",
"2.9 Diamonds, 3.0 <aqua>Diamonds, 2.9, 0, 3, 27",
"All, 11.1 <aqua>Diamonds, all, 1, 11, 100",
)
fun successfulWithdraw(
@Suppress("UNUSED_PARAMETER") name: String,
successDiamondText: String,
commandArg: String,
invShardsCount: Long,
invDiamondsCount: Long,
removedShardCount: Long,
) = runTest {
coEvery { balanceManager.getBankShards(playerUuid) } returns Result.success(100)
val inventory = mockPlayerInventory(player, playerUuid, emptyArray())
val withdraw = Withdraw(config, balanceManager, mm, scope, transactionLock)
withdraw.onCommand(player, command, "withdraw", arrayOf(commandArg))
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text(
"DiamondBank-OG<reset>: <green>Successfully withdrew <yellow>$successDiamondText <green>from your bank account."
)
)
}
},
{ coVerify { balanceManager.subtractFromBankShards(playerUuid, removedShardCount) } },
{ assertEquals(invShardsCount, inventory.countShards(), "Shard count") },
{ assertEquals(invDiamondsCount, inventory.countDiamonds(), "Diamond count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Withdraw while in minigame should fail")
fun withdrawWhileInMinigame() = runTest {
every { player.world.name } returns "minigame_world"
val inventory = mockPlayerInventory(player, playerUuid, emptyArray())
val withdraw = Withdraw(config, balanceManager, mm, scope, transactionLock)
withdraw.onCommand(player, command, "withdraw", arrayOf("all"))
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text("DiamondBank-OG<reset>: <red>You cannot use /withdraw when in a minigame.")
)
}
},
{ coVerify(exactly = 0) { balanceManager.subtractFromBankShards(any(), any()) } },
{ assertEquals(0, inventory.countTotal(), "Total count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Withdraw while locked should fail")
fun withdrawWhileLocked() = runTest {
val inventory = mockPlayerInventory(player, playerUuid, emptyArray())
coEvery { transactionLock.tryWithLockSuspend<Unit>(any(), any()) } returns TransactionLock.LockResult.Failed
val withdraw = Withdraw(config, balanceManager, mm, scope, transactionLock)
withdraw.onCommand(player, command, "withdraw", arrayOf("all"))
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text("DiamondBank-OG<reset>: <red>You are currently blocked from using /withdraw.")
)
}
},
{ coVerify(exactly = 0) { balanceManager.subtractFromBankShards(any(), any()) } },
{ assertEquals(0, inventory.countTotal(), "Total count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Withdraw no arguments should fail")
fun withdrawNoArgs() = runTest {
val inventory = mockPlayerInventory(player, playerUuid, emptyArray())
val withdraw = Withdraw(config, balanceManager, mm, scope, transactionLock)
withdraw.onCommand(player, command, "withdraw", arrayOf())
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text(
"DiamondBank-OG<reset>: <red>You did not provide the amount of <aqua>Diamonds <red>that you want to withdraw."
)
)
}
},
{ coVerify(exactly = 0) { balanceManager.subtractFromBankShards(any(), any()) } },
{ assertEquals(0, inventory.countTotal(), "Total count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Withdraw too many arguments should fail")
fun withdrawTooManyArgs() = runTest {
val inventory = mockPlayerInventory(player, playerUuid, emptyArray())
val withdraw = Withdraw(config, balanceManager, mm, scope, transactionLock)
withdraw.onCommand(player, command, "withdraw", arrayOf("1", "2"))
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text(
"DiamondBank-OG<reset>: <red>Please (only) provide the amount of <aqua>Diamonds <red>you want to withdraw. Either a number or \"all\"."
)
)
}
},
{ coVerify(exactly = 0) { balanceManager.subtractFromBankShards(any(), any()) } },
{ assertEquals(0, inventory.countTotal(), "Total count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@ParameterizedTest(name = "{0}")
@DisplayName("Withdraw with invalid argument should fail")
@CsvSource(
"Not a number, abc, <red>Invalid argument",
"Negative, -1, <red>You cannot withdraw a negative or zero amount",
"Zero, 0, <red>You cannot withdraw a negative or zero amount",
"More than one decimal digit, 1.11, <aqua>Diamonds<red> can only have one decimal digit. Issue /diamondbankhelp for more information",
)
fun withdrawInvalidArg(@Suppress("UNUSED_PARAMETER") name: String, argument: String, errorMessage: String) =
runTest {
val inventory = mockPlayerInventory(player, playerUuid, emptyArray())
val withdraw = Withdraw(config, balanceManager, mm, scope, transactionLock)
withdraw.onCommand(player, command, "withdraw", arrayOf(argument))
waitForCoroutines(scope)
assertAll(
{ verify { player.sendMessage(Component.text("DiamondBank-OG<reset>: $errorMessage.")) } },
{ coVerify(exactly = 0) { balanceManager.subtractFromBankShards(any(), any()) } },
{ assertEquals(0, inventory.countTotal(), "Total count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Withdraw with no permission should fail")
fun withdrawNoPermission() = runTest {
every { player.hasPermission("diamondbank-og.withdraw") } returns false
val inventory = mockPlayerInventory(player, playerUuid, emptyArray())
val withdraw = Withdraw(config, balanceManager, mm, scope, transactionLock)
withdraw.onCommand(player, command, "withdraw", arrayOf("1"))
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text("DiamondBank-OG<reset>: <red>You do not have permission to use this command.")
)
}
},
{ coVerify(exactly = 0) { balanceManager.subtractFromBankShards(any(), any()) } },
{ assertEquals(0, inventory.countTotal(), "Total count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Withdraw when not enough Diamonds in bank should fail")
fun withdrawNotEnoughDiamondsInBank() = runTest {
coEvery { balanceManager.getBankShards(playerUuid) } returns Result.success(18)
val inventory = mockPlayerInventory(player, playerUuid, emptyArray())
val withdraw = Withdraw(config, balanceManager, mm, scope, transactionLock)
withdraw.onCommand(player, command, "withdraw", arrayOf("5"))
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text(
"DiamondBank-OG<reset>: <red>Cannot withdraw <yellow>5.0 <aqua>Diamonds <red>because your bank only contains <yellow>2.0 <aqua>Diamonds<red>."
)
)
}
},
{ coVerify(exactly = 0) { balanceManager.subtractFromBankShards(any(), any()) } },
{ assertEquals(0, inventory.countTotal(), "Total count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
@Test
@DisplayName("Withdraw when not enough inventory space should fail")
fun withdrawNotEnoughInventorySpace() = runTest {
coEvery { balanceManager.getBankShards(playerUuid) } returns Result.success(18)
val inventory = mockPlayerInventory(player, playerUuid, Array(41) { ItemStack(Material.DIRT, 1) })
val withdraw = Withdraw(config, balanceManager, mm, scope, transactionLock)
withdraw.onCommand(player, command, "withdraw", arrayOf("2"))
waitForCoroutines(scope)
assertAll(
{
verify {
player.sendMessage(
Component.text(
"DiamondBank-OG<reset>: <red>You don't have enough inventory space to withdraw <yellow>2.0 <aqua>Diamonds<red>."
)
)
}
},
{ coVerify(exactly = 0) { balanceManager.subtractFromBankShards(any(), any()) } },
{ assertEquals(0, inventory.countTotal(), "Total count") },
{ assertFalse(playerUuid.isInventoryLocked(), "Inventory locked") },
{ assertFalse(transactionLock.isLocked(playerUuid), "Transaction lock") },
)
}
}