From c6f826bb4112236b413e736e8d2593fa457d10a6 Mon Sep 17 00:00:00 2001
From: 1robie <97293924+1robie@users.noreply.github.com>
Date: Thu, 10 Sep 2026 10:03:57 +0200
Subject: [PATCH 1/7] feat: Implement withdrawIfSufficient method for currency
providers and add CurrenciesAPI for main thread scheduling
---
build.gradle.kts | 4 +-
gradle.properties | 2 +-
readme.md | 130 +++++++++++++
.../fr/traqueur/currencies/Currencies.java | 84 ++++++++-
.../fr/traqueur/currencies/CurrenciesAPI.java | 47 +++++
.../currencies/CurrencyArgumentChecks.java | 38 ++++
.../fr/traqueur/currencies/CurrencyLocks.java | 49 +++++
.../traqueur/currencies/CurrencyProvider.java | 127 +++++++++++++
.../traqueur/currencies/CurrencyRegistry.java | 163 ++++++++++++++++
.../currencies/TransactionResult.java | 174 ++++++++++++++++++
.../providers/ExcellentEconomyProvider.java | 68 +++++++
.../providers/ExperienceProvider.java | 48 ++++-
.../currencies/providers/ItemProvider.java | 58 +++++-
.../currencies/providers/LevelProvider.java | 39 ++++
.../providers/PlayerPointsProvider.java | 32 ++++
.../providers/RedisEconomyProvider.java | 52 ++++++
.../currencies/providers/VaultProvider.java | 45 +++++
.../currencies/providers/VotingProvider.java | 32 ++++
.../providers/ZEssentialsProvider.java | 38 +++-
19 files changed, 1196 insertions(+), 34 deletions(-)
create mode 100644 src/main/java/fr/traqueur/currencies/CurrenciesAPI.java
create mode 100644 src/main/java/fr/traqueur/currencies/CurrencyArgumentChecks.java
create mode 100644 src/main/java/fr/traqueur/currencies/CurrencyLocks.java
create mode 100644 src/main/java/fr/traqueur/currencies/CurrencyRegistry.java
create mode 100644 src/main/java/fr/traqueur/currencies/TransactionResult.java
diff --git a/build.gradle.kts b/build.gradle.kts
index 376f295..a1c41b9 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -46,7 +46,9 @@ dependencies {
compileOnly("com.github.MilkBowl:VaultAPI:1.7")
compileOnly("com.github.PlayerNguyen:OptEco:2.1.4b")
compileOnly("com.willfp:EcoBits:1.8.4")
- compileOnly("com.bencodez:votingplugin:6.17.2")
+ compileOnly("com.bencodez:votingplugin:6.17.2") {
+ exclude(group = "org.mozilla", module = "rhino")
+ }
compileOnly("com.github.Emibergo02:RedisEconomy:4.3.19")
compileOnly("io.lettuce:lettuce-core:6.4.0.RELEASE")
compileOnly("su.nightexpress.excellenteconomy:ExcellentEconomy:2.8.0")
diff --git a/gradle.properties b/gradle.properties
index 1284b4e..eef2451 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1 +1 @@
-version=1.0.14
\ No newline at end of file
+version=1.0.15
\ No newline at end of file
diff --git a/readme.md b/readme.md
index af1a420..f2a1775 100644
--- a/readme.md
+++ b/readme.md
@@ -136,6 +136,135 @@ Currencies.ZESSENTIALS.getBalance(player, "coins");
```
+### Safe Purchases: `withdrawIfSufficient`
+
+`withdraw` does **not** check whether the player can afford the amount. Most backends will happily
+drive a balance negative or silently clamp it to zero. Checking the balance first and then calling
+`withdraw` is not safe either, because anything can happen between the two calls: a second click, a
+second server, or an economy plugin that commits its writes asynchronously. That gap is a
+double-spend.
+
+Use `withdrawIfSufficient` for anything that is paying for something. It performs the check and the
+debit as one operation and tells you what happened:
+
+```java
+TransactionResult result = Currencies.VAULT.withdrawIfSufficient(
+ playerId, new BigDecimal("1000"), "Shop purchase");
+
+switch (result.getStatus()) {
+ case SUCCESS:
+ // The money is gone. Only now hand over the goods.
+ break;
+ case INSUFFICIENT_FUNDS:
+ player.sendMessage("You cannot afford this.");
+ break;
+ case UNSUPPORTED:
+ // The backend cannot do this at all. Nothing was debited.
+ break;
+ case FAILED:
+ // Something went wrong. Nothing was debited.
+ break;
+}
+```
+
+**Nothing is ever debited unless the status is `SUCCESS`.** That is the only case where you should
+hand out the goods.
+
+An asynchronous variant is available and never completes exceptionally, failures come back through
+the result:
+
+```java
+Currencies.VAULT.withdrawIfSufficientAsync(playerId, amount, "default", "Shop purchase")
+ .thenAccept(result -> { /* ... */ });
+```
+
+### Atomicity Per Backend
+
+Some backends can refuse a withdrawal themselves, others cannot. When a backend cannot, the library
+emulates the operation by locking around a balance read and a withdraw. That stops one server racing
+itself, but it **cannot** stop a second server acting on the same shared economy.
+
+Ask before you rely on it, either up front or from the result:
+
+```java
+if (!Currencies.VAULT.hasNativeConditionalWithdraw("default")) {
+ getLogger().warning("This currency cannot guarantee atomic purchases across servers.");
+}
+
+result.isBackendGuaranteed(); // false when the library had to emulate the operation
+```
+
+| Currency | Atomic | Notes |
+| --- | --- | --- |
+| `VAULT` | yes | As atomic as the underlying economy plugin |
+| `PLAYERPOINTS` | yes | `take` refuses when the balance is too low |
+| `ZESSENTIALS` | yes | `withdraw` reports the outcome |
+| `REDISECONOMY` | yes | Validated in Redis, so it holds across servers |
+| `EXCELLENTECONOMY` | yes | Native async operation with a result |
+| `VOTINGPLUGIN` | yes | `removePoints` reports the outcome |
+| `ITEM`, `ZMENUITEMS` | yes | Player inventory, main thread only |
+| `LEVEL`, `EXPERIENCE` | yes | Player state, main thread only |
+| `COINSENGINE` | no | Its boolean means "currency found", not "could afford" |
+| `ECOBITS` | no | `adjustBalance` returns nothing |
+| `BEASTTOKENS` | no | `removeTokens` returns nothing |
+| `ROYALEECONOMY` | no | `removeBalance` returns nothing |
+| `ELEMENTALTOKENS`, `ELEMENTALGEMS` | no | `removeTokens` / `removeGems` return nothing |
+
+If you run a network where several servers share one economy database, only the currencies marked
+atomic are safe against cross-server double spends. For the others the fix has to come from the
+economy plugin itself.
+
+### Custom Economies
+
+`Currencies` is an enum, so it cannot be extended. To plug in your own economy, implement
+`CurrencyProvider` and register the instance:
+
+```java
+public class MyGemsProvider implements CurrencyProvider {
+ public void deposit(UUID playerId, BigDecimal amount, String reason) { /* ... */ }
+ public void withdraw(UUID playerId, BigDecimal amount, String reason) { /* ... */ }
+ public BigDecimal getBalance(UUID playerId) { /* ... */ }
+}
+
+CurrencyRegistry.register("my_gems", new MyGemsProvider());
+
+TransactionResult result = CurrencyRegistry.withdrawIfSufficient(
+ "my_gems", playerId, BigDecimal.TEN, "Shop purchase");
+```
+
+When you override `withdrawIfSufficient`, build the result with the factory that matches who made
+the decision: `TransactionResult.nativeSuccess(...)` / `nativeInsufficientFunds(...)` when your
+backend checked the funds itself, or `emulatedSuccess(...)` / `emulatedInsufficientFunds(...)` when
+you checked them yourself. `unsupported(...)` and `failed(...)` cover the rest. That is what
+`isBackendGuaranteed()` reports back to the caller.
+
+To look a registered currency up, `CurrencyRegistry.require(name)` throws when there is none and
+`CurrencyRegistry.find(name)` returns null. Use `registerOrReplace(...)` to deliberately swap an
+implementation, for example on a config reload.
+
+Those three methods are all you have to write. Everything else has a default implementation, so an
+existing provider keeps working unchanged. Two optional overrides are worth knowing about:
+
+- `hasNativeConditionalWithdraw()` and `withdrawIfSufficient(...)`: override both when your backend can
+ refuse a withdrawal itself. You get a real guarantee instead of the emulated one. Call
+ `CurrencyArgumentChecks.findProblem(playerId, amount)` first so your implementation rejects the same bad
+ inputs as every other provider.
+- `requiresMainThread()`: **defaults to `true`**, because most Bukkit APIs are not thread safe.
+ Override it to return `false` only if your backend is documented as safe for concurrent access.
+ Leaving it `true` means `withdrawIfSufficientAsync` hops back to the main thread for you.
+
+### Asynchronous Access and `CurrenciesAPI.init`
+
+Scheduling work back onto the main server thread needs a plugin instance. If you intend to use the
+asynchronous API with a main-thread-bound currency, call this once in `onEnable`:
+
+```java
+CurrenciesAPI.init(this);
+```
+
+Without it, an asynchronous call on such a currency returns a `FAILED` result explaining what is
+missing, rather than touching player state from the wrong thread.
+
### Example Usage
Here is a more complete example of how to use the `Currencies` class within a Minecraft plugin. In this example, we create an economy instance with `zEssentials` and provide a command that allows players to choose between `Vault` and `zEssentials` to deposit or withdraw an amount.
@@ -221,3 +350,4 @@ public class MyPlugin extends JavaPlugin {
}
}
+```
\ No newline at end of file
diff --git a/src/main/java/fr/traqueur/currencies/Currencies.java b/src/main/java/fr/traqueur/currencies/Currencies.java
index 53bb13f..3ee3f34 100644
--- a/src/main/java/fr/traqueur/currencies/Currencies.java
+++ b/src/main/java/fr/traqueur/currencies/Currencies.java
@@ -11,6 +11,7 @@
import java.util.Map;
import java.util.Set;
import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
/**
* The list of all the currencies that can be used in the plugin.
@@ -92,6 +93,9 @@ public enum Currencies {
EXCELLENTEECONOMY("ExcellentEconomy", ExcellentEconomyProvider.class, true, true, EXCELLENTECONOMY)
;
+ private final static String DEFAULT_CURRENCY_NAME = "default";
+ private final static String DEFAULT_REASON = "No reason";
+
static {
Updater.checkUpdates();
}
@@ -194,7 +198,7 @@ private boolean isDisable() {
* @param reason The reason of the deposit.
*/
public void deposit(UUID playerId, BigDecimal amount, String reason) {
- this.deposit(playerId, amount, "default", reason);
+ this.deposit(playerId, amount, DEFAULT_CURRENCY_NAME, reason);
}
/**
@@ -205,7 +209,7 @@ public void deposit(UUID playerId, BigDecimal amount, String reason) {
* @param reason The reason of the withdrawal.
*/
public void withdraw(UUID playerId, BigDecimal amount, String reason) {
- this.withdraw(playerId, amount, "default", reason);
+ this.withdraw(playerId, amount, DEFAULT_CURRENCY_NAME, reason);
}
/**
@@ -215,7 +219,7 @@ public void withdraw(UUID playerId, BigDecimal amount, String reason) {
* @param amount The amount of money to add.
*/
public void deposit(UUID playerId, BigDecimal amount) {
- this.deposit(playerId, amount, "default", "No reason");
+ this.deposit(playerId, amount, DEFAULT_CURRENCY_NAME, DEFAULT_REASON);
}
/**
@@ -225,7 +229,7 @@ public void deposit(UUID playerId, BigDecimal amount) {
* @param amount The amount of money to remove.
*/
public void withdraw(UUID playerId, BigDecimal amount) {
- this.withdraw(playerId, amount, "default", "No reason");
+ this.withdraw(playerId, amount, DEFAULT_CURRENCY_NAME, DEFAULT_REASON);
}
/**
@@ -235,7 +239,7 @@ public void withdraw(UUID playerId, BigDecimal amount) {
* @return The balance of the player.
*/
public BigDecimal getBalance(UUID playerId) {
- return getBalance(playerId, "default");
+ return this.getBalance(playerId, DEFAULT_CURRENCY_NAME);
}
/**
@@ -276,19 +280,79 @@ public BigDecimal getBalance(UUID playerId, String currencyName) {
return this.providers.get(currencyName).getBalance(playerId);
}
+ /**
+ * Remove some money from a player, but only if the player can actually afford it.
+ *
+ *
This is the operation to use for a purchase. Unlike calling {@link #getBalance} and then
+ * {@link #withdraw}, nothing can slip in between the check and the debit.
+ *
+ * @param playerId The UUID of the player to debit.
+ * @param amount The amount to debit, must be strictly positive.
+ * @param reason The reason of the withdrawal.
+ * @return The outcome. Nothing is debited unless the status is
+ * {@link TransactionResult.Status#SUCCESS}.
+ */
+ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) {
+ return this.withdrawIfSufficient(playerId, amount, DEFAULT_CURRENCY_NAME, reason);
+ }
+
+ /**
+ * Remove some money from a player, but only if the player can actually afford it.
+ *
+ * @param playerId The UUID of the player to debit.
+ * @param amount The amount to debit, must be strictly positive.
+ * @param currencyName The name of the currency.
+ * @param reason The reason of the withdrawal.
+ * @return The outcome. Nothing is debited unless the status is
+ * {@link TransactionResult.Status#SUCCESS}.
+ */
+ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String currencyName, String reason) {
+ this.canBeUse(currencyName);
+ return this.providers.get(currencyName).withdrawIfSufficient(playerId, amount, reason);
+ }
+
+ /**
+ * Asynchronous variant of {@link #withdrawIfSufficient(UUID, BigDecimal, String, String)}.
+ *
+ * @param playerId The UUID of the player to debit.
+ * @param amount The amount to debit, must be strictly positive.
+ * @param currencyName The name of the currency.
+ * @param reason The reason of the withdrawal.
+ * @return A future completed with the outcome.
+ */
+ public CompletableFuture withdrawIfSufficientAsync(UUID playerId, BigDecimal amount, String currencyName, String reason) {
+ this.canBeUse(currencyName);
+ return this.providers.get(currencyName).withdrawIfSufficientAsync(playerId, amount, reason);
+ }
+
+ /**
+ * Whether this currency can check the balance and apply the debit as one indivisible
+ * operation, rather than having the library emulate it.
+ *
+ * Worth checking on a network where several servers share one economy: an emulated
+ * operation is only protected against races inside this server.
+ *
+ * @param currencyName The name of the currency.
+ * @return True when the backend itself guarantees the operation.
+ */
+ public boolean hasNativeConditionalWithdraw(String currencyName) {
+ this.canBeUse(currencyName);
+ return this.providers.get(currencyName).hasNativeConditionalWithdraw();
+ }
+
private void canBeUse(String currencyName) {
if (this.isDisable()) {
throw new IllegalStateException("The plugin " + this.name + " is not enable.");
}
- if (autoCreate) {
+ if (this.autoCreate) {
- if (currencySpecific) {
- registerProvider(currencyName, currencyName);
+ if (this.currencySpecific) {
+ this.registerProvider(currencyName, currencyName);
} else {
- registerProvider(currencyName);
+ this.registerProvider(currencyName);
}
} else if (!this.providers.containsKey(currencyName)) {
- String currency = name.equalsIgnoreCase("default") ? "" : " and for the currency " + name;
+ String currency = this.name.equalsIgnoreCase(DEFAULT_CURRENCY_NAME) ? "" : " and for the currency " + name;
throw new IllegalStateException("You must create the provider for the plugin " + this.name + currency + " before using it.");
}
}
diff --git a/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java b/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java
new file mode 100644
index 0000000..02958c3
--- /dev/null
+++ b/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java
@@ -0,0 +1,47 @@
+package fr.traqueur.currencies;
+
+import org.bukkit.Bukkit;
+import org.bukkit.plugin.Plugin;
+
+public final class CurrenciesAPI {
+
+ private static volatile Plugin plugin;
+
+ private CurrenciesAPI() {
+ }
+
+ /**
+ * Register the plugin instance used to schedule work on the main server thread.
+ *
+ * @param owningPlugin The plugin instance, must not be null.
+ */
+ public static void init(Plugin owningPlugin) {
+ if (owningPlugin == null) {
+ throw new IllegalArgumentException("The plugin instance cannot be null.");
+ }
+ if (plugin != null) {
+ throw new IllegalStateException("The plugin instance has already been set by " + plugin.getName() + ".");
+ }
+ plugin = owningPlugin;
+ }
+
+ /**
+ * @return The registered plugin instance, or null when {@link #init(Plugin)} was never called.
+ */
+ public static Plugin getPlugin() {
+ return plugin;
+ }
+
+ /**
+ * Whether the caller is on the main server thread.
+ *
+ * @return True when the caller is running on the main server thread.
+ */
+ static boolean isMainThread() {
+ try {
+ return Bukkit.isPrimaryThread();
+ } catch (Throwable throwable) {
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/fr/traqueur/currencies/CurrencyArgumentChecks.java b/src/main/java/fr/traqueur/currencies/CurrencyArgumentChecks.java
new file mode 100644
index 0000000..340cf18
--- /dev/null
+++ b/src/main/java/fr/traqueur/currencies/CurrencyArgumentChecks.java
@@ -0,0 +1,38 @@
+package fr.traqueur.currencies;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+/**
+ * Argument checks shared by every conditional currency operation.
+ *
+ * Custom providers that override
+ * {@link CurrencyProvider#withdrawIfSufficient(UUID, BigDecimal, String)} should call
+ * {@link #findProblem(UUID, BigDecimal)} first, so that every implementation rejects the same
+ * nonsensical inputs in the same way.
+ */
+public final class CurrencyArgumentChecks {
+
+ private CurrencyArgumentChecks() {
+ }
+
+ /**
+ * Looks for a problem with the arguments to a conditional currency operation.
+ *
+ * @param playerId The player.
+ * @param amount The requested amount.
+ * @return A result describing the problem, or null when the arguments are usable.
+ */
+ public static TransactionResult findProblem(UUID playerId, BigDecimal amount) {
+ if (playerId == null) {
+ return TransactionResult.failed(amount, "The player UUID cannot be null.");
+ }
+ if (amount == null) {
+ return TransactionResult.failed(BigDecimal.ZERO, "The amount cannot be null.");
+ }
+ if (amount.signum() <= 0) {
+ return TransactionResult.failed(amount, "The amount must be strictly positive, was " + amount + ".");
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/fr/traqueur/currencies/CurrencyLocks.java b/src/main/java/fr/traqueur/currencies/CurrencyLocks.java
new file mode 100644
index 0000000..d62bf73
--- /dev/null
+++ b/src/main/java/fr/traqueur/currencies/CurrencyLocks.java
@@ -0,0 +1,49 @@
+package fr.traqueur.currencies;
+
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
+
+final class CurrencyLocks {
+
+ private static final int STRIPES = 64;
+ private static final long LOCK_TIMEOUT_MILLIS = 250L;
+ private static final ReentrantLock[] LOCKS = new ReentrantLock[STRIPES];
+
+ static {
+ for (int i = 0; i < STRIPES; i++) {
+ LOCKS[i] = new ReentrantLock();
+ }
+ }
+
+ private CurrencyLocks() {
+ }
+
+ /**
+ * Resolve the lock guarding a given provider and player pair.
+ *
+ * @param provider The provider performing the operation.
+ * @param playerId The player being debited.
+ * @return The lock to use.
+ */
+ static ReentrantLock lockFor(CurrencyProvider provider, UUID playerId) {
+ int hash = System.identityHashCode(provider) * 31 + (playerId == null ? 0 : playerId.hashCode());
+ hash ^= (hash >>> 16);
+ return LOCKS[hash & (STRIPES - 1)];
+ }
+
+ /**
+ * Try to acquire a lock within the configured timeout.
+ *
+ * @param lock The lock to acquire.
+ * @return True when the lock was acquired and must be released by the caller.
+ */
+ static boolean tryLock(ReentrantLock lock) {
+ try {
+ return lock.tryLock(LOCK_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException exception) {
+ Thread.currentThread().interrupt();
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/fr/traqueur/currencies/CurrencyProvider.java b/src/main/java/fr/traqueur/currencies/CurrencyProvider.java
index 49873f8..7afb949 100644
--- a/src/main/java/fr/traqueur/currencies/CurrencyProvider.java
+++ b/src/main/java/fr/traqueur/currencies/CurrencyProvider.java
@@ -1,7 +1,11 @@
package fr.traqueur.currencies;
+import org.bukkit.Bukkit;
+
import java.math.BigDecimal;
import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.locks.ReentrantLock;
/**
* Interface used to interact with a currency provider.
@@ -36,4 +40,127 @@ public interface CurrencyProvider {
*/
BigDecimal getBalance(UUID playerId);
+ /**
+ * Whether this provider can check the balance and apply the debit as one indivisible
+ * operation.
+ *
+ * When this returns false, {@link #withdrawIfSufficient} still works but is emulated by
+ * the library: the sequence is serialized inside this JVM, which stops one server racing
+ * itself, but it cannot stop a second server acting on the same shared economy.
+ *
+ * @return True when the backend itself guarantees the operation.
+ */
+ default boolean hasNativeConditionalWithdraw() {
+ return false;
+ }
+
+ /**
+ * Whether this provider must be used from the main server thread.
+ *
+ * This defaults to true on purpose. Most Bukkit plugin APIs are not thread safe,
+ * and a provider that reads or writes live player state, such as inventory contents or
+ * experience levels, will corrupt that state if it is touched from another thread. Assuming
+ * the unsafe case by default means an unknown third party provider is never called off the
+ * main thread by accident.
+ *
+ * Override this to return false only when the backend is documented as safe for
+ * concurrent access, for example one that talks to Redis or to its own thread safe storage.
+ * Doing so lets {@link #withdrawIfSufficientAsync} keep the work off the main thread.
+ *
+ * @return True when every call has to happen on the main server thread.
+ */
+ default boolean requiresMainThread() {
+ return true;
+ }
+
+ /**
+ * Debit a player, but only if the funds are actually available.
+ *
+ * This is the operation to use for a purchase. Unlike a balance check followed by a
+ * separate {@link #withdraw}, nothing can slip between the two steps.
+ *
+ * The default implementation emulates the operation by reading the balance and then
+ * withdrawing, with the whole sequence held under a lock so that two threads cannot both
+ * observe the same balance and both debit. The returned result reports
+ * {@link TransactionResult#isBackendGuaranteed()} as false to make that limitation visible.
+ *
+ * @param playerId The UUID of the player to debit.
+ * @param amount The amount to debit, must be strictly positive.
+ * @param reason The reason of the withdrawal.
+ * @return The outcome. Nothing is debited unless the status is
+ * {@link TransactionResult.Status#SUCCESS}.
+ */
+ default TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) {
+ TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount);
+ if (invalid != null) {
+ return invalid;
+ }
+
+ ReentrantLock lock = CurrencyLocks.lockFor(this, playerId);
+ if (!CurrencyLocks.tryLock(lock)) {
+ return TransactionResult.failed(amount, "Timed out waiting for a concurrent operation on the same balance.");
+ }
+
+ try {
+ BigDecimal balance = this.getBalance(playerId);
+ if (balance == null) {
+ balance = BigDecimal.ZERO;
+ }
+
+ if (balance.compareTo(amount) < 0) {
+ return TransactionResult.emulatedInsufficientFunds(amount, balance);
+ }
+
+ this.withdraw(playerId, amount, reason);
+ return TransactionResult.emulatedSuccess(amount, balance.subtract(amount));
+ } catch (Exception exception) {
+ return TransactionResult.failed(amount, "The backend threw while withdrawing: " + exception.getMessage());
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * Asynchronous variant of {@link #withdrawIfSufficient}.
+ *
+ * For a provider that must run on the main server thread, the work is scheduled back onto
+ * it, which requires {@link CurrenciesAPI#init(org.bukkit.plugin.Plugin)} to have been called.
+ * Without it the returned result is a failure rather than an unsafe off thread call.
+ *
+ * @param playerId The UUID of the player to debit.
+ * @param amount The amount to debit, must be strictly positive.
+ * @param reason The reason of the withdrawal.
+ * @return A future completed with the outcome. The future itself never completes
+ * exceptionally, failures are reported through the result.
+ */
+ default CompletableFuture withdrawIfSufficientAsync(UUID playerId, BigDecimal amount, String reason) {
+ TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount);
+ if (invalid != null) {
+ return CompletableFuture.completedFuture(invalid);
+ }
+
+ if (!this.requiresMainThread()) {
+ return CompletableFuture.supplyAsync(() -> CurrencyProvider.this.withdrawIfSufficient(playerId, amount, reason));
+ }
+
+ if (CurrenciesAPI.isMainThread()) {
+ return CompletableFuture.completedFuture(this.withdrawIfSufficient(playerId, amount, reason));
+ }
+
+ if (CurrenciesAPI.getPlugin() == null) {
+ return CompletableFuture.completedFuture(TransactionResult.failed(amount,
+ "This currency must be used on the main server thread. Call CurrenciesAPI.init(plugin) "
+ + "to enable asynchronous access to it."));
+ }
+
+ final CompletableFuture future = new CompletableFuture();
+ Bukkit.getScheduler().runTask(CurrenciesAPI.getPlugin(), () -> {
+ try {
+ future.complete(CurrencyProvider.this.withdrawIfSufficient(playerId, amount, reason));
+ } catch (Exception exception) {
+ future.complete(TransactionResult.failed(amount, "The backend threw while withdrawing: " + exception.getMessage()));
+ }
+ });
+ return future;
+ }
}
diff --git a/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java b/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java
new file mode 100644
index 0000000..27b2787
--- /dev/null
+++ b/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java
@@ -0,0 +1,163 @@
+package fr.traqueur.currencies;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.math.BigDecimal;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Registry for currencies that are not one of the built in {@link Currencies} constants.
+ *
+ * {@code
+ * CurrencyRegistry.register("my_gems", new MyGemsProvider());
+ *
+ * TransactionResult result = CurrencyRegistry.require("my_gems")
+ * .withdrawIfSufficient(playerId, BigDecimal.TEN, "Shop purchase");
+ * if (result.isSuccess()) {
+ * // give the goods
+ * }
+ * }
+ *
+ * A custom provider only has to implement {@code deposit}, {@code withdraw} and
+ * {@code getBalance}. It inherits an emulated {@link CurrencyProvider#withdrawIfSufficient} which
+ * is serialized inside this JVM. If the backend can refuse a withdrawal itself, override
+ * {@code withdrawIfSufficient} and {@code hasNativeConditionalWithdraw} to get a real guarantee. If the
+ * backend is safe to use off the main server thread, also override
+ * {@link CurrencyProvider#requiresMainThread()} to return false.
+ */
+public final class CurrencyRegistry {
+
+ private static final Map PROVIDERS = new ConcurrentHashMap();
+
+ private CurrencyRegistry() {
+ }
+
+ /**
+ * Register a custom provider under a name.
+ *
+ * @param name The name used to look the provider up. Case-insensitive.
+ * @param provider The provider instance.
+ * @throws IllegalArgumentException if the name or the provider is null or the name is blank.
+ * @throws IllegalStateException if a different provider is already registered under this name.
+ */
+ public static void register(String name, CurrencyProvider provider) {
+ String key = normalize(name);
+ if (provider == null) {
+ throw new IllegalArgumentException("The provider cannot be null.");
+ }
+
+ CurrencyProvider existing = PROVIDERS.putIfAbsent(key, provider);
+ if (existing != null && existing != provider) {
+ throw new IllegalStateException("A different provider is already registered for the currency " + name + ".");
+ }
+ }
+
+ /**
+ * Register a custom provider, replacing any provider already registered under this name.
+ *
+ * @param name The name used to look the provider up. Case-insensitive.
+ * @param provider The provider instance.
+ * @return The provider that was previously registered, or null.
+ */
+ public static CurrencyProvider registerOrReplace(String name, CurrencyProvider provider) {
+ String key = normalize(name);
+ if (provider == null) {
+ throw new IllegalArgumentException("The provider cannot be null.");
+ }
+ return PROVIDERS.put(key, provider);
+ }
+
+ /**
+ * Remove a registered provider.
+ *
+ * @param name The name it was registered under.
+ * @return The removed provider, or null when nothing was registered.
+ */
+ public static CurrencyProvider unregister(String name) {
+ return PROVIDERS.remove(normalize(name));
+ }
+
+ /**
+ * Look a registered provider up, failing when there is none.
+ *
+ * @param name The name it was registered under.
+ * @return The provider, never null.
+ * @throws IllegalStateException if nothing is registered under this name.
+ */
+ @NotNull
+ public static CurrencyProvider require(String name) {
+ CurrencyProvider provider = find(name);
+ if (provider == null) {
+ throw new IllegalStateException("No custom currency is registered under the name " + name
+ + ". Register one with CurrencyRegistry.register(name, provider) first.");
+ }
+ return provider;
+ }
+
+ /**
+ * Look a registered provider up, returning null when there is none.
+ *
+ * @param name The name it was registered under.
+ * @return The provider, or null.
+ */
+ @Nullable
+ public static CurrencyProvider find(String name) {
+ return PROVIDERS.get(normalize(name));
+ }
+
+ /**
+ * @param name The name to look up.
+ * @return True when a provider is registered under this name.
+ */
+ public static boolean isRegistered(String name) {
+ return name != null && PROVIDERS.containsKey(normalize(name));
+ }
+
+ /**
+ * @return The names of every registered custom currency. The returned set is a snapshot.
+ */
+ @NotNull
+ public static Set getRegisteredNames() {
+ return Collections.unmodifiableSet(new java.util.HashSet<>(PROVIDERS.keySet()));
+ }
+
+ /**
+ * Debit a registered custom currency, but only if the funds are available.
+ *
+ * @param name The name the provider was registered under.
+ * @param playerId The UUID of the player to debit.
+ * @param amount The amount to debit, must be strictly positive.
+ * @param reason The reason of the withdrawal.
+ * @return The outcome. Nothing is debited unless the status is
+ * {@link TransactionResult.Status#SUCCESS}.
+ */
+ public static TransactionResult withdrawIfSufficient(String name, UUID playerId, BigDecimal amount, String reason) {
+ return require(name).withdrawIfSufficient(playerId, amount, reason);
+ }
+
+ /**
+ * Asynchronous variant of {@link #withdrawIfSufficient(String, UUID, BigDecimal, String)}.
+ *
+ * @param name The name the provider was registered under.
+ * @param playerId The UUID of the player to debit.
+ * @param amount The amount to debit, must be strictly positive.
+ * @param reason The reason of the withdrawal.
+ * @return A future completed with the outcome.
+ */
+ public static CompletableFuture withdrawIfSufficientAsync(String name, UUID playerId, BigDecimal amount, String reason) {
+ return require(name).withdrawIfSufficientAsync(playerId, amount, reason);
+ }
+
+ private static String normalize(String name) {
+ if (name == null || name.trim().isEmpty()) {
+ throw new IllegalArgumentException("The currency name cannot be null or blank.");
+ }
+ return name.trim().toLowerCase(java.util.Locale.ROOT);
+ }
+}
diff --git a/src/main/java/fr/traqueur/currencies/TransactionResult.java b/src/main/java/fr/traqueur/currencies/TransactionResult.java
new file mode 100644
index 0000000..99adf53
--- /dev/null
+++ b/src/main/java/fr/traqueur/currencies/TransactionResult.java
@@ -0,0 +1,174 @@
+package fr.traqueur.currencies;
+
+import java.math.BigDecimal;
+
+public final class TransactionResult {
+
+ public enum Status {
+
+ /**
+ * The funds were available and the debit was applied.
+ */
+ SUCCESS,
+
+ /**
+ * The player did not have enough funds. Nothing was debited.
+ */
+ INSUFFICIENT_FUNDS,
+
+ /**
+ * The backend does not implement this operation at all. Nothing was debited.
+ * Callers must decide for themselves whether to fall back to a non-atomic
+ * balance check followed by a plain withdraw, or to refuse the operation.
+ */
+ UNSUPPORTED,
+
+ /**
+ * The operation could not be completed for any other reason, for example the
+ * economy plugin returned an error or the player data could not be loaded.
+ * Nothing was debited.
+ */
+ FAILED
+ }
+
+ private final Status status;
+ private final BigDecimal amount;
+ private final BigDecimal balance;
+ private final String errorMessage;
+ private final boolean backendGuaranteed;
+
+ private TransactionResult(Status status, BigDecimal amount, BigDecimal balance, String errorMessage, boolean backendGuaranteed) {
+ this.status = status;
+ this.amount = amount == null ? BigDecimal.ZERO : amount;
+ this.balance = balance;
+ this.errorMessage = errorMessage;
+ this.backendGuaranteed = backendGuaranteed;
+ }
+
+ /**
+ * Build a successful result for a backend that applied the check and the debit itself.
+ *
+ * Use this from a provider that overrides
+ * {@link CurrencyProvider#withdrawIfSufficient(java.util.UUID, BigDecimal, String)} because its
+ * backend can refuse a withdrawal on its own.
+ *
+ * @param amount The amount that was debited.
+ * @param balance The resulting balance, or null when the backend does not report it.
+ * @return The result.
+ */
+ public static TransactionResult nativeSuccess(BigDecimal amount, BigDecimal balance) {
+ return new TransactionResult(Status.SUCCESS, amount, balance, null, true);
+ }
+
+ /**
+ * Build a successful result for an operation the library emulated with a balance read followed
+ * by a withdraw.
+ *
+ * @param amount The amount that was debited.
+ * @param balance The resulting balance, or null when it is not known.
+ * @return The result.
+ */
+ public static TransactionResult emulatedSuccess(BigDecimal amount, BigDecimal balance) {
+ return new TransactionResult(Status.SUCCESS, amount, balance, null, false);
+ }
+
+ /**
+ * Build an insufficient funds result for a backend that made the decision itself. Nothing was
+ * debited.
+ *
+ * @param amount The amount that was requested.
+ * @param balance The balance that was observed, or null when it is not known.
+ * @return The result.
+ */
+ public static TransactionResult nativeInsufficientFunds(BigDecimal amount, BigDecimal balance) {
+ return new TransactionResult(Status.INSUFFICIENT_FUNDS, amount, balance, null, true);
+ }
+
+ /**
+ * Build an insufficient funds result for a check the library performed itself. Nothing was
+ * debited.
+ *
+ * @param amount The amount that was requested.
+ * @param balance The balance that was observed, or null when it is not known.
+ * @return The result.
+ */
+ public static TransactionResult emulatedInsufficientFunds(BigDecimal amount, BigDecimal balance) {
+ return new TransactionResult(Status.INSUFFICIENT_FUNDS, amount, balance, null, false);
+ }
+
+ /**
+ * Build a result for a backend that cannot perform this operation. Nothing was debited.
+ *
+ * @param amount The amount that was requested.
+ * @param errorMessage A human readable explanation.
+ * @return The result.
+ */
+ public static TransactionResult unsupported(BigDecimal amount, String errorMessage) {
+ return new TransactionResult(Status.UNSUPPORTED, amount, null, errorMessage, false);
+ }
+
+ /**
+ * Build a failed result. Nothing was debited.
+ *
+ * @param amount The amount that was requested.
+ * @param errorMessage A human readable explanation.
+ * @return The result.
+ */
+ public static TransactionResult failed(BigDecimal amount, String errorMessage) {
+ return new TransactionResult(Status.FAILED, amount, null, errorMessage, false);
+ }
+
+ /**
+ * @return The outcome of the operation.
+ */
+ public Status getStatus() {
+ return this.status;
+ }
+
+ /**
+ * @return True only when the funds were actually debited.
+ */
+ public boolean isSuccess() {
+ return this.status == Status.SUCCESS;
+ }
+
+ /**
+ * @return True when the backend guaranteed that the check and the debit were indivisible.
+ * False means the library emulated the operation and it is only safe against concurrent
+ * access from inside this server.
+ */
+ public boolean isBackendGuaranteed() {
+ return this.backendGuaranteed;
+ }
+
+ /**
+ * @return The amount that was requested.
+ */
+ public BigDecimal getAmount() {
+ return this.amount;
+ }
+
+ /**
+ * @return The resulting balance, or null when the backend does not report one.
+ */
+ public BigDecimal getBalance() {
+ return this.balance;
+ }
+
+ /**
+ * @return A human-readable explanation for a failure, or null.
+ */
+ public String getErrorMessage() {
+ return this.errorMessage;
+ }
+
+ @Override
+ public String toString() {
+ return "TransactionResult{status=" + this.status
+ + ", amount=" + this.amount
+ + ", balance=" + this.balance
+ + ", backendGuaranteed=" + this.backendGuaranteed
+ + (this.errorMessage == null ? "" : ", error='" + this.errorMessage + "'")
+ + '}';
+ }
+}
diff --git a/src/main/java/fr/traqueur/currencies/providers/ExcellentEconomyProvider.java b/src/main/java/fr/traqueur/currencies/providers/ExcellentEconomyProvider.java
index 3b26755..4116f7e 100644
--- a/src/main/java/fr/traqueur/currencies/providers/ExcellentEconomyProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/ExcellentEconomyProvider.java
@@ -1,13 +1,18 @@
package fr.traqueur.currencies.providers;
+import fr.traqueur.currencies.CurrencyArgumentChecks;
import fr.traqueur.currencies.CurrencyProvider;
+import fr.traqueur.currencies.TransactionResult;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.plugin.RegisteredServiceProvider;
import su.nightexpress.excellenteconomy.api.ExcellentEconomyAPI;
import su.nightexpress.excellenteconomy.api.currency.operation.OperationContext;
+import su.nightexpress.excellenteconomy.api.currency.operation.OperationResult;
import java.math.BigDecimal;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.BiFunction;
import java.util.UUID;
public class ExcellentEconomyProvider implements CurrencyProvider {
@@ -53,4 +58,67 @@ public BigDecimal getBalance(UUID playerId) {
: this.api.getBalanceAsync(playerId, this.currencyName).join();
return BigDecimal.valueOf(raw);
}
+
+ @Override
+ public boolean hasNativeConditionalWithdraw() {
+ return true;
+ }
+
+ @Override
+ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) {
+ TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount);
+ if (invalid != null) {
+ return invalid;
+ }
+
+ try {
+ OperationContext ctx = OperationContext.custom(reason);
+ Player player = Bukkit.getPlayer(playerId);
+
+ boolean success;
+ if (player != null) {
+ success = this.api.withdraw(player, this.currencyName, amount.doubleValue(), ctx);
+ } else {
+ OperationResult result = this.api.withdrawAsync(playerId, this.currencyName, amount.doubleValue(), ctx).join();
+ success = result != null && result.success();
+ }
+
+ if (success) {
+ return TransactionResult.nativeSuccess(amount, this.getBalance(playerId));
+ }
+ return TransactionResult.nativeInsufficientFunds(amount, this.getBalance(playerId));
+ } catch (Exception exception) {
+ return TransactionResult.failed(amount, "ExcellentEconomy threw while withdrawing: " + exception.getMessage());
+ }
+ }
+
+ @Override
+ public CompletableFuture withdrawIfSufficientAsync(UUID playerId, BigDecimal amount, String reason) {
+ TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount);
+ if (invalid != null) {
+ return CompletableFuture.completedFuture(invalid);
+ }
+
+ try {
+ OperationContext ctx = OperationContext.custom(reason);
+ return this.api.withdrawAsync(playerId, this.currencyName, amount.doubleValue(), ctx)
+ .handle((result, throwable) -> {
+ if (throwable != null) {
+ return TransactionResult.failed(amount, "ExcellentEconomy threw while withdrawing: " + throwable.getMessage());
+ }
+ if (result != null && result.success()) {
+ return TransactionResult.nativeSuccess(amount, null);
+ }
+ return TransactionResult.nativeInsufficientFunds(amount, null);
+ });
+ } catch (Exception exception) {
+ return CompletableFuture.completedFuture(
+ TransactionResult.failed(amount, "ExcellentEconomy threw while withdrawing: " + exception.getMessage()));
+ }
+ }
+
+ @Override
+ public boolean requiresMainThread() {
+ return false;
+ }
}
diff --git a/src/main/java/fr/traqueur/currencies/providers/ExperienceProvider.java b/src/main/java/fr/traqueur/currencies/providers/ExperienceProvider.java
index bad37b2..47c8afc 100644
--- a/src/main/java/fr/traqueur/currencies/providers/ExperienceProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/ExperienceProvider.java
@@ -1,7 +1,10 @@
package fr.traqueur.currencies.providers;
+import fr.traqueur.currencies.CurrencyArgumentChecks;
import fr.traqueur.currencies.CurrencyProvider;
+import fr.traqueur.currencies.TransactionResult;
import org.bukkit.Bukkit;
+import org.bukkit.OfflinePlayer;
import org.bukkit.entity.Player;
import java.math.BigDecimal;
@@ -13,8 +16,8 @@ public class ExperienceProvider implements CurrencyProvider {
public void deposit(UUID playerId, BigDecimal amount, String reason) {
Player player = Bukkit.getPlayer(playerId);
if (player != null) {
- BigDecimal totalExperience = BigDecimal.valueOf(getTotalExperience(player));
- setTotalExperience(player, totalExperience.add(amount).intValue());
+ BigDecimal totalExperience = BigDecimal.valueOf(this.getTotalExperience(player));
+ this.setTotalExperience(player, totalExperience.add(amount).intValue());
}
}
@@ -22,16 +25,16 @@ public void deposit(UUID playerId, BigDecimal amount, String reason) {
public void withdraw(UUID playerId, BigDecimal amount, String reason) {
Player player = Bukkit.getPlayer(playerId);
if (player != null) {
- BigDecimal totalExperience = BigDecimal.valueOf(getTotalExperience(player));
+ BigDecimal totalExperience = BigDecimal.valueOf(this.getTotalExperience(player));
BigDecimal newExperience = totalExperience.subtract(amount);
- setTotalExperience(player, newExperience.max(BigDecimal.ZERO).intValue());
+ this.setTotalExperience(player, newExperience.max(BigDecimal.ZERO).intValue());
}
}
@Override
public BigDecimal getBalance(UUID playerId) {
Player player = Bukkit.getPlayer(playerId);
- return player != null ? BigDecimal.valueOf(getTotalExperience(player)) : BigDecimal.ZERO;
+ return player != null ? BigDecimal.valueOf(this.getTotalExperience(player)) : BigDecimal.ZERO;
}
private void setTotalExperience(Player player, int experience) {
@@ -41,7 +44,7 @@ private void setTotalExperience(Player player, int experience) {
player.setTotalExperience(0);
int currentExperience = experience;
while (currentExperience > 0) {
- int j = getExpAtLevel(player);
+ int j = this.getExpAtLevel(player);
currentExperience -= j;
if (currentExperience >= 0) {
player.giveExp(j);
@@ -54,7 +57,7 @@ private void setTotalExperience(Player player, int experience) {
}
private int getExpAtLevel(Player player) {
- return getExpAtLevel(player.getLevel());
+ return this.getExpAtLevel(player.getLevel());
}
private int getExpAtLevel(int experience) {
@@ -64,15 +67,42 @@ private int getExpAtLevel(int experience) {
}
private int getTotalExperience(Player player) {
- int experience = Math.round(getExpAtLevel(player) * player.getExp());
+ int experience = Math.round(this.getExpAtLevel(player) * player.getExp());
int playerLevel = player.getLevel();
while (playerLevel > 0) {
playerLevel--;
- experience += getExpAtLevel(playerLevel);
+ experience += this.getExpAtLevel(playerLevel);
}
if (experience < 0) {
experience = Integer.MAX_VALUE;
}
return experience;
}
+
+ @Override
+ public boolean hasNativeConditionalWithdraw() {
+ return true;
+ }
+
+ @Override
+ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) {
+ TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount);
+ if (invalid != null) {
+ return invalid;
+ }
+
+ Player player = Bukkit.getPlayer(playerId);
+ if (player == null) {
+ return TransactionResult.failed(amount, "Experience can only be taken from an online player.");
+ }
+
+ BigDecimal current = BigDecimal.valueOf(this.getTotalExperience(player));
+ if (current.compareTo(amount) < 0) {
+ return TransactionResult.nativeInsufficientFunds(amount, current);
+ }
+
+ BigDecimal remaining = current.subtract(amount);
+ this.setTotalExperience(player, remaining.intValue());
+ return TransactionResult.nativeSuccess(amount, remaining);
+ }
}
diff --git a/src/main/java/fr/traqueur/currencies/providers/ItemProvider.java b/src/main/java/fr/traqueur/currencies/providers/ItemProvider.java
index f5f0deb..a71d442 100644
--- a/src/main/java/fr/traqueur/currencies/providers/ItemProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/ItemProvider.java
@@ -1,6 +1,8 @@
package fr.traqueur.currencies.providers;
+import fr.traqueur.currencies.CurrencyArgumentChecks;
import fr.traqueur.currencies.CurrencyProvider;
+import fr.traqueur.currencies.TransactionResult;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
@@ -24,7 +26,7 @@ public ItemProvider(Plugin plugin, ItemStack itemStack) {
public void deposit(UUID playerId, BigDecimal amount, String reason) {
Player player = Bukkit.getPlayer(playerId);
if (player != null) {
- giveItem(player, amount.intValue(), this.itemStack);
+ this.giveItem(player, amount.intValue(), this.itemStack);
} else{
this.plugin.getLogger().severe("Deposit items to " + playerId + " but is offline");
}
@@ -34,7 +36,7 @@ public void deposit(UUID playerId, BigDecimal amount, String reason) {
public void withdraw(UUID playerId, BigDecimal amount, String reason) {
Player player = Bukkit.getPlayer(playerId);
if (player != null) {
- removeItems(player, this.itemStack, amount.intValue());
+ this.removeItems(player, this.itemStack, amount.intValue());
} else {
this.plugin.getLogger().severe("Withdraw items from " + playerId + " but is offline");
}
@@ -44,7 +46,7 @@ public void withdraw(UUID playerId, BigDecimal amount, String reason) {
public BigDecimal getBalance(UUID playerId) {
Player player = Bukkit.getPlayer(playerId);
if (player != null) {
- return BigDecimal.valueOf(getAmount(player, this.itemStack));
+ return BigDecimal.valueOf(this.getAmount(player, this.itemStack));
} else return BigDecimal.ZERO;
}
@@ -89,11 +91,11 @@ protected void giveItem(Player player, long value, ItemStack itemStack) {
if (value > 64) {
value -= 64;
itemStack.setAmount(64);
- give(player, itemStack);
- giveItem(player, value, itemStack);
+ this.give(player, itemStack);
+ this.giveItem(player, value, itemStack);
} else {
itemStack.setAmount((int) value);
- give(player, itemStack);
+ this.give(player, itemStack);
}
}
@@ -102,7 +104,7 @@ public ItemStack getItemStack(Player player) {
}
private void give(Player player, ItemStack item) {
- if (hasInventoryFull(player)) player.getWorld().dropItem(player.getLocation(), item);
+ if (this.hasInventoryFull(player)) player.getWorld().dropItem(player.getLocation(), item);
else player.getInventory().addItem(item);
}
@@ -115,4 +117,46 @@ private boolean hasInventoryFull(Player player) {
}
return slot == 0;
}
+
+ @Override
+ public boolean hasNativeConditionalWithdraw() {
+ return true;
+ }
+
+ @Override
+ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) {
+ TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount);
+ if (invalid != null) {
+ return invalid;
+ }
+
+ Player player = Bukkit.getPlayer(playerId);
+ if (player == null) {
+ return TransactionResult.failed(amount, "Items can only be taken from an online player.");
+ }
+
+ if (amount.stripTrailingZeros().scale() > 0) {
+ return TransactionResult.failed(amount, "An item currency only supports whole amounts, got " + amount + ".");
+ }
+
+ int cost;
+ try {
+ cost = amount.intValueExact();
+ } catch (ArithmeticException exception) {
+ return TransactionResult.failed(amount, "The amount does not fit in an integer item count: " + amount + ".");
+ }
+
+ ItemStack currencyItem = this.getItemStack(player);
+ if (currencyItem == null) {
+ return TransactionResult.failed(amount, "The currency item could not be resolved for " + player.getName() + ".");
+ }
+
+ int held = this.getAmount(player, currencyItem);
+ if (held < cost) {
+ return TransactionResult.nativeInsufficientFunds(amount, BigDecimal.valueOf(held));
+ }
+
+ this.removeItems(player, currencyItem, cost);
+ return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(held - cost));
+ }
}
diff --git a/src/main/java/fr/traqueur/currencies/providers/LevelProvider.java b/src/main/java/fr/traqueur/currencies/providers/LevelProvider.java
index 023d324..3e60725 100644
--- a/src/main/java/fr/traqueur/currencies/providers/LevelProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/LevelProvider.java
@@ -1,6 +1,8 @@
package fr.traqueur.currencies.providers;
+import fr.traqueur.currencies.CurrencyArgumentChecks;
import fr.traqueur.currencies.CurrencyProvider;
+import fr.traqueur.currencies.TransactionResult;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
@@ -32,4 +34,41 @@ public BigDecimal getBalance(UUID playerId) {
Player player = Bukkit.getPlayer(playerId);
return BigDecimal.valueOf(player != null ? player.getLevel() : 0);
}
+
+ @Override
+ public boolean hasNativeConditionalWithdraw() {
+ return true;
+ }
+
+ @Override
+ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) {
+ TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount);
+ if (invalid != null) {
+ return invalid;
+ }
+
+ Player player = Bukkit.getPlayer(playerId);
+ if (player == null) {
+ return TransactionResult.failed(amount, "Levels can only be taken from an online player.");
+ }
+
+ if (amount.stripTrailingZeros().scale() > 0) {
+ return TransactionResult.failed(amount, "Levels only support whole amounts, got " + amount + ".");
+ }
+
+ int cost;
+ try {
+ cost = amount.intValueExact();
+ } catch (ArithmeticException exception) {
+ return TransactionResult.failed(amount, "The amount does not fit in an integer level: " + amount + ".");
+ }
+
+ int current = player.getLevel();
+ if (current < cost) {
+ return TransactionResult.nativeInsufficientFunds(amount, BigDecimal.valueOf(current));
+ }
+
+ player.setLevel(current - cost);
+ return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(current - cost));
+ }
}
diff --git a/src/main/java/fr/traqueur/currencies/providers/PlayerPointsProvider.java b/src/main/java/fr/traqueur/currencies/providers/PlayerPointsProvider.java
index 3de7f77..8b10053 100644
--- a/src/main/java/fr/traqueur/currencies/providers/PlayerPointsProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/PlayerPointsProvider.java
@@ -1,6 +1,8 @@
package fr.traqueur.currencies.providers;
+import fr.traqueur.currencies.CurrencyArgumentChecks;
import fr.traqueur.currencies.CurrencyProvider;
+import fr.traqueur.currencies.TransactionResult;
import org.black_ixx.playerpoints.PlayerPoints;
import org.black_ixx.playerpoints.PlayerPointsAPI;
import org.bukkit.plugin.java.JavaPlugin;
@@ -35,4 +37,34 @@ public void withdraw(UUID playerId, BigDecimal amount, String reason) {
public BigDecimal getBalance(UUID playerId) {
return BigDecimal.valueOf(this.getAPI().look(playerId));
}
+
+ @Override
+ public boolean hasNativeConditionalWithdraw() {
+ return true;
+ }
+
+ @Override
+ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) {
+ TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount);
+ if (invalid != null) {
+ return invalid;
+ }
+
+ try {
+ if (amount.stripTrailingZeros().scale() > 0) {
+ return TransactionResult.failed(amount, "PlayerPoints only supports whole amounts, got " + amount + ".");
+ }
+
+ int points = amount.intValueExact();
+ if (this.getAPI().take(playerId, points)) {
+ return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(this.getAPI().look(playerId)));
+ }
+
+ return TransactionResult.nativeInsufficientFunds(amount, BigDecimal.valueOf(this.getAPI().look(playerId)));
+ } catch (ArithmeticException exception) {
+ return TransactionResult.failed(amount, "The amount does not fit in a PlayerPoints integer: " + amount + ".");
+ } catch (Exception exception) {
+ return TransactionResult.failed(amount, "PlayerPoints threw while withdrawing: " + exception.getMessage());
+ }
+ }
}
diff --git a/src/main/java/fr/traqueur/currencies/providers/RedisEconomyProvider.java b/src/main/java/fr/traqueur/currencies/providers/RedisEconomyProvider.java
index 72564fc..c7074e3 100644
--- a/src/main/java/fr/traqueur/currencies/providers/RedisEconomyProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/RedisEconomyProvider.java
@@ -2,7 +2,10 @@
import dev.unnm3d.rediseconomy.api.RedisEconomyAPI;
import dev.unnm3d.rediseconomy.currency.Currency;
+import fr.traqueur.currencies.CurrencyArgumentChecks;
import fr.traqueur.currencies.CurrencyProvider;
+import fr.traqueur.currencies.TransactionResult;
+import net.milkbowl.vault.economy.EconomyResponse;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
@@ -57,4 +60,53 @@ public BigDecimal getBalance(UUID playerId) {
}
return BigDecimal.ZERO;
}
+
+ @Override
+ public boolean hasNativeConditionalWithdraw() {
+ return true;
+ }
+
+ @Override
+ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) {
+ TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount);
+ if (invalid != null) {
+ return invalid;
+ }
+
+ try {
+ Currency currency = this.getCurrency();
+ if (currency == null) {
+ return TransactionResult.failed(amount, "The RedisEconomy currency " + this.economyName + " was not found.");
+ }
+
+ OfflinePlayer offlinePlayer = Bukkit.getOfflinePlayer(playerId);
+ EconomyResponse response = currency.withdrawPlayer(offlinePlayer, amount.doubleValue());
+ if (response == null) {
+ return TransactionResult.failed(amount, "RedisEconomy returned no response.");
+ }
+
+ if (response.type == EconomyResponse.ResponseType.SUCCESS) {
+ return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(response.balance));
+ }
+
+ if (response.type == EconomyResponse.ResponseType.NOT_IMPLEMENTED) {
+ return TransactionResult.unsupported(amount, "RedisEconomy does not implement withdrawPlayer.");
+ }
+
+ if (!currency.has(playerId, amount.doubleValue())) {
+ return TransactionResult.nativeInsufficientFunds(amount, BigDecimal.valueOf(currency.getBalance(playerId)));
+ }
+
+ return TransactionResult.failed(amount, response.errorMessage == null
+ ? "RedisEconomy refused the withdrawal."
+ : response.errorMessage);
+ } catch (Exception exception) {
+ return TransactionResult.failed(amount, "RedisEconomy threw while withdrawing: " + exception.getMessage());
+ }
+ }
+
+ @Override
+ public boolean requiresMainThread() {
+ return false;
+ }
}
diff --git a/src/main/java/fr/traqueur/currencies/providers/VaultProvider.java b/src/main/java/fr/traqueur/currencies/providers/VaultProvider.java
index 371f92f..5187e31 100644
--- a/src/main/java/fr/traqueur/currencies/providers/VaultProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/VaultProvider.java
@@ -1,7 +1,10 @@
package fr.traqueur.currencies.providers;
+import fr.traqueur.currencies.CurrencyArgumentChecks;
import fr.traqueur.currencies.CurrencyProvider;
+import fr.traqueur.currencies.TransactionResult;
import net.milkbowl.vault.economy.Economy;
+import net.milkbowl.vault.economy.EconomyResponse;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.plugin.RegisteredServiceProvider;
@@ -43,4 +46,46 @@ public BigDecimal getBalance(UUID playerId) {
OfflinePlayer offlinePlayer = Bukkit.getOfflinePlayer(playerId);
return BigDecimal.valueOf(this.getEconomy().getBalance(offlinePlayer));
}
+
+ @Override
+ public boolean hasNativeConditionalWithdraw() {
+ return true;
+ }
+
+ @Override
+ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) {
+ TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount);
+ if (invalid != null) {
+ return invalid;
+ }
+
+ try {
+ OfflinePlayer offlinePlayer = Bukkit.getOfflinePlayer(playerId);
+ Economy vaultEconomy = this.getEconomy();
+
+ EconomyResponse response = vaultEconomy.withdrawPlayer(offlinePlayer, amount.doubleValue());
+ if (response == null) {
+ return TransactionResult.failed(amount, "The Vault economy returned no response.");
+ }
+
+ if (response.type == EconomyResponse.ResponseType.SUCCESS) {
+ return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(response.balance));
+ }
+
+ if (response.type == EconomyResponse.ResponseType.NOT_IMPLEMENTED) {
+ return TransactionResult.unsupported(amount, "The Vault economy does not implement withdrawPlayer.");
+ }
+
+ BigDecimal balance = BigDecimal.valueOf(vaultEconomy.getBalance(offlinePlayer));
+ if (balance.compareTo(amount) < 0) {
+ return TransactionResult.nativeInsufficientFunds(amount, balance);
+ }
+
+ return TransactionResult.failed(amount, response.errorMessage == null
+ ? "The Vault economy refused the withdrawal."
+ : response.errorMessage);
+ } catch (Exception exception) {
+ return TransactionResult.failed(amount, "The Vault economy threw while withdrawing: " + exception.getMessage());
+ }
+ }
}
diff --git a/src/main/java/fr/traqueur/currencies/providers/VotingProvider.java b/src/main/java/fr/traqueur/currencies/providers/VotingProvider.java
index 2d299b1..44bca3b 100644
--- a/src/main/java/fr/traqueur/currencies/providers/VotingProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/VotingProvider.java
@@ -2,7 +2,10 @@
import com.bencodez.votingplugin.VotingPluginHooks;
import com.bencodez.votingplugin.user.UserManager;
+import com.bencodez.votingplugin.user.VotingPluginUser;
+import fr.traqueur.currencies.CurrencyArgumentChecks;
import fr.traqueur.currencies.CurrencyProvider;
+import fr.traqueur.currencies.TransactionResult;
import java.math.BigDecimal;
import java.util.UUID;
@@ -25,4 +28,33 @@ public void withdraw(UUID playerId, BigDecimal amount, String reason) {
public BigDecimal getBalance(UUID playerId) {
return BigDecimal.valueOf(this.userManager.getVotingPluginUser(playerId).getPoints());
}
+
+ @Override
+ public boolean hasNativeConditionalWithdraw() {
+ return true;
+ }
+
+ @Override
+ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) {
+ TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount);
+ if (invalid != null) {
+ return invalid;
+ }
+
+ try {
+ if (amount.stripTrailingZeros().scale() > 0) {
+ return TransactionResult.failed(amount, "VotingPlugin only supports whole amounts, got " + amount + ".");
+ }
+
+ VotingPluginUser user = this.userManager.getVotingPluginUser(playerId);
+ if (user.removePoints(amount.intValueExact())) {
+ return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(user.getPoints()));
+ }
+ return TransactionResult.nativeInsufficientFunds(amount, BigDecimal.valueOf(user.getPoints()));
+ } catch (ArithmeticException exception) {
+ return TransactionResult.failed(amount, "The amount does not fit in a VotingPlugin integer: " + amount + ".");
+ } catch (Exception exception) {
+ return TransactionResult.failed(amount, "VotingPlugin threw while withdrawing: " + exception.getMessage());
+ }
+ }
}
diff --git a/src/main/java/fr/traqueur/currencies/providers/ZEssentialsProvider.java b/src/main/java/fr/traqueur/currencies/providers/ZEssentialsProvider.java
index 949c502..839fd75 100644
--- a/src/main/java/fr/traqueur/currencies/providers/ZEssentialsProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/ZEssentialsProvider.java
@@ -3,7 +3,9 @@
import fr.maxlego08.essentials.api.EssentialsPlugin;
import fr.maxlego08.essentials.api.economy.Economy;
import fr.maxlego08.essentials.api.economy.EconomyManager;
+import fr.traqueur.currencies.CurrencyArgumentChecks;
import fr.traqueur.currencies.CurrencyProvider;
+import fr.traqueur.currencies.TransactionResult;
import org.bukkit.Bukkit;
import java.math.BigDecimal;
@@ -21,33 +23,57 @@ public ZEssentialsProvider(String economyName) {
}
private void initialize() {
- if (economyManager == null || economy == null) {
+ if (this.economyManager == null || this.economy == null) {
EssentialsPlugin essentialsPlugin = (EssentialsPlugin) Bukkit.getPluginManager().getPlugin("zEssentials");
+ assert essentialsPlugin != null : "zEssentials plugin not found";
this.economyManager = essentialsPlugin.getEconomyManager();
- Optional optional = economyManager.getEconomy(economyName);
+ Optional optional = this.economyManager.getEconomy(this.economyName);
if (optional.isPresent()) {
this.economy = optional.get();
} else {
- throw new NullPointerException("ZEssentials economy " + economyName + " not found");
+ throw new NullPointerException("ZEssentials economy " + this.economyName + " not found");
}
}
}
@Override
public void deposit(UUID playerId, BigDecimal amount, String reason) {
- initialize();
+ this.initialize();
this.economyManager.deposit(playerId, this.economy, amount, reason);
}
@Override
public void withdraw(UUID playerId, BigDecimal amount, String reason) {
- initialize();
+ this.initialize();
this.economyManager.withdraw(playerId, this.economy, amount, reason);
}
@Override
public BigDecimal getBalance(UUID playerId) {
- initialize();
+ this.initialize();
return this.economyManager.getBalance(Bukkit.getOfflinePlayer(playerId), this.economy);
}
+
+ @Override
+ public boolean hasNativeConditionalWithdraw() {
+ return true;
+ }
+
+ @Override
+ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) {
+ TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount);
+ if (invalid != null) {
+ return invalid;
+ }
+
+ try {
+ this.initialize();
+ if (this.economyManager.withdraw(playerId, this.economy, amount, reason)) {
+ return TransactionResult.nativeSuccess(amount, this.getBalance(playerId));
+ }
+ return TransactionResult.nativeInsufficientFunds(amount, this.getBalance(playerId));
+ } catch (Exception exception) {
+ return TransactionResult.failed(amount, "zEssentials threw while withdrawing: " + exception.getMessage());
+ }
+ }
}
From 541919573333ca85ace4745803b46305d3d94a3b Mon Sep 17 00:00:00 2001
From: 1robie <97293924+1robie@users.noreply.github.com>
Date: Fri, 11 Sep 2026 09:42:57 +0200
Subject: [PATCH 2/7] fix: Prevent setting plugin instance multiple times in
CurrenciesAPI
---
src/main/java/fr/traqueur/currencies/CurrenciesAPI.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java b/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java
index 02958c3..95e32f4 100644
--- a/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java
+++ b/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java
@@ -20,7 +20,7 @@ public static void init(Plugin owningPlugin) {
throw new IllegalArgumentException("The plugin instance cannot be null.");
}
if (plugin != null) {
- throw new IllegalStateException("The plugin instance has already been set by " + plugin.getName() + ".");
+ return;
}
plugin = owningPlugin;
}
From 80ffea376be97de91f89641956ede7f227c79fde Mon Sep 17 00:00:00 2001
From: 1robie <97293924+1robie@users.noreply.github.com>
Date: Fri, 11 Sep 2026 09:43:42 +0200
Subject: [PATCH 3/7] fix: Increase lock stripes and adjust timeout for main
thread in CurrencyLocks
---
src/main/java/fr/traqueur/currencies/CurrencyLocks.java | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/main/java/fr/traqueur/currencies/CurrencyLocks.java b/src/main/java/fr/traqueur/currencies/CurrencyLocks.java
index d62bf73..6beeaff 100644
--- a/src/main/java/fr/traqueur/currencies/CurrencyLocks.java
+++ b/src/main/java/fr/traqueur/currencies/CurrencyLocks.java
@@ -6,8 +6,9 @@
final class CurrencyLocks {
- private static final int STRIPES = 64;
+ private static final int STRIPES = 1024;
private static final long LOCK_TIMEOUT_MILLIS = 250L;
+ private static final long MAIN_THREAD_LOCK_TIMEOUT_MILLIS = 25L;
private static final ReentrantLock[] LOCKS = new ReentrantLock[STRIPES];
static {
@@ -39,8 +40,9 @@ static ReentrantLock lockFor(CurrencyProvider provider, UUID playerId) {
* @return True when the lock was acquired and must be released by the caller.
*/
static boolean tryLock(ReentrantLock lock) {
+ long timeout = CurrenciesAPI.isMainThread() ? MAIN_THREAD_LOCK_TIMEOUT_MILLIS : LOCK_TIMEOUT_MILLIS;
try {
- return lock.tryLock(LOCK_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+ return lock.tryLock(timeout, TimeUnit.MILLISECONDS);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return false;
From 8d927e21fbc5e709068227480848544e5b45a15a Mon Sep 17 00:00:00 2001
From: 1robie <97293924+1robie@users.noreply.github.com>
Date: Fri, 11 Sep 2026 09:44:28 +0200
Subject: [PATCH 4/7] feat: Add JUnit 5 dependencies and configure test task to
use JUnit platform
---
build.gradle.kts | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/build.gradle.kts b/build.gradle.kts
index a1c41b9..0950655 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -64,6 +64,16 @@ dependencies {
compileOnly(files("libs/CoinsEngine-2.4.2.jar"))
compileOnly(files("libs/nightcore-2.7.1.jar"))
compileOnly(files("libs/RoyaleEconomyAPI.jar"))
+
+ testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.2")
+ testImplementation("org.junit.jupiter:junit-jupiter-params:5.8.2")
+ testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.8.2")
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.8.2")
+ testCompileOnly("org.spigotmc:spigot-api:1.21.1-R0.1-SNAPSHOT")
+}
+
+tasks.test {
+ useJUnitPlatform()
}
val targetJavaVersion = 8
From 5ca28c2dfa313ec559557099757edacc9c45fa96 Mon Sep 17 00:00:00 2001
From: 1robie <97293924+1robie@users.noreply.github.com>
Date: Fri, 11 Sep 2026 09:57:27 +0200
Subject: [PATCH 5/7] feat: Enhance currency provider functionality with
guarantee levels and improved withdrawal handling
---
gradle.properties | 2 +-
readme.md | 74 +++++----
.../fr/traqueur/currencies/Currencies.java | 23 ++-
.../traqueur/currencies/CurrencyExecutor.java | 44 +++++
.../traqueur/currencies/CurrencyProvider.java | 31 ++--
.../traqueur/currencies/CurrencyRegistry.java | 52 +++++-
.../fr/traqueur/currencies/Guarantee.java | 49 ++++++
.../currencies/TransactionResult.java | 93 +++++------
.../providers/ExcellentEconomyProvider.java | 45 +++--
.../providers/ExperienceProvider.java | 14 +-
.../currencies/providers/ItemProvider.java | 9 +-
.../currencies/providers/LevelProvider.java | 9 +-
.../providers/PlayerPointsProvider.java | 9 +-
.../providers/RedisEconomyProvider.java | 9 +-
.../currencies/providers/VaultProvider.java | 9 +-
.../currencies/providers/VotingProvider.java | 9 +-
.../providers/ZEssentialsProvider.java | 15 +-
.../CurrencyArgumentChecksTest.java | 47 ++++++
.../fr/traqueur/currencies/FakeProvider.java | 65 ++++++++
.../currencies/TransactionResultTest.java | 63 +++++++
.../currencies/WithdrawIfSufficientTest.java | 156 ++++++++++++++++++
21 files changed, 664 insertions(+), 163 deletions(-)
create mode 100644 src/main/java/fr/traqueur/currencies/CurrencyExecutor.java
create mode 100644 src/main/java/fr/traqueur/currencies/Guarantee.java
create mode 100644 src/test/java/fr/traqueur/currencies/CurrencyArgumentChecksTest.java
create mode 100644 src/test/java/fr/traqueur/currencies/FakeProvider.java
create mode 100644 src/test/java/fr/traqueur/currencies/TransactionResultTest.java
create mode 100644 src/test/java/fr/traqueur/currencies/WithdrawIfSufficientTest.java
diff --git a/gradle.properties b/gradle.properties
index eef2451..1284b4e 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1 +1 @@
-version=1.0.15
\ No newline at end of file
+version=1.0.14
\ No newline at end of file
diff --git a/readme.md b/readme.md
index f2a1775..b57c38c 100644
--- a/readme.md
+++ b/readme.md
@@ -167,8 +167,10 @@ switch (result.getStatus()) {
}
```
-**Nothing is ever debited unless the status is `SUCCESS`.** That is the only case where you should
-hand out the goods.
+**Only hand out the goods on `SUCCESS`.** `INSUFFICIENT_FUNDS` and `UNSUPPORTED` never debit
+anything. `FAILED` normally does not either, but it cannot promise it: a backend that throws after
+it has already applied the withdrawal is indistinguishable from one that failed cleanly, so treat
+`FAILED` as "no goods, and worth logging" rather than as proof the balance is untouched.
An asynchronous variant is available and never completes exceptionally, failures come back through
the result:
@@ -178,41 +180,47 @@ Currencies.VAULT.withdrawIfSufficientAsync(playerId, amount, "default", "Shop pu
.thenAccept(result -> { /* ... */ });
```
-### Atomicity Per Backend
+### Guarantee Per Backend
-Some backends can refuse a withdrawal themselves, others cannot. When a backend cannot, the library
-emulates the operation by locking around a balance read and a withdraw. That stops one server racing
-itself, but it **cannot** stop a second server acting on the same shared economy.
+Backends differ in how strong a promise they can make, and it is not a yes or no question. Three
+levels, reported by `Guarantee`:
-Ask before you rely on it, either up front or from the result:
+| Level | Meaning |
+| --- | --- |
+| `NATIVE` | The backend validated the funds inside storage every server shares. Safe against a cross-server double spend. |
+| `DELEGATED` | The backend reported the outcome, but does not promise the check and the debit were indivisible. Trustworthy for one request, not a cross-server guarantee. |
+| `EMULATED` | This library did the check and the debit itself under a lock. Protects one server against racing itself only. |
+
+Ask up front, or read it off the result:
```java
-if (!Currencies.VAULT.hasNativeConditionalWithdraw("default")) {
- getLogger().warning("This currency cannot guarantee atomic purchases across servers.");
+if (!Currencies.VAULT.getWithdrawGuarantee("default").isCrossServerSafe()) {
+ getLogger().warning("This currency cannot guarantee purchases across servers.");
}
-result.isBackendGuaranteed(); // false when the library had to emulate the operation
+result.getGuarantee(); // NATIVE, DELEGATED or EMULATED
```
-| Currency | Atomic | Notes |
+| Currency | Guarantee | Notes |
| --- | --- | --- |
-| `VAULT` | yes | As atomic as the underlying economy plugin |
-| `PLAYERPOINTS` | yes | `take` refuses when the balance is too low |
-| `ZESSENTIALS` | yes | `withdraw` reports the outcome |
-| `REDISECONOMY` | yes | Validated in Redis, so it holds across servers |
-| `EXCELLENTECONOMY` | yes | Native async operation with a result |
-| `VOTINGPLUGIN` | yes | `removePoints` reports the outcome |
-| `ITEM`, `ZMENUITEMS` | yes | Player inventory, main thread only |
-| `LEVEL`, `EXPERIENCE` | yes | Player state, main thread only |
-| `COINSENGINE` | no | Its boolean means "currency found", not "could afford" |
-| `ECOBITS` | no | `adjustBalance` returns nothing |
-| `BEASTTOKENS` | no | `removeTokens` returns nothing |
-| `ROYALEECONOMY` | no | `removeBalance` returns nothing |
-| `ELEMENTALTOKENS`, `ELEMENTALGEMS` | no | `removeTokens` / `removeGems` return nothing |
-
-If you run a network where several servers share one economy database, only the currencies marked
-atomic are safe against cross-server double spends. For the others the fix has to come from the
-economy plugin itself.
+| `REDISECONOMY` | `NATIVE` | Validated in Redis, so it holds across servers |
+| `EXCELLENTECONOMY` | `NATIVE` | Native async operation with a result |
+| `ITEM`, `ZMENUITEMS` | `NATIVE` | Player inventory, local to this server, main thread only |
+| `LEVEL`, `EXPERIENCE` | `NATIVE` | Player state, local to this server, main thread only |
+| `VAULT` | `DELEGATED` | `withdrawPlayer` reports failure, but Vault delegates to whichever economy plugin is installed and most do a plain read-modify-write |
+| `ZESSENTIALS` | `DELEGATED` | `withdraw` returns a boolean, indivisibility is not promised |
+| `PLAYERPOINTS` | `DELEGATED` | `take` refuses when the balance is too low |
+| `VOTINGPLUGIN` | `DELEGATED` | `removePoints` reports the outcome |
+| `COINSENGINE` | `EMULATED` | Its boolean means "currency found", not "could afford" |
+| `ECOBITS` | `EMULATED` | `adjustBalance` returns nothing |
+| `BEASTTOKENS` | `EMULATED` | `removeTokens` returns nothing |
+| `ROYALEECONOMY` | `EMULATED` | `removeBalance` returns nothing |
+| `ELEMENTALTOKENS`, `ELEMENTALGEMS` | `EMULATED` | `removeTokens` / `removeGems` return nothing |
+
+If several servers share one economy database, only `NATIVE` is safe against a cross-server double
+spend. `DELEGATED` is the honest answer for Vault: it does tell you whether the withdrawal worked,
+which is strictly better than guessing, but the economy plugin behind it is usually not atomic. For
+`EMULATED` the fix has to come from the economy plugin itself.
### Custom Economies
@@ -233,10 +241,10 @@ TransactionResult result = CurrencyRegistry.withdrawIfSufficient(
```
When you override `withdrawIfSufficient`, build the result with the factory that matches who made
-the decision: `TransactionResult.nativeSuccess(...)` / `nativeInsufficientFunds(...)` when your
-backend checked the funds itself, or `emulatedSuccess(...)` / `emulatedInsufficientFunds(...)` when
-you checked them yourself. `unsupported(...)` and `failed(...)` cover the rest. That is what
-`isBackendGuaranteed()` reports back to the caller.
+the level your backend can actually promise: `TransactionResult.success(amount, balance, guarantee)`
+and `insufficientFunds(amount, balance, guarantee)`, passing `Guarantee.NATIVE`, `DELEGATED` or
+`EMULATED`. `unsupported(...)` and `failed(...)` cover the rest. That is what `getGuarantee()`
+reports back to the caller, so be honest about it.
To look a registered currency up, `CurrencyRegistry.require(name)` throws when there is none and
`CurrencyRegistry.find(name)` returns null. Use `registerOrReplace(...)` to deliberately swap an
@@ -245,7 +253,7 @@ implementation, for example on a config reload.
Those three methods are all you have to write. Everything else has a default implementation, so an
existing provider keeps working unchanged. Two optional overrides are worth knowing about:
-- `hasNativeConditionalWithdraw()` and `withdrawIfSufficient(...)`: override both when your backend can
+- `getWithdrawGuarantee()` and `withdrawIfSufficient(...)`: override both when your backend can
refuse a withdrawal itself. You get a real guarantee instead of the emulated one. Call
`CurrencyArgumentChecks.findProblem(playerId, amount)` first so your implementation rejects the same bad
inputs as every other provider.
diff --git a/src/main/java/fr/traqueur/currencies/Currencies.java b/src/main/java/fr/traqueur/currencies/Currencies.java
index 3ee3f34..b5573c4 100644
--- a/src/main/java/fr/traqueur/currencies/Currencies.java
+++ b/src/main/java/fr/traqueur/currencies/Currencies.java
@@ -2,6 +2,7 @@
import fr.traqueur.currencies.providers.*;
import org.bukkit.Bukkit;
+import org.jetbrains.annotations.NotNull;
import java.lang.reflect.Constructor;
import java.math.BigDecimal;
@@ -93,7 +94,7 @@ public enum Currencies {
EXCELLENTEECONOMY("ExcellentEconomy", ExcellentEconomyProvider.class, true, true, EXCELLENTECONOMY)
;
- private final static String DEFAULT_CURRENCY_NAME = "default";
+ final static String DEFAULT_CURRENCY_NAME = "default";
private final static String DEFAULT_REASON = "No reason";
static {
@@ -326,18 +327,24 @@ public CompletableFuture withdrawIfSufficientAsync(UUID playe
}
/**
- * Whether this currency can check the balance and apply the debit as one indivisible
- * operation, rather than having the library emulate it.
+ * Returns the provider backing this currency, creating it if necessary.
*
- * Worth checking on a network where several servers share one economy: an emulated
- * operation is only protected against races inside this server.
+ * Useful for inspecting a provider's capabilities, and the point
+ * {@link CurrencyRegistry#resolve(String, String)} bridges to so that a built-in currency and a
+ * custom one can be looked up the same way.
*
* @param currencyName The name of the currency.
- * @return True when the backend itself guarantees the operation.
+ * @return The provider.
*/
- public boolean hasNativeConditionalWithdraw(String currencyName) {
+ @NotNull
+ public CurrencyProvider getProvider(String currencyName) {
this.canBeUse(currencyName);
- return this.providers.get(currencyName).hasNativeConditionalWithdraw();
+ return this.providers.get(currencyName);
+ }
+
+ public Guarantee getWithdrawGuarantee(String currencyName) {
+ this.canBeUse(currencyName);
+ return this.providers.get(currencyName).getWithdrawGuarantee();
}
private void canBeUse(String currencyName) {
diff --git a/src/main/java/fr/traqueur/currencies/CurrencyExecutor.java b/src/main/java/fr/traqueur/currencies/CurrencyExecutor.java
new file mode 100644
index 0000000..2bdca2e
--- /dev/null
+++ b/src/main/java/fr/traqueur/currencies/CurrencyExecutor.java
@@ -0,0 +1,44 @@
+package fr.traqueur.currencies;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * The thread pool used by the asynchronous currency operations.
+ *
+ * Deliberately not the common {@link java.util.concurrent.ForkJoinPool}, which is what
+ * {@code CompletableFuture.supplyAsync} uses when no executor is given. That pool is shared with
+ * everything else in the JVM and sized for CPU bound work, so on a machine with few cores its
+ * parallelism can be as low as one. A withdrawal waiting on a SQL or Redis round trip would then
+ * block the whole queue, including work that has nothing to do with currencies.
+ *
+ * The threads are daemons, so they never hold the server open on shutdown, and they are named so
+ * that a thread dump from a user reporting lag is actually readable.
+ */
+final class CurrencyExecutor {
+ private static final int THREADS = Math.max(2, Math.min(8, Runtime.getRuntime().availableProcessors()));
+
+ private static final Executor EXECUTOR = Executors.newFixedThreadPool(THREADS, new ThreadFactory() {
+
+ private final AtomicInteger counter = new AtomicInteger();
+
+ @Override
+ public Thread newThread(Runnable runnable) {
+ Thread thread = new Thread(runnable, "CurrenciesAPI-async-" + this.counter.incrementAndGet());
+ thread.setDaemon(true);
+ return thread;
+ }
+ });
+
+ private CurrencyExecutor() {
+ }
+
+ /**
+ * @return The executor for asynchronous currency operations.
+ */
+ static Executor get() {
+ return EXECUTOR;
+ }
+}
diff --git a/src/main/java/fr/traqueur/currencies/CurrencyProvider.java b/src/main/java/fr/traqueur/currencies/CurrencyProvider.java
index 7afb949..7b01a81 100644
--- a/src/main/java/fr/traqueur/currencies/CurrencyProvider.java
+++ b/src/main/java/fr/traqueur/currencies/CurrencyProvider.java
@@ -41,17 +41,21 @@ public interface CurrencyProvider {
BigDecimal getBalance(UUID playerId);
/**
- * Whether this provider can check the balance and apply the debit as one indivisible
- * operation.
+ * How strong a promise this provider can make about {@link #withdrawIfSufficient}.
*
- * When this returns false, {@link #withdrawIfSufficient} still works but is emulated by
- * the library: the sequence is serialized inside this JVM, which stops one server racing
- * itself, but it cannot stop a second server acting on the same shared economy.
+ * Every provider supports the operation, this only says who guarantee it. The default is
+ * {@link Guarantee#EMULATED}, meaning the library performs the check and the debit itself under
+ * a lock, which stops one server racing itself but cannot stop a second server acting on the
+ * same shared economy.
*
- * @return True when the backend itself guarantees the operation.
+ * Override with {@link Guarantee#DELEGATED} when the backend reports the outcome of the
+ * withdrawal but does not promise the check and the debit were indivisible, and with
+ * {@link Guarantee#NATIVE} only when it validates inside storage that every server shares.
+ *
+ * @return The level of guarantee behind a conditional withdrawal.
*/
- default boolean hasNativeConditionalWithdraw() {
- return false;
+ default Guarantee getWithdrawGuarantee() {
+ return Guarantee.EMULATED;
}
/**
@@ -82,7 +86,8 @@ default boolean requiresMainThread() {
* The default implementation emulates the operation by reading the balance and then
* withdrawing, with the whole sequence held under a lock so that two threads cannot both
* observe the same balance and both debit. The returned result reports
- * {@link TransactionResult#isBackendGuaranteed()} as false to make that limitation visible.
+ * {@link TransactionResult#getGuarantee()} as {@link Guarantee#EMULATED} to make that limitation
+ * visible.
*
* @param playerId The UUID of the player to debit.
* @param amount The amount to debit, must be strictly positive.
@@ -98,7 +103,7 @@ default TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount,
ReentrantLock lock = CurrencyLocks.lockFor(this, playerId);
if (!CurrencyLocks.tryLock(lock)) {
- return TransactionResult.failed(amount, "Timed out waiting for a concurrent operation on the same balance.");
+ return TransactionResult.failed(amount, "Timed out waiting for the currency lock, nothing was taken.");
}
try {
@@ -108,11 +113,11 @@ default TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount,
}
if (balance.compareTo(amount) < 0) {
- return TransactionResult.emulatedInsufficientFunds(amount, balance);
+ return TransactionResult.insufficientFunds(amount, balance, Guarantee.EMULATED);
}
this.withdraw(playerId, amount, reason);
- return TransactionResult.emulatedSuccess(amount, balance.subtract(amount));
+ return TransactionResult.success(amount, balance.subtract(amount), Guarantee.EMULATED);
} catch (Exception exception) {
return TransactionResult.failed(amount, "The backend threw while withdrawing: " + exception.getMessage());
} finally {
@@ -140,7 +145,7 @@ default CompletableFuture withdrawIfSufficientAsync(UUID play
}
if (!this.requiresMainThread()) {
- return CompletableFuture.supplyAsync(() -> CurrencyProvider.this.withdrawIfSufficient(playerId, amount, reason));
+ return CompletableFuture.supplyAsync(() -> CurrencyProvider.this.withdrawIfSufficient(playerId, amount, reason), CurrencyExecutor.get());
}
if (CurrenciesAPI.isMainThread()) {
diff --git a/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java b/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java
index 27b2787..863a3ba 100644
--- a/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java
+++ b/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java
@@ -27,7 +27,7 @@
* A custom provider only has to implement {@code deposit}, {@code withdraw} and
* {@code getBalance}. It inherits an emulated {@link CurrencyProvider#withdrawIfSufficient} which
* is serialized inside this JVM. If the backend can refuse a withdrawal itself, override
- * {@code withdrawIfSufficient} and {@code hasNativeConditionalWithdraw} to get a real guarantee. If the
+ * {@code withdrawIfSufficient} and {@code getWithdrawGuarantee} to report a real guarantee. If the
* backend is safe to use off the main server thread, also override
* {@link CurrencyProvider#requiresMainThread()} to return false.
*/
@@ -47,10 +47,12 @@ private CurrencyRegistry() {
* @throws IllegalStateException if a different provider is already registered under this name.
*/
public static void register(String name, CurrencyProvider provider) {
- String key = normalize(name);
+ // Provider checked before the name, so register(null, null) reports the provider rather
+ // than blaming the name.
if (provider == null) {
throw new IllegalArgumentException("The provider cannot be null.");
}
+ String key = normalize(name);
CurrencyProvider existing = PROVIDERS.putIfAbsent(key, provider);
if (existing != null && existing != provider) {
@@ -66,11 +68,10 @@ public static void register(String name, CurrencyProvider provider) {
* @return The provider that was previously registered, or null.
*/
public static CurrencyProvider registerOrReplace(String name, CurrencyProvider provider) {
- String key = normalize(name);
if (provider == null) {
throw new IllegalArgumentException("The provider cannot be null.");
}
- return PROVIDERS.put(key, provider);
+ return PROVIDERS.put(normalize(name), provider);
}
/**
@@ -120,7 +121,12 @@ public static boolean isRegistered(String name) {
}
/**
- * @return The names of every registered custom currency. The returned set is a snapshot.
+ * The names of every registered custom currency.
+ *
+ * The names come back normalized, trimmed and lower cased, because that is the form
+ * lookups use. Registering {@code "MyGems"} and reading this back gives {@code "mygems"}.
+ *
+ * @return A snapshot of the registered names, in their normalized form.
*/
@NotNull
public static Set getRegisteredNames() {
@@ -154,6 +160,42 @@ public static CompletableFuture withdrawIfSufficientAsync(Str
return require(name).withdrawIfSufficientAsync(playerId, amount, reason);
}
+ /**
+ * Resolves a currency by name, whether it is one of the built in {@link Currencies} constants
+ * or a custom provider registered here.
+ *
+ * This is the bridge between the two. Without it a caller holding a currency name from a
+ * configuration file would have to know in advance which of the two mechanisms it belongs to,
+ * and there would be two unrelated ways to do the same thing.
+ *
+ * The built in constants win when a name matches both, so a custom registration cannot
+ * silently shadow {@code VAULT}. Note that a built in currency also needs a currency name for
+ * the economies that support several, which is why {@code currencyName} is separate: it is
+ * ignored for a custom provider, which is registered per currency already.
+ *
+ * @param name The currency name, either a {@link Currencies} constant or a registered custom name.
+ * @param currencyName The sub currency for a built in provider, or null for the default one.
+ * @return The provider backing that name.
+ * @throws IllegalStateException if the name matches neither.
+ */
+ @NotNull
+ public static CurrencyProvider resolve(String name, String currencyName) {
+ if (name != null) {
+ try {
+ Currencies currency = Currencies.fromName(name.trim().toUpperCase(java.util.Locale.ROOT));
+ return currency.getProvider(currencyName == null ? Currencies.DEFAULT_CURRENCY_NAME : currencyName);
+ } catch (IllegalArgumentException ignored) {
+ }
+ }
+
+ CurrencyProvider provider = find(name);
+ if (provider == null) {
+ throw new IllegalStateException("No currency named " + name + " is known, either as a built in "
+ + "Currencies constant or as a provider registered with CurrencyRegistry.register(name, provider).");
+ }
+ return provider;
+ }
+
private static String normalize(String name) {
if (name == null || name.trim().isEmpty()) {
throw new IllegalArgumentException("The currency name cannot be null or blank.");
diff --git a/src/main/java/fr/traqueur/currencies/Guarantee.java b/src/main/java/fr/traqueur/currencies/Guarantee.java
new file mode 100644
index 0000000..1222ed5
--- /dev/null
+++ b/src/main/java/fr/traqueur/currencies/Guarantee.java
@@ -0,0 +1,49 @@
+package fr.traqueur.currencies;
+
+/**
+ * How strong the promise behind a conditional currency operation actually is.
+ *
+ * A two state boolean was not enough to describe the backends honestly. Some of them validate
+ * the funds inside their own shared storage, some merely report an outcome without promising the
+ * check and the debit were indivisible, and for the rest this library has to emulate the operation.
+ * Collapsing the middle case into "guaranteed" told callers they were safe against a cross server
+ * double spend when they were not.
+ */
+public enum Guarantee {
+
+ /**
+ * The backend applied the check and the debit as one indivisible operation, inside storage that
+ * every server shares. This is the only level that is safe against a second server acting on
+ * the same balance at the same time.
+ */
+ NATIVE,
+
+ /**
+ * The backend reported whether the withdrawal succeeded, but does not promise that the check
+ * and the debit were indivisible.
+ *
+ * Vault is the clearest example: {@code withdrawPlayer} returns a response, but Vault is an
+ * abstraction over whichever economy plugin is installed, and most of them do a plain read,
+ * modify and write. The result is trustworthy for a single request, and it is better than an
+ * emulated check because the decision was made by the thing that owns the money, but it is not
+ * a cross server guarantee.
+ */
+ DELEGATED,
+
+ /**
+ * This library performed the check and the debit itself, serialized under a lock.
+ *
+ * Protects one server against racing itself. A second server sharing the same economy does
+ * not see that lock.
+ */
+ EMULATED;
+
+ /**
+ * Whether this level is safe when several servers share one economy.
+ *
+ * @return True only for {@link #NATIVE}.
+ */
+ public boolean isCrossServerSafe() {
+ return this == NATIVE;
+ }
+}
diff --git a/src/main/java/fr/traqueur/currencies/TransactionResult.java b/src/main/java/fr/traqueur/currencies/TransactionResult.java
index 99adf53..c2a5033 100644
--- a/src/main/java/fr/traqueur/currencies/TransactionResult.java
+++ b/src/main/java/fr/traqueur/currencies/TransactionResult.java
@@ -1,5 +1,8 @@
package fr.traqueur.currencies;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
import java.math.BigDecimal;
public final class TransactionResult {
@@ -24,9 +27,15 @@ public enum Status {
UNSUPPORTED,
/**
- * The operation could not be completed for any other reason, for example the
- * economy plugin returned an error or the player data could not be loaded.
- * Nothing was debited.
+ * The operation could not be completed for any other reason, for example the economy
+ * plugin returned an error or the player data could not be loaded.
+ *
+ * Nothing was debited in the ordinary case. The one exception worth knowing about is a
+ * backend that throws after it has already applied the withdrawal, for instance a
+ * committed transaction followed by an error on the way back. The library cannot tell that
+ * apart from a clean failure, so a caller handing out something valuable should treat a
+ * FAILED result as "no goods, and worth logging" rather than as proof the money is
+ * untouched.
*/
FAILED
}
@@ -35,65 +44,38 @@ public enum Status {
private final BigDecimal amount;
private final BigDecimal balance;
private final String errorMessage;
- private final boolean backendGuaranteed;
+ private final Guarantee guarantee;
- private TransactionResult(Status status, BigDecimal amount, BigDecimal balance, String errorMessage, boolean backendGuaranteed) {
+ private TransactionResult(Status status, BigDecimal amount, BigDecimal balance, String errorMessage, Guarantee guarantee) {
this.status = status;
this.amount = amount == null ? BigDecimal.ZERO : amount;
this.balance = balance;
this.errorMessage = errorMessage;
- this.backendGuaranteed = backendGuaranteed;
- }
-
- /**
- * Build a successful result for a backend that applied the check and the debit itself.
- *
- * Use this from a provider that overrides
- * {@link CurrencyProvider#withdrawIfSufficient(java.util.UUID, BigDecimal, String)} because its
- * backend can refuse a withdrawal on its own.
- *
- * @param amount The amount that was debited.
- * @param balance The resulting balance, or null when the backend does not report it.
- * @return The result.
- */
- public static TransactionResult nativeSuccess(BigDecimal amount, BigDecimal balance) {
- return new TransactionResult(Status.SUCCESS, amount, balance, null, true);
- }
-
- /**
- * Build a successful result for an operation the library emulated with a balance read followed
- * by a withdraw.
- *
- * @param amount The amount that was debited.
- * @param balance The resulting balance, or null when it is not known.
- * @return The result.
- */
- public static TransactionResult emulatedSuccess(BigDecimal amount, BigDecimal balance) {
- return new TransactionResult(Status.SUCCESS, amount, balance, null, false);
+ this.guarantee = guarantee;
}
/**
- * Build an insufficient funds result for a backend that made the decision itself. Nothing was
- * debited.
+ * Build a successful result.
*
- * @param amount The amount that was requested.
- * @param balance The balance that was observed, or null when it is not known.
+ * @param amount The amount that was debited.
+ * @param balance The resulting balance, or null when it is not known.
+ * @param guarantee How strong the promise behind the operation is.
* @return The result.
*/
- public static TransactionResult nativeInsufficientFunds(BigDecimal amount, BigDecimal balance) {
- return new TransactionResult(Status.INSUFFICIENT_FUNDS, amount, balance, null, true);
+ public static TransactionResult success(BigDecimal amount, BigDecimal balance, Guarantee guarantee) {
+ return new TransactionResult(Status.SUCCESS, amount, balance, null, guarantee);
}
/**
- * Build an insufficient funds result for a check the library performed itself. Nothing was
- * debited.
+ * Build a result for a player who could not afford the amount. Nothing was debited.
*
- * @param amount The amount that was requested.
- * @param balance The balance that was observed, or null when it is not known.
+ * @param amount The amount that was requested.
+ * @param balance The balance that was observed, or null when it is not known.
+ * @param guarantee How strong the promise behind the check is.
* @return The result.
*/
- public static TransactionResult emulatedInsufficientFunds(BigDecimal amount, BigDecimal balance) {
- return new TransactionResult(Status.INSUFFICIENT_FUNDS, amount, balance, null, false);
+ public static TransactionResult insufficientFunds(BigDecimal amount, BigDecimal balance, Guarantee guarantee) {
+ return new TransactionResult(Status.INSUFFICIENT_FUNDS, amount, balance, null, guarantee);
}
/**
@@ -104,7 +86,7 @@ public static TransactionResult emulatedInsufficientFunds(BigDecimal amount, Big
* @return The result.
*/
public static TransactionResult unsupported(BigDecimal amount, String errorMessage) {
- return new TransactionResult(Status.UNSUPPORTED, amount, null, errorMessage, false);
+ return new TransactionResult(Status.UNSUPPORTED, amount, null, errorMessage, Guarantee.EMULATED);
}
/**
@@ -115,12 +97,13 @@ public static TransactionResult unsupported(BigDecimal amount, String errorMessa
* @return The result.
*/
public static TransactionResult failed(BigDecimal amount, String errorMessage) {
- return new TransactionResult(Status.FAILED, amount, null, errorMessage, false);
+ return new TransactionResult(Status.FAILED, amount, null, errorMessage, Guarantee.EMULATED);
}
/**
* @return The outcome of the operation.
*/
+ @NotNull
public Status getStatus() {
return this.status;
}
@@ -133,17 +116,19 @@ public boolean isSuccess() {
}
/**
- * @return True when the backend guaranteed that the check and the debit were indivisible.
- * False means the library emulated the operation and it is only safe against concurrent
- * access from inside this server.
+ * @return How strong the promise behind this operation was. Use
+ * {@link Guarantee#isCrossServerSafe()} to decide whether it holds on a network where several
+ * servers share one economy.
*/
- public boolean isBackendGuaranteed() {
- return this.backendGuaranteed;
+ @NotNull
+ public Guarantee getGuarantee() {
+ return this.guarantee;
}
/**
* @return The amount that was requested.
*/
+ @NotNull
public BigDecimal getAmount() {
return this.amount;
}
@@ -151,6 +136,7 @@ public BigDecimal getAmount() {
/**
* @return The resulting balance, or null when the backend does not report one.
*/
+ @Nullable
public BigDecimal getBalance() {
return this.balance;
}
@@ -158,6 +144,7 @@ public BigDecimal getBalance() {
/**
* @return A human-readable explanation for a failure, or null.
*/
+ @Nullable
public String getErrorMessage() {
return this.errorMessage;
}
@@ -167,7 +154,7 @@ public String toString() {
return "TransactionResult{status=" + this.status
+ ", amount=" + this.amount
+ ", balance=" + this.balance
- + ", backendGuaranteed=" + this.backendGuaranteed
+ + ", guarantee=" + this.guarantee
+ (this.errorMessage == null ? "" : ", error='" + this.errorMessage + "'")
+ '}';
}
diff --git a/src/main/java/fr/traqueur/currencies/providers/ExcellentEconomyProvider.java b/src/main/java/fr/traqueur/currencies/providers/ExcellentEconomyProvider.java
index 4116f7e..95faece 100644
--- a/src/main/java/fr/traqueur/currencies/providers/ExcellentEconomyProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/ExcellentEconomyProvider.java
@@ -2,6 +2,7 @@
import fr.traqueur.currencies.CurrencyArgumentChecks;
import fr.traqueur.currencies.CurrencyProvider;
+import fr.traqueur.currencies.Guarantee;
import fr.traqueur.currencies.TransactionResult;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
@@ -59,9 +60,18 @@ public BigDecimal getBalance(UUID playerId) {
return BigDecimal.valueOf(raw);
}
+ private BigDecimal getBalanceWithoutBukkit(UUID playerId) {
+ try {
+ Double raw = this.api.getBalanceAsync(playerId, this.currencyName).join();
+ return raw == null ? null : BigDecimal.valueOf(raw);
+ } catch (Exception exception) {
+ return null;
+ }
+ }
+
@Override
- public boolean hasNativeConditionalWithdraw() {
- return true;
+ public Guarantee getWithdrawGuarantee() {
+ return Guarantee.NATIVE;
}
@Override
@@ -73,20 +83,20 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount,
try {
OperationContext ctx = OperationContext.custom(reason);
- Player player = Bukkit.getPlayer(playerId);
-
- boolean success;
- if (player != null) {
- success = this.api.withdraw(player, this.currencyName, amount.doubleValue(), ctx);
- } else {
- OperationResult result = this.api.withdrawAsync(playerId, this.currencyName, amount.doubleValue(), ctx).join();
- success = result != null && result.success();
+
+ OperationResult result = this.api.withdrawAsync(playerId, this.currencyName, amount.doubleValue(), ctx).join();
+
+ if (result != null && result.success()) {
+ return TransactionResult.success(amount, null, Guarantee.NATIVE);
}
- if (success) {
- return TransactionResult.nativeSuccess(amount, this.getBalance(playerId));
+ BigDecimal balance = this.getBalanceWithoutBukkit(playerId);
+ if (balance != null && balance.compareTo(amount) < 0) {
+ return TransactionResult.insufficientFunds(amount, balance, Guarantee.NATIVE);
}
- return TransactionResult.nativeInsufficientFunds(amount, this.getBalance(playerId));
+
+ return TransactionResult.failed(amount, "ExcellentEconomy refused the withdrawal and the player could afford it, "
+ + "so the currency " + this.currencyName + " may be unknown or the player data may not be loaded.");
} catch (Exception exception) {
return TransactionResult.failed(amount, "ExcellentEconomy threw while withdrawing: " + exception.getMessage());
}
@@ -107,9 +117,14 @@ public CompletableFuture withdrawIfSufficientAsync(UUID playe
return TransactionResult.failed(amount, "ExcellentEconomy threw while withdrawing: " + throwable.getMessage());
}
if (result != null && result.success()) {
- return TransactionResult.nativeSuccess(amount, null);
+ return TransactionResult.success(amount, null, Guarantee.NATIVE);
+ }
+ BigDecimal balance = this.getBalanceWithoutBukkit(playerId);
+ if (balance != null && balance.compareTo(amount) < 0) {
+ return TransactionResult.insufficientFunds(amount, balance, Guarantee.NATIVE);
}
- return TransactionResult.nativeInsufficientFunds(amount, null);
+ return TransactionResult.failed(amount, "ExcellentEconomy refused the withdrawal and the player could afford it, "
+ + "so the currency " + this.currencyName + " may be unknown or the player data may not be loaded.");
});
} catch (Exception exception) {
return CompletableFuture.completedFuture(
diff --git a/src/main/java/fr/traqueur/currencies/providers/ExperienceProvider.java b/src/main/java/fr/traqueur/currencies/providers/ExperienceProvider.java
index 47c8afc..e779f73 100644
--- a/src/main/java/fr/traqueur/currencies/providers/ExperienceProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/ExperienceProvider.java
@@ -2,9 +2,9 @@
import fr.traqueur.currencies.CurrencyArgumentChecks;
import fr.traqueur.currencies.CurrencyProvider;
+import fr.traqueur.currencies.Guarantee;
import fr.traqueur.currencies.TransactionResult;
import org.bukkit.Bukkit;
-import org.bukkit.OfflinePlayer;
import org.bukkit.entity.Player;
import java.math.BigDecimal;
@@ -80,8 +80,8 @@ private int getTotalExperience(Player player) {
}
@Override
- public boolean hasNativeConditionalWithdraw() {
- return true;
+ public Guarantee getWithdrawGuarantee() {
+ return Guarantee.NATIVE;
}
@Override
@@ -96,13 +96,17 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount,
return TransactionResult.failed(amount, "Experience can only be taken from an online player.");
}
+ if (amount.stripTrailingZeros().scale() > 0) {
+ return TransactionResult.failed(amount, "Experience only supports whole amounts, got " + amount + ".");
+ }
+
BigDecimal current = BigDecimal.valueOf(this.getTotalExperience(player));
if (current.compareTo(amount) < 0) {
- return TransactionResult.nativeInsufficientFunds(amount, current);
+ return TransactionResult.insufficientFunds(amount, current, Guarantee.NATIVE);
}
BigDecimal remaining = current.subtract(amount);
this.setTotalExperience(player, remaining.intValue());
- return TransactionResult.nativeSuccess(amount, remaining);
+ return TransactionResult.success(amount, remaining, Guarantee.NATIVE);
}
}
diff --git a/src/main/java/fr/traqueur/currencies/providers/ItemProvider.java b/src/main/java/fr/traqueur/currencies/providers/ItemProvider.java
index a71d442..a8603ca 100644
--- a/src/main/java/fr/traqueur/currencies/providers/ItemProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/ItemProvider.java
@@ -2,6 +2,7 @@
import fr.traqueur.currencies.CurrencyArgumentChecks;
import fr.traqueur.currencies.CurrencyProvider;
+import fr.traqueur.currencies.Guarantee;
import fr.traqueur.currencies.TransactionResult;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
@@ -119,8 +120,8 @@ private boolean hasInventoryFull(Player player) {
}
@Override
- public boolean hasNativeConditionalWithdraw() {
- return true;
+ public Guarantee getWithdrawGuarantee() {
+ return Guarantee.NATIVE;
}
@Override
@@ -153,10 +154,10 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount,
int held = this.getAmount(player, currencyItem);
if (held < cost) {
- return TransactionResult.nativeInsufficientFunds(amount, BigDecimal.valueOf(held));
+ return TransactionResult.insufficientFunds(amount, BigDecimal.valueOf(held), Guarantee.NATIVE);
}
this.removeItems(player, currencyItem, cost);
- return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(held - cost));
+ return TransactionResult.success(amount, BigDecimal.valueOf(held - cost), Guarantee.NATIVE);
}
}
diff --git a/src/main/java/fr/traqueur/currencies/providers/LevelProvider.java b/src/main/java/fr/traqueur/currencies/providers/LevelProvider.java
index 3e60725..56fbb52 100644
--- a/src/main/java/fr/traqueur/currencies/providers/LevelProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/LevelProvider.java
@@ -2,6 +2,7 @@
import fr.traqueur.currencies.CurrencyArgumentChecks;
import fr.traqueur.currencies.CurrencyProvider;
+import fr.traqueur.currencies.Guarantee;
import fr.traqueur.currencies.TransactionResult;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
@@ -36,8 +37,8 @@ public BigDecimal getBalance(UUID playerId) {
}
@Override
- public boolean hasNativeConditionalWithdraw() {
- return true;
+ public Guarantee getWithdrawGuarantee() {
+ return Guarantee.NATIVE;
}
@Override
@@ -65,10 +66,10 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount,
int current = player.getLevel();
if (current < cost) {
- return TransactionResult.nativeInsufficientFunds(amount, BigDecimal.valueOf(current));
+ return TransactionResult.insufficientFunds(amount, BigDecimal.valueOf(current), Guarantee.NATIVE);
}
player.setLevel(current - cost);
- return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(current - cost));
+ return TransactionResult.success(amount, BigDecimal.valueOf(current - cost), Guarantee.NATIVE);
}
}
diff --git a/src/main/java/fr/traqueur/currencies/providers/PlayerPointsProvider.java b/src/main/java/fr/traqueur/currencies/providers/PlayerPointsProvider.java
index 8b10053..80baa58 100644
--- a/src/main/java/fr/traqueur/currencies/providers/PlayerPointsProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/PlayerPointsProvider.java
@@ -2,6 +2,7 @@
import fr.traqueur.currencies.CurrencyArgumentChecks;
import fr.traqueur.currencies.CurrencyProvider;
+import fr.traqueur.currencies.Guarantee;
import fr.traqueur.currencies.TransactionResult;
import org.black_ixx.playerpoints.PlayerPoints;
import org.black_ixx.playerpoints.PlayerPointsAPI;
@@ -39,8 +40,8 @@ public BigDecimal getBalance(UUID playerId) {
}
@Override
- public boolean hasNativeConditionalWithdraw() {
- return true;
+ public Guarantee getWithdrawGuarantee() {
+ return Guarantee.DELEGATED;
}
@Override
@@ -57,10 +58,10 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount,
int points = amount.intValueExact();
if (this.getAPI().take(playerId, points)) {
- return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(this.getAPI().look(playerId)));
+ return TransactionResult.success(amount, BigDecimal.valueOf(this.getAPI().look(playerId)), Guarantee.DELEGATED);
}
- return TransactionResult.nativeInsufficientFunds(amount, BigDecimal.valueOf(this.getAPI().look(playerId)));
+ return TransactionResult.insufficientFunds(amount, BigDecimal.valueOf(this.getAPI().look(playerId)), Guarantee.DELEGATED);
} catch (ArithmeticException exception) {
return TransactionResult.failed(amount, "The amount does not fit in a PlayerPoints integer: " + amount + ".");
} catch (Exception exception) {
diff --git a/src/main/java/fr/traqueur/currencies/providers/RedisEconomyProvider.java b/src/main/java/fr/traqueur/currencies/providers/RedisEconomyProvider.java
index c7074e3..e5b14c7 100644
--- a/src/main/java/fr/traqueur/currencies/providers/RedisEconomyProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/RedisEconomyProvider.java
@@ -4,6 +4,7 @@
import dev.unnm3d.rediseconomy.currency.Currency;
import fr.traqueur.currencies.CurrencyArgumentChecks;
import fr.traqueur.currencies.CurrencyProvider;
+import fr.traqueur.currencies.Guarantee;
import fr.traqueur.currencies.TransactionResult;
import net.milkbowl.vault.economy.EconomyResponse;
import org.bukkit.Bukkit;
@@ -62,8 +63,8 @@ public BigDecimal getBalance(UUID playerId) {
}
@Override
- public boolean hasNativeConditionalWithdraw() {
- return true;
+ public Guarantee getWithdrawGuarantee() {
+ return Guarantee.NATIVE;
}
@Override
@@ -86,7 +87,7 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount,
}
if (response.type == EconomyResponse.ResponseType.SUCCESS) {
- return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(response.balance));
+ return TransactionResult.success(amount, BigDecimal.valueOf(response.balance), Guarantee.NATIVE);
}
if (response.type == EconomyResponse.ResponseType.NOT_IMPLEMENTED) {
@@ -94,7 +95,7 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount,
}
if (!currency.has(playerId, amount.doubleValue())) {
- return TransactionResult.nativeInsufficientFunds(amount, BigDecimal.valueOf(currency.getBalance(playerId)));
+ return TransactionResult.insufficientFunds(amount, BigDecimal.valueOf(currency.getBalance(playerId)), Guarantee.NATIVE);
}
return TransactionResult.failed(amount, response.errorMessage == null
diff --git a/src/main/java/fr/traqueur/currencies/providers/VaultProvider.java b/src/main/java/fr/traqueur/currencies/providers/VaultProvider.java
index 5187e31..13f1c04 100644
--- a/src/main/java/fr/traqueur/currencies/providers/VaultProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/VaultProvider.java
@@ -2,6 +2,7 @@
import fr.traqueur.currencies.CurrencyArgumentChecks;
import fr.traqueur.currencies.CurrencyProvider;
+import fr.traqueur.currencies.Guarantee;
import fr.traqueur.currencies.TransactionResult;
import net.milkbowl.vault.economy.Economy;
import net.milkbowl.vault.economy.EconomyResponse;
@@ -48,8 +49,8 @@ public BigDecimal getBalance(UUID playerId) {
}
@Override
- public boolean hasNativeConditionalWithdraw() {
- return true;
+ public Guarantee getWithdrawGuarantee() {
+ return Guarantee.DELEGATED;
}
@Override
@@ -69,7 +70,7 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount,
}
if (response.type == EconomyResponse.ResponseType.SUCCESS) {
- return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(response.balance));
+ return TransactionResult.success(amount, BigDecimal.valueOf(response.balance), Guarantee.DELEGATED);
}
if (response.type == EconomyResponse.ResponseType.NOT_IMPLEMENTED) {
@@ -78,7 +79,7 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount,
BigDecimal balance = BigDecimal.valueOf(vaultEconomy.getBalance(offlinePlayer));
if (balance.compareTo(amount) < 0) {
- return TransactionResult.nativeInsufficientFunds(amount, balance);
+ return TransactionResult.insufficientFunds(amount, balance, Guarantee.DELEGATED);
}
return TransactionResult.failed(amount, response.errorMessage == null
diff --git a/src/main/java/fr/traqueur/currencies/providers/VotingProvider.java b/src/main/java/fr/traqueur/currencies/providers/VotingProvider.java
index 44bca3b..c6b029d 100644
--- a/src/main/java/fr/traqueur/currencies/providers/VotingProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/VotingProvider.java
@@ -5,6 +5,7 @@
import com.bencodez.votingplugin.user.VotingPluginUser;
import fr.traqueur.currencies.CurrencyArgumentChecks;
import fr.traqueur.currencies.CurrencyProvider;
+import fr.traqueur.currencies.Guarantee;
import fr.traqueur.currencies.TransactionResult;
import java.math.BigDecimal;
@@ -30,8 +31,8 @@ public BigDecimal getBalance(UUID playerId) {
}
@Override
- public boolean hasNativeConditionalWithdraw() {
- return true;
+ public Guarantee getWithdrawGuarantee() {
+ return Guarantee.DELEGATED;
}
@Override
@@ -48,9 +49,9 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount,
VotingPluginUser user = this.userManager.getVotingPluginUser(playerId);
if (user.removePoints(amount.intValueExact())) {
- return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(user.getPoints()));
+ return TransactionResult.success(amount, BigDecimal.valueOf(user.getPoints()), Guarantee.DELEGATED);
}
- return TransactionResult.nativeInsufficientFunds(amount, BigDecimal.valueOf(user.getPoints()));
+ return TransactionResult.insufficientFunds(amount, BigDecimal.valueOf(user.getPoints()), Guarantee.DELEGATED);
} catch (ArithmeticException exception) {
return TransactionResult.failed(amount, "The amount does not fit in a VotingPlugin integer: " + amount + ".");
} catch (Exception exception) {
diff --git a/src/main/java/fr/traqueur/currencies/providers/ZEssentialsProvider.java b/src/main/java/fr/traqueur/currencies/providers/ZEssentialsProvider.java
index 839fd75..3ae0dc7 100644
--- a/src/main/java/fr/traqueur/currencies/providers/ZEssentialsProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/ZEssentialsProvider.java
@@ -5,6 +5,7 @@
import fr.maxlego08.essentials.api.economy.EconomyManager;
import fr.traqueur.currencies.CurrencyArgumentChecks;
import fr.traqueur.currencies.CurrencyProvider;
+import fr.traqueur.currencies.Guarantee;
import fr.traqueur.currencies.TransactionResult;
import org.bukkit.Bukkit;
@@ -25,13 +26,15 @@ public ZEssentialsProvider(String economyName) {
private void initialize() {
if (this.economyManager == null || this.economy == null) {
EssentialsPlugin essentialsPlugin = (EssentialsPlugin) Bukkit.getPluginManager().getPlugin("zEssentials");
- assert essentialsPlugin != null : "zEssentials plugin not found";
+ if (essentialsPlugin == null) {
+ throw new IllegalStateException("The plugin zEssentials is not installed.");
+ }
this.economyManager = essentialsPlugin.getEconomyManager();
Optional optional = this.economyManager.getEconomy(this.economyName);
if (optional.isPresent()) {
this.economy = optional.get();
} else {
- throw new NullPointerException("ZEssentials economy " + this.economyName + " not found");
+ throw new IllegalStateException("The zEssentials economy " + this.economyName + " was not found.");
}
}
}
@@ -55,8 +58,8 @@ public BigDecimal getBalance(UUID playerId) {
}
@Override
- public boolean hasNativeConditionalWithdraw() {
- return true;
+ public Guarantee getWithdrawGuarantee() {
+ return Guarantee.DELEGATED;
}
@Override
@@ -69,9 +72,9 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount,
try {
this.initialize();
if (this.economyManager.withdraw(playerId, this.economy, amount, reason)) {
- return TransactionResult.nativeSuccess(amount, this.getBalance(playerId));
+ return TransactionResult.success(amount, this.getBalance(playerId), Guarantee.DELEGATED);
}
- return TransactionResult.nativeInsufficientFunds(amount, this.getBalance(playerId));
+ return TransactionResult.insufficientFunds(amount, this.getBalance(playerId), Guarantee.DELEGATED);
} catch (Exception exception) {
return TransactionResult.failed(amount, "zEssentials threw while withdrawing: " + exception.getMessage());
}
diff --git a/src/test/java/fr/traqueur/currencies/CurrencyArgumentChecksTest.java b/src/test/java/fr/traqueur/currencies/CurrencyArgumentChecksTest.java
new file mode 100644
index 0000000..55a4b4b
--- /dev/null
+++ b/src/test/java/fr/traqueur/currencies/CurrencyArgumentChecksTest.java
@@ -0,0 +1,47 @@
+package fr.traqueur.currencies;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+class CurrencyArgumentChecksTest {
+
+ @Test
+ @DisplayName("usable arguments report no problem")
+ void usableArgumentsReportNoProblem() {
+ assertNull(CurrencyArgumentChecks.findProblem(UUID.randomUUID(), BigDecimal.TEN));
+ }
+
+ @Test
+ @DisplayName("a null player is a problem")
+ void nullPlayerIsAProblem() {
+ TransactionResult problem = CurrencyArgumentChecks.findProblem(null, BigDecimal.TEN);
+ assertNotNull(problem);
+ assertSame(TransactionResult.Status.FAILED, problem.getStatus());
+ }
+
+ @Test
+ @DisplayName("a null amount is a problem")
+ void nullAmountIsAProblem() {
+ TransactionResult problem = CurrencyArgumentChecks.findProblem(UUID.randomUUID(), null);
+ assertNotNull(problem);
+ assertSame(TransactionResult.Status.FAILED, problem.getStatus());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"0", "-0.01", "-1", "-1000000"})
+ @DisplayName("a zero or negative amount is a problem")
+ void nonPositiveAmountIsAProblem(String amount) {
+ TransactionResult problem = CurrencyArgumentChecks.findProblem(UUID.randomUUID(), new BigDecimal(amount));
+ assertNotNull(problem, amount + " should be refused");
+ assertSame(TransactionResult.Status.FAILED, problem.getStatus());
+ }
+}
diff --git a/src/test/java/fr/traqueur/currencies/FakeProvider.java b/src/test/java/fr/traqueur/currencies/FakeProvider.java
new file mode 100644
index 0000000..8d3947d
--- /dev/null
+++ b/src/test/java/fr/traqueur/currencies/FakeProvider.java
@@ -0,0 +1,65 @@
+package fr.traqueur.currencies;
+
+import java.math.BigDecimal;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * An in-memory provider used to exercise the emulated {@link CurrencyProvider#withdrawIfSufficient}
+ * without a server.
+ *
+ * {@link #withdraw} deliberately reads the balance, pauses, then writes the result back. That is
+ * how a real backend behaves when the write goes over a network, and it is what makes an
+ * unsynchronized check-then-withdraw lose updates. Without the pause the race almost never shows up
+ * and the test would pass for the wrong reason.
+ */
+final class FakeProvider implements CurrencyProvider {
+
+ private final Map balances = new ConcurrentHashMap<>();
+ private final AtomicInteger withdrawCalls = new AtomicInteger();
+ private final long writeDelayMillis;
+
+ FakeProvider(long writeDelayMillis) {
+ this.writeDelayMillis = writeDelayMillis;
+ }
+
+ @Override
+ public void deposit(UUID playerId, BigDecimal amount, String reason) {
+ this.balances.merge(playerId, amount, BigDecimal::add);
+ }
+
+ @Override
+ public void withdraw(UUID playerId, BigDecimal amount, String reason) {
+ this.withdrawCalls.incrementAndGet();
+ BigDecimal current = this.getBalance(playerId);
+ if (this.writeDelayMillis > 0) {
+ try {
+ Thread.sleep(this.writeDelayMillis);
+ } catch (InterruptedException exception) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ this.balances.put(playerId, current.subtract(amount));
+ }
+
+ @Override
+ public BigDecimal getBalance(UUID playerId) {
+ BigDecimal balance = this.balances.get(playerId);
+ return balance == null ? BigDecimal.ZERO : balance;
+ }
+
+ /**
+ * False so the tests exercise the real asynchronous path instead of the main thread hop, which
+ * would need a running server.
+ */
+ @Override
+ public boolean requiresMainThread() {
+ return false;
+ }
+
+ int getWithdrawCalls() {
+ return this.withdrawCalls.get();
+ }
+}
diff --git a/src/test/java/fr/traqueur/currencies/TransactionResultTest.java b/src/test/java/fr/traqueur/currencies/TransactionResultTest.java
new file mode 100644
index 0000000..2b212bd
--- /dev/null
+++ b/src/test/java/fr/traqueur/currencies/TransactionResultTest.java
@@ -0,0 +1,63 @@
+package fr.traqueur.currencies;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class TransactionResultTest {
+
+ @Test
+ @DisplayName("success is the only status that reports isSuccess")
+ void onlySuccessIsSuccessful() {
+ assertTrue(TransactionResult.success(BigDecimal.TEN, BigDecimal.ONE, Guarantee.NATIVE).isSuccess());
+ assertFalse(TransactionResult.insufficientFunds(BigDecimal.TEN, BigDecimal.ONE, Guarantee.NATIVE).isSuccess());
+ assertFalse(TransactionResult.unsupported(BigDecimal.TEN, "nope").isSuccess());
+ assertFalse(TransactionResult.failed(BigDecimal.TEN, "boom").isSuccess());
+ }
+
+ @Test
+ @DisplayName("the guarantee is carried through unchanged")
+ void guaranteeIsCarriedThrough() {
+ for (Guarantee guarantee : Guarantee.values()) {
+ assertSame(guarantee, TransactionResult.success(BigDecimal.TEN, null, guarantee).getGuarantee());
+ assertSame(guarantee, TransactionResult.insufficientFunds(BigDecimal.TEN, null, guarantee).getGuarantee());
+ }
+ }
+
+ @Test
+ @DisplayName("only NATIVE is cross-server safe")
+ void onlyNativeIsCrossServerSafe() {
+ assertTrue(Guarantee.NATIVE.isCrossServerSafe());
+ assertFalse(Guarantee.DELEGATED.isCrossServerSafe(), "delegated does not promise indivisibility");
+ assertFalse(Guarantee.EMULATED.isCrossServerSafe(), "emulated only covers this JVM");
+ }
+
+ @Test
+ @DisplayName("a null amount is normalised to zero rather than kept null")
+ void nullAmountBecomesZero() {
+ assertEquals(0, TransactionResult.failed(null, "boom").getAmount().compareTo(BigDecimal.ZERO));
+ }
+
+ @Test
+ @DisplayName("an unreported balance stays null instead of being invented")
+ void unreportedBalanceStaysNull() {
+ assertNull(TransactionResult.success(BigDecimal.TEN, null, Guarantee.NATIVE).getBalance());
+ assertNull(TransactionResult.failed(BigDecimal.TEN, "boom").getBalance());
+ }
+
+ @Test
+ @DisplayName("failure statuses carry an explanation")
+ void failuresCarryAnExplanation() {
+ assertNotNull(TransactionResult.unsupported(BigDecimal.TEN, "not implemented").getErrorMessage());
+ assertNotNull(TransactionResult.failed(BigDecimal.TEN, "boom").getErrorMessage());
+ assertNull(TransactionResult.success(BigDecimal.TEN, null, Guarantee.NATIVE).getErrorMessage());
+ }
+}
diff --git a/src/test/java/fr/traqueur/currencies/WithdrawIfSufficientTest.java b/src/test/java/fr/traqueur/currencies/WithdrawIfSufficientTest.java
new file mode 100644
index 0000000..90e3c35
--- /dev/null
+++ b/src/test/java/fr/traqueur/currencies/WithdrawIfSufficientTest.java
@@ -0,0 +1,156 @@
+package fr.traqueur.currencies;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests the emulated conditional withdrawal, which is the whole point of this feature: a provider
+ * that cannot refuse a withdrawal itself must still not allow a double spend on one server.
+ */
+class WithdrawIfSufficientTest {
+
+ private static final String REASON = "test";
+
+ @Test
+ @DisplayName("concurrent purchases cannot overspend the balance")
+ void concurrentPurchasesCannotOverspend() throws Exception {
+ FakeProvider provider = new FakeProvider(30);
+ UUID player = UUID.randomUUID();
+ provider.deposit(player, new BigDecimal("1000"), REASON);
+
+ int threads = 8;
+ ExecutorService pool = Executors.newFixedThreadPool(threads);
+ CountDownLatch start = new CountDownLatch(1);
+ List> futures = new ArrayList<>();
+
+ try {
+ for (int i = 0; i < threads; i++) {
+ futures.add(pool.submit(() -> {
+ start.await();
+ return provider.withdrawIfSufficient(player, new BigDecimal("1000"), REASON);
+ }));
+ }
+ start.countDown();
+
+ int successes = 0;
+ for (Future future : futures) {
+ if (future.get(30, TimeUnit.SECONDS).isSuccess()) {
+ successes++;
+ }
+ }
+
+ assertEquals(1, successes, "exactly one purchase should succeed");
+ assertEquals(0, provider.getBalance(player).compareTo(BigDecimal.ZERO), "balance should be exactly zero");
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+
+ @Test
+ @DisplayName("an unsynchronised check then withdraw does overspend, so the test above is meaningful")
+ void unsynchronisedPatternOverspends() throws Exception {
+ FakeProvider provider = new FakeProvider(30);
+ UUID player = UUID.randomUUID();
+ provider.deposit(player, new BigDecimal("1000"), REASON);
+
+ int threads = 8;
+ ExecutorService pool = Executors.newFixedThreadPool(threads);
+ CountDownLatch start = new CountDownLatch(1);
+ List> futures = new ArrayList<>();
+
+ try {
+ for (int i = 0; i < threads; i++) {
+ futures.add(pool.submit(() -> {
+ start.await();
+ if (provider.getBalance(player).compareTo(new BigDecimal("1000")) >= 0) {
+ provider.withdraw(player, new BigDecimal("1000"), REASON);
+ return true;
+ }
+ return false;
+ }));
+ }
+ start.countDown();
+
+ int successes = 0;
+ for (Future future : futures) {
+ if (future.get(30, TimeUnit.SECONDS)) {
+ successes++;
+ }
+ }
+
+ assertTrue(successes > 1, "the unsynchronised pattern is expected to overspend, got " + successes);
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+
+ @Test
+ @DisplayName("insufficient funds never reaches the backend")
+ void insufficientFundsNeverDebits() {
+ FakeProvider provider = new FakeProvider(0);
+ UUID player = UUID.randomUUID();
+ provider.deposit(player, new BigDecimal("5"), REASON);
+
+ TransactionResult result = provider.withdrawIfSufficient(player, BigDecimal.TEN, REASON);
+
+ assertSame(TransactionResult.Status.INSUFFICIENT_FUNDS, result.getStatus());
+ assertEquals(0, provider.getWithdrawCalls(), "withdraw must not be called when the funds are short");
+ assertEquals(0, provider.getBalance(player).compareTo(new BigDecimal("5")), "balance must be untouched");
+ }
+
+ @Test
+ @DisplayName("a negative amount is refused instead of crediting the player")
+ void negativeAmountIsRefused() {
+ FakeProvider provider = new FakeProvider(0);
+ UUID player = UUID.randomUUID();
+ provider.deposit(player, new BigDecimal("100"), REASON);
+
+ TransactionResult result = provider.withdrawIfSufficient(player, new BigDecimal("-50"), REASON);
+
+ assertSame(TransactionResult.Status.FAILED, result.getStatus());
+ assertEquals(0, provider.getWithdrawCalls(), "a negative withdrawal must never reach the backend");
+ assertEquals(0, provider.getBalance(player).compareTo(new BigDecimal("100")), "balance must be untouched");
+ }
+
+ @Test
+ @DisplayName("the emulated path reports EMULATED, not a guarantee it cannot make")
+ void emulatedPathIsHonest() {
+ FakeProvider provider = new FakeProvider(0);
+ UUID player = UUID.randomUUID();
+ provider.deposit(player, new BigDecimal("100"), REASON);
+
+ TransactionResult result = provider.withdrawIfSufficient(player, BigDecimal.TEN, REASON);
+
+ assertTrue(result.isSuccess());
+ assertSame(Guarantee.EMULATED, result.getGuarantee());
+ assertSame(Guarantee.EMULATED, provider.getWithdrawGuarantee());
+ assertFalse(result.getGuarantee().isCrossServerSafe(), "an emulated result is not cross-server safe");
+ assertNotEquals(Guarantee.NATIVE, result.getGuarantee());
+ }
+
+ @Test
+ @DisplayName("the asynchronous variant completes with a result rather than throwing")
+ void asyncCompletesWithResult() throws Exception {
+ FakeProvider provider = new FakeProvider(0);
+ UUID player = UUID.randomUUID();
+ provider.deposit(player, new BigDecimal("100"), REASON);
+
+ TransactionResult result = provider.withdrawIfSufficientAsync(player, new BigDecimal("40"), REASON)
+ .get(30, TimeUnit.SECONDS);
+
+ assertTrue(result.isSuccess());
+ assertEquals(0, provider.getBalance(player).compareTo(new BigDecimal("60")));
+ }
+}
From a1ffda36922715891d8a2a8304707264b09b0dc2 Mon Sep 17 00:00:00 2001
From: 1robie <97293924+1robie@users.noreply.github.com>
Date: Fri, 11 Sep 2026 10:11:20 +0200
Subject: [PATCH 6/7] feat: Refactor currency locking mechanism to use a map
for dynamic lock management and improve concurrency handling
---
.../fr/traqueur/currencies/CurrencyLocks.java | 151 +++++++++++++++---
.../traqueur/currencies/CurrencyProvider.java | 12 +-
.../currencies/CurrencyLocksTest.java | 144 +++++++++++++++++
3 files changed, 278 insertions(+), 29 deletions(-)
create mode 100644 src/test/java/fr/traqueur/currencies/CurrencyLocksTest.java
diff --git a/src/main/java/fr/traqueur/currencies/CurrencyLocks.java b/src/main/java/fr/traqueur/currencies/CurrencyLocks.java
index 6beeaff..0b6d8e0 100644
--- a/src/main/java/fr/traqueur/currencies/CurrencyLocks.java
+++ b/src/main/java/fr/traqueur/currencies/CurrencyLocks.java
@@ -1,51 +1,156 @@
package fr.traqueur.currencies;
+import java.util.Map;
+import java.util.Objects;
import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
+/**
+ * One lock per provider and player pair, used to serialize emulated conditional operations.
+ *
+ * Backends that cannot perform an atomic "debit only if the funds are there" have to be emulated
+ * as a balance read followed by a withdraw. Two threads running that sequence at the same time for
+ * the same player can both observe the same balance and both debit, which is the classic double
+ * spend. Holding a lock for the duration of the sequence removes that window.
+ *
+ * The lock is keyed on the exact pair being operated on, so two unrelated players never wait on
+ * each other and a timeout genuinely means there was a competing operation on that same balance.
+ * An earlier version used a fixed set of striped locks, which was cheap but let independent players
+ * collide, producing a failed purchase whose message claimed a conflict that had not happened.
+ *
+ * The map does not grow without bound despite having no fixed size: each entry is reference
+ * counted and removed as soon as the last holder or waiter is done with it, so its size tracks the
+ * number of operations currently in flight rather than the number of players ever seen.
+ *
+ * This only protects against concurrency inside this JVM. When several servers share one
+ * economy database, a lock held here is invisible to the other servers. That limitation is why an
+ * emulated result reports {@link Guarantee#EMULATED}.
+ */
final class CurrencyLocks {
- private static final int STRIPES = 1024;
private static final long LOCK_TIMEOUT_MILLIS = 250L;
private static final long MAIN_THREAD_LOCK_TIMEOUT_MILLIS = 25L;
- private static final ReentrantLock[] LOCKS = new ReentrantLock[STRIPES];
- static {
- for (int i = 0; i < STRIPES; i++) {
- LOCKS[i] = new ReentrantLock();
- }
- }
+ private static final Map LOCKS = new ConcurrentHashMap<>();
private CurrencyLocks() {
}
/**
- * Resolve the lock guarding a given provider and player pair.
+ * Takes the lock guarding one provider and player pair.
*
* @param provider The provider performing the operation.
* @param playerId The player being debited.
- * @return The lock to use.
+ * @return A handle that must be released in a finally block, or null when the lock could not be
+ * taken in time. Null means no operation was performed.
*/
- static ReentrantLock lockFor(CurrencyProvider provider, UUID playerId) {
- int hash = System.identityHashCode(provider) * 31 + (playerId == null ? 0 : playerId.hashCode());
- hash ^= (hash >>> 16);
- return LOCKS[hash & (STRIPES - 1)];
- }
+ static Handle tryAcquire(CurrencyProvider provider, UUID playerId) {
+ Key key = new Key(System.identityHashCode(provider), playerId);
+
+ CountedLock entry = LOCKS.compute(key, (k, existing) -> {
+ CountedLock counted = existing == null ? new CountedLock() : existing;
+ counted.references++;
+ return counted;
+ });
- /**
- * Try to acquire a lock within the configured timeout.
- *
- * @param lock The lock to acquire.
- * @return True when the lock was acquired and must be released by the caller.
- */
- static boolean tryLock(ReentrantLock lock) {
long timeout = CurrenciesAPI.isMainThread() ? MAIN_THREAD_LOCK_TIMEOUT_MILLIS : LOCK_TIMEOUT_MILLIS;
+ boolean acquired;
try {
- return lock.tryLock(timeout, TimeUnit.MILLISECONDS);
+ acquired = entry.lock.tryLock(timeout, TimeUnit.MILLISECONDS);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
- return false;
+ acquired = false;
+ }
+
+ if (!acquired) {
+ release(key);
+ return null;
+ }
+
+ return new Handle(key, entry);
+ }
+
+ /**
+ * Drops one reference to an entry, removing it once nobody is using it any more.
+ */
+ private static void release(Key key) {
+ LOCKS.compute(key, (k, existing) -> {
+ if (existing == null) {
+ return null;
+ }
+ existing.references--;
+ return existing.references <= 0 ? null : existing;
+ });
+ }
+
+ /**
+ * Number of live lock entries. For tests, to prove entries do not accumulate.
+ */
+ static int activeLockCount() {
+ return LOCKS.size();
+ }
+
+ /**
+ * A held lock. Release it in a finally block.
+ */
+ static final class Handle {
+
+ private final Key key;
+ private final CountedLock entry;
+
+ private Handle(Key key, CountedLock entry) {
+ this.key = key;
+ this.entry = entry;
+ }
+
+ void release() {
+ this.entry.lock.unlock();
+ CurrencyLocks.release(this.key);
+ }
+ }
+
+ private static final class CountedLock {
+
+ private final ReentrantLock lock = new ReentrantLock();
+
+ private int references;
+ }
+
+ /**
+ * Identifies one provider and player pair.
+ *
+ * The provider is identified by its identity hash rather than by equality, because two
+ * distinct provider instances for the same economy are genuinely separate paths to the same
+ * money only by coincidence, and providers do not define equals.
+ */
+ private static final class Key {
+
+ private final int providerIdentity;
+ private final UUID playerId;
+
+ private Key(int providerIdentity, UUID playerId) {
+ this.providerIdentity = providerIdentity;
+ this.playerId = playerId;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (!(other instanceof Key)) {
+ return false;
+ }
+ Key key = (Key) other;
+ return this.providerIdentity == key.providerIdentity
+ && (Objects.equals(this.playerId, key.playerId));
+ }
+
+ @Override
+ public int hashCode() {
+ return this.providerIdentity * 31 + (this.playerId == null ? 0 : this.playerId.hashCode());
}
}
}
diff --git a/src/main/java/fr/traqueur/currencies/CurrencyProvider.java b/src/main/java/fr/traqueur/currencies/CurrencyProvider.java
index 7b01a81..dcf98f9 100644
--- a/src/main/java/fr/traqueur/currencies/CurrencyProvider.java
+++ b/src/main/java/fr/traqueur/currencies/CurrencyProvider.java
@@ -5,7 +5,6 @@
import java.math.BigDecimal;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.locks.ReentrantLock;
/**
* Interface used to interact with a currency provider.
@@ -100,10 +99,11 @@ default TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount,
if (invalid != null) {
return invalid;
}
-
- ReentrantLock lock = CurrencyLocks.lockFor(this, playerId);
- if (!CurrencyLocks.tryLock(lock)) {
- return TransactionResult.failed(amount, "Timed out waiting for the currency lock, nothing was taken.");
+
+ CurrencyLocks.Handle handle = CurrencyLocks.tryAcquire(this, playerId);
+ if (handle == null) {
+ return TransactionResult.failed(amount,
+ "Timed out waiting for another operation on the same balance, nothing was taken.");
}
try {
@@ -121,7 +121,7 @@ default TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount,
} catch (Exception exception) {
return TransactionResult.failed(amount, "The backend threw while withdrawing: " + exception.getMessage());
} finally {
- lock.unlock();
+ handle.release();
}
}
diff --git a/src/test/java/fr/traqueur/currencies/CurrencyLocksTest.java b/src/test/java/fr/traqueur/currencies/CurrencyLocksTest.java
new file mode 100644
index 0000000..35fe3cd
--- /dev/null
+++ b/src/test/java/fr/traqueur/currencies/CurrencyLocksTest.java
@@ -0,0 +1,144 @@
+package fr.traqueur.currencies;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The lock is keyed per provider and player, so it has two properties worth pinning down: unrelated
+ * players must never wait on each other, and the entries must not pile up over time.
+ */
+class CurrencyLocksTest {
+
+ private static final String REASON = "test";
+
+ @Test
+ @DisplayName("two different players do not block each other")
+ void differentPlayersDoNotBlockEachOther() throws Exception {
+ FakeProvider provider = new FakeProvider(0);
+ UUID first = UUID.randomUUID();
+
+ CurrencyLocks.Handle held = CurrencyLocks.tryAcquire(provider, first);
+ assertNotNull(held, "the first acquisition should succeed");
+
+ try {
+ UUID second = UUID.randomUUID();
+ CurrencyLocks.Handle other = CurrencyLocks.tryAcquire(provider, second);
+ assertNotNull(other, "an unrelated player must not be blocked");
+ other.release();
+ } finally {
+ held.release();
+ }
+ }
+
+ @Test
+ @DisplayName("the same player is serialized, and a timeout means a real conflict")
+ void samePlayerIsSerialized() throws Exception {
+ FakeProvider provider = new FakeProvider(0);
+ UUID player = UUID.randomUUID();
+
+ CurrencyLocks.Handle held = CurrencyLocks.tryAcquire(provider, player);
+ assertNotNull(held);
+
+ try {
+ ExecutorService pool = Executors.newSingleThreadExecutor();
+ try {
+ Future attempt = pool.submit(() -> CurrencyLocks.tryAcquire(provider, player));
+ assertNull(attempt.get(30, TimeUnit.SECONDS), "the same balance must be serialized");
+ } finally {
+ pool.shutdownNow();
+ }
+ } finally {
+ held.release();
+ }
+ }
+
+ @Test
+ @DisplayName("lock entries are released, so the map does not grow with the number of players")
+ void lockEntriesDoNotAccumulate() throws Exception {
+ FakeProvider provider = new FakeProvider(0);
+
+ int before = CurrencyLocks.activeLockCount();
+
+ for (int i = 0; i < 2000; i++) {
+ UUID player = UUID.randomUUID();
+ provider.deposit(player, BigDecimal.TEN, REASON);
+ provider.withdrawIfSufficient(player, BigDecimal.ONE, REASON);
+ }
+
+ assertEquals(before, CurrencyLocks.activeLockCount(),
+ "entries must be removed once no thread holds or waits for them");
+ }
+
+ @Test
+ @DisplayName("entries are also released when the acquisition times out")
+ void timedOutAcquisitionReleasesItsEntry() throws Exception {
+ FakeProvider provider = new FakeProvider(0);
+ UUID player = UUID.randomUUID();
+
+ int before = CurrencyLocks.activeLockCount();
+
+ CurrencyLocks.Handle held = CurrencyLocks.tryAcquire(provider, player);
+ assertNotNull(held);
+
+ ExecutorService pool = Executors.newSingleThreadExecutor();
+ try {
+ assertNull(pool.submit(() -> CurrencyLocks.tryAcquire(provider, player)).get(30, TimeUnit.SECONDS));
+ } finally {
+ pool.shutdownNow();
+ }
+
+ held.release();
+
+ assertEquals(before, CurrencyLocks.activeLockCount(), "a timed out attempt must not leak an entry");
+ }
+
+ @Test
+ @DisplayName("concurrent traffic across many players leaves no entries behind")
+ void concurrentTrafficLeavesNoEntries() throws Exception {
+ FakeProvider provider = new FakeProvider(1);
+ int before = CurrencyLocks.activeLockCount();
+
+ int threads = 8;
+ ExecutorService pool = Executors.newFixedThreadPool(threads);
+ CountDownLatch start = new CountDownLatch(1);
+ List> futures = new ArrayList<>();
+
+ try {
+ for (int i = 0; i < threads; i++) {
+ futures.add(pool.submit(() -> {
+ start.await();
+ for (int n = 0; n < 100; n++) {
+ UUID player = UUID.randomUUID();
+ provider.deposit(player, BigDecimal.TEN, REASON);
+ provider.withdrawIfSufficient(player, BigDecimal.ONE, REASON);
+ }
+ return null;
+ }));
+ }
+ start.countDown();
+ for (Future> future : futures) {
+ future.get(60, TimeUnit.SECONDS);
+ }
+ } finally {
+ pool.shutdownNow();
+ }
+
+ assertTrue(CurrencyLocks.activeLockCount() <= before,
+ "expected no leftover entries, got " + CurrencyLocks.activeLockCount());
+ }
+}
From 775d5b5e8c759ff953418988dfab38962dba4482 Mon Sep 17 00:00:00 2001
From: 1robie <97293924+1robie@users.noreply.github.com>
Date: Fri, 11 Sep 2026 10:21:06 +0200
Subject: [PATCH 7/7] feat: Add nullability annotations to method parameters
and return types for improved API clarity
---
readme.md | 37 +++++++++++++++++++
.../fr/traqueur/currencies/Currencies.java | 15 +++++---
.../fr/traqueur/currencies/CurrenciesAPI.java | 6 ++-
.../currencies/CurrencyArgumentChecks.java | 5 ++-
.../traqueur/currencies/CurrencyProvider.java | 9 ++++-
.../traqueur/currencies/CurrencyRegistry.java | 24 ++++++------
.../currencies/TransactionResult.java | 13 +++++--
7 files changed, 85 insertions(+), 24 deletions(-)
diff --git a/readme.md b/readme.md
index b57c38c..985457a 100644
--- a/readme.md
+++ b/readme.md
@@ -136,6 +136,26 @@ Currencies.ZESSENTIALS.getBalance(player, "coins");
```
+#### The default economy
+
+Every method that takes a currency name has an overload that does not. Those overloads use the
+currency named `"default"`, which is why `"default"` shows up in the examples further down:
+
+```java
+// These two are the same call
+Currencies.VAULT.getBalance(playerId);
+Currencies.VAULT.getBalance(playerId, "default");
+
+// And so are these
+Currencies.VAULT.withdrawIfSufficient(playerId, amount, "Shop purchase");
+Currencies.VAULT.withdrawIfSufficient(playerId, amount, "default", "Shop purchase");
+```
+
+For a single-currency backend such as Vault there is nothing else to know: everything lives under
+`"default"` and the short overloads are all you need. For a multi-currency backend the name selects
+which currency you mean, and the short overloads would look for one actually called `"default"`, so
+pass the name explicitly.
+
### Safe Purchases: `withdrawIfSufficient`
`withdraw` does **not** check whether the player can afford the amount. Most backends will happily
@@ -250,6 +270,23 @@ To look a registered currency up, `CurrencyRegistry.require(name)` throws when t
`CurrencyRegistry.find(name)` returns null. Use `registerOrReplace(...)` to deliberately swap an
implementation, for example on a config reload.
+#### Looking up either kind by name
+
+A currency name read from a config file could be a built-in constant or one of your own
+registrations, and the caller usually should not have to care. `resolve(...)` handles both:
+
+```java
+// "VAULT", "COINSENGINE", "my_gems" — all work, whichever mechanism they came from
+CurrencyProvider provider = CurrencyRegistry.resolve(nameFromConfig, null);
+
+TransactionResult result = provider.withdrawIfSufficient(playerId, amount, "Shop purchase");
+```
+
+The second argument is the currency name for a multi-currency built-in backend; pass `null` for the
+default economy, and it is ignored for a custom provider since those are registered per currency
+already. Built-in constants win when a name matches both, so a custom registration cannot silently
+shadow `VAULT`.
+
Those three methods are all you have to write. Everything else has a default implementation, so an
existing provider keeps working unchanged. Two optional overrides are worth knowing about:
diff --git a/src/main/java/fr/traqueur/currencies/Currencies.java b/src/main/java/fr/traqueur/currencies/Currencies.java
index b5573c4..3401310 100644
--- a/src/main/java/fr/traqueur/currencies/Currencies.java
+++ b/src/main/java/fr/traqueur/currencies/Currencies.java
@@ -3,6 +3,7 @@
import fr.traqueur.currencies.providers.*;
import org.bukkit.Bukkit;
import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
import java.lang.reflect.Constructor;
import java.math.BigDecimal;
@@ -293,7 +294,8 @@ public BigDecimal getBalance(UUID playerId, String currencyName) {
* @return The outcome. Nothing is debited unless the status is
* {@link TransactionResult.Status#SUCCESS}.
*/
- public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) {
+ @NotNull
+ public TransactionResult withdrawIfSufficient(@NotNull UUID playerId, @NotNull BigDecimal amount, @Nullable String reason) {
return this.withdrawIfSufficient(playerId, amount, DEFAULT_CURRENCY_NAME, reason);
}
@@ -307,7 +309,8 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount,
* @return The outcome. Nothing is debited unless the status is
* {@link TransactionResult.Status#SUCCESS}.
*/
- public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String currencyName, String reason) {
+ @NotNull
+ public TransactionResult withdrawIfSufficient(@NotNull UUID playerId, @NotNull BigDecimal amount, @NotNull String currencyName, @Nullable String reason) {
this.canBeUse(currencyName);
return this.providers.get(currencyName).withdrawIfSufficient(playerId, amount, reason);
}
@@ -321,7 +324,8 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount,
* @param reason The reason of the withdrawal.
* @return A future completed with the outcome.
*/
- public CompletableFuture withdrawIfSufficientAsync(UUID playerId, BigDecimal amount, String currencyName, String reason) {
+ @NotNull
+ public CompletableFuture withdrawIfSufficientAsync(@NotNull UUID playerId, @NotNull BigDecimal amount, @NotNull String currencyName, @Nullable String reason) {
this.canBeUse(currencyName);
return this.providers.get(currencyName).withdrawIfSufficientAsync(playerId, amount, reason);
}
@@ -337,12 +341,13 @@ public CompletableFuture withdrawIfSufficientAsync(UUID playe
* @return The provider.
*/
@NotNull
- public CurrencyProvider getProvider(String currencyName) {
+ public CurrencyProvider getProvider(@NotNull String currencyName) {
this.canBeUse(currencyName);
return this.providers.get(currencyName);
}
- public Guarantee getWithdrawGuarantee(String currencyName) {
+ @NotNull
+ public Guarantee getWithdrawGuarantee(@NotNull String currencyName) {
this.canBeUse(currencyName);
return this.providers.get(currencyName).getWithdrawGuarantee();
}
diff --git a/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java b/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java
index 95e32f4..e03173c 100644
--- a/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java
+++ b/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java
@@ -1,5 +1,8 @@
package fr.traqueur.currencies;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
import org.bukkit.Bukkit;
import org.bukkit.plugin.Plugin;
@@ -15,7 +18,7 @@ private CurrenciesAPI() {
*
* @param owningPlugin The plugin instance, must not be null.
*/
- public static void init(Plugin owningPlugin) {
+ public static void init(@NotNull Plugin owningPlugin) {
if (owningPlugin == null) {
throw new IllegalArgumentException("The plugin instance cannot be null.");
}
@@ -28,6 +31,7 @@ public static void init(Plugin owningPlugin) {
/**
* @return The registered plugin instance, or null when {@link #init(Plugin)} was never called.
*/
+ @Nullable
public static Plugin getPlugin() {
return plugin;
}
diff --git a/src/main/java/fr/traqueur/currencies/CurrencyArgumentChecks.java b/src/main/java/fr/traqueur/currencies/CurrencyArgumentChecks.java
index 340cf18..ae1c99c 100644
--- a/src/main/java/fr/traqueur/currencies/CurrencyArgumentChecks.java
+++ b/src/main/java/fr/traqueur/currencies/CurrencyArgumentChecks.java
@@ -1,5 +1,7 @@
package fr.traqueur.currencies;
+import org.jetbrains.annotations.Nullable;
+
import java.math.BigDecimal;
import java.util.UUID;
@@ -23,7 +25,8 @@ private CurrencyArgumentChecks() {
* @param amount The requested amount.
* @return A result describing the problem, or null when the arguments are usable.
*/
- public static TransactionResult findProblem(UUID playerId, BigDecimal amount) {
+ @Nullable
+ public static TransactionResult findProblem(@Nullable UUID playerId, @Nullable BigDecimal amount) {
if (playerId == null) {
return TransactionResult.failed(amount, "The player UUID cannot be null.");
}
diff --git a/src/main/java/fr/traqueur/currencies/CurrencyProvider.java b/src/main/java/fr/traqueur/currencies/CurrencyProvider.java
index dcf98f9..6862164 100644
--- a/src/main/java/fr/traqueur/currencies/CurrencyProvider.java
+++ b/src/main/java/fr/traqueur/currencies/CurrencyProvider.java
@@ -1,6 +1,8 @@
package fr.traqueur.currencies;
import org.bukkit.Bukkit;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
import java.math.BigDecimal;
import java.util.UUID;
@@ -53,6 +55,7 @@ public interface CurrencyProvider {
*
* @return The level of guarantee behind a conditional withdrawal.
*/
+ @NotNull
default Guarantee getWithdrawGuarantee() {
return Guarantee.EMULATED;
}
@@ -94,7 +97,8 @@ default boolean requiresMainThread() {
* @return The outcome. Nothing is debited unless the status is
* {@link TransactionResult.Status#SUCCESS}.
*/
- default TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) {
+ @NotNull
+ default TransactionResult withdrawIfSufficient(@NotNull UUID playerId, @NotNull BigDecimal amount, @Nullable String reason) {
TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount);
if (invalid != null) {
return invalid;
@@ -138,7 +142,8 @@ default TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount,
* @return A future completed with the outcome. The future itself never completes
* exceptionally, failures are reported through the result.
*/
- default CompletableFuture withdrawIfSufficientAsync(UUID playerId, BigDecimal amount, String reason) {
+ @NotNull
+ default CompletableFuture withdrawIfSufficientAsync(@NotNull UUID playerId, @NotNull BigDecimal amount, @Nullable String reason) {
TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount);
if (invalid != null) {
return CompletableFuture.completedFuture(invalid);
diff --git a/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java b/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java
index 863a3ba..f4744c3 100644
--- a/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java
+++ b/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java
@@ -46,9 +46,7 @@ private CurrencyRegistry() {
* @throws IllegalArgumentException if the name or the provider is null or the name is blank.
* @throws IllegalStateException if a different provider is already registered under this name.
*/
- public static void register(String name, CurrencyProvider provider) {
- // Provider checked before the name, so register(null, null) reports the provider rather
- // than blaming the name.
+ public static void register(@NotNull String name, @NotNull CurrencyProvider provider) {
if (provider == null) {
throw new IllegalArgumentException("The provider cannot be null.");
}
@@ -67,7 +65,8 @@ public static void register(String name, CurrencyProvider provider) {
* @param provider The provider instance.
* @return The provider that was previously registered, or null.
*/
- public static CurrencyProvider registerOrReplace(String name, CurrencyProvider provider) {
+ @Nullable
+ public static CurrencyProvider registerOrReplace(@NotNull String name, @NotNull CurrencyProvider provider) {
if (provider == null) {
throw new IllegalArgumentException("The provider cannot be null.");
}
@@ -80,7 +79,8 @@ public static CurrencyProvider registerOrReplace(String name, CurrencyProvider p
* @param name The name it was registered under.
* @return The removed provider, or null when nothing was registered.
*/
- public static CurrencyProvider unregister(String name) {
+ @Nullable
+ public static CurrencyProvider unregister(@NotNull String name) {
return PROVIDERS.remove(normalize(name));
}
@@ -92,7 +92,7 @@ public static CurrencyProvider unregister(String name) {
* @throws IllegalStateException if nothing is registered under this name.
*/
@NotNull
- public static CurrencyProvider require(String name) {
+ public static CurrencyProvider require(@NotNull String name) {
CurrencyProvider provider = find(name);
if (provider == null) {
throw new IllegalStateException("No custom currency is registered under the name " + name
@@ -108,7 +108,7 @@ public static CurrencyProvider require(String name) {
* @return The provider, or null.
*/
@Nullable
- public static CurrencyProvider find(String name) {
+ public static CurrencyProvider find(@NotNull String name) {
return PROVIDERS.get(normalize(name));
}
@@ -116,7 +116,7 @@ public static CurrencyProvider find(String name) {
* @param name The name to look up.
* @return True when a provider is registered under this name.
*/
- public static boolean isRegistered(String name) {
+ public static boolean isRegistered(@Nullable String name) {
return name != null && PROVIDERS.containsKey(normalize(name));
}
@@ -143,7 +143,8 @@ public static Set getRegisteredNames() {
* @return The outcome. Nothing is debited unless the status is
* {@link TransactionResult.Status#SUCCESS}.
*/
- public static TransactionResult withdrawIfSufficient(String name, UUID playerId, BigDecimal amount, String reason) {
+ @NotNull
+ public static TransactionResult withdrawIfSufficient(@NotNull String name, @NotNull UUID playerId, @NotNull BigDecimal amount, @Nullable String reason) {
return require(name).withdrawIfSufficient(playerId, amount, reason);
}
@@ -156,7 +157,8 @@ public static TransactionResult withdrawIfSufficient(String name, UUID playerId,
* @param reason The reason of the withdrawal.
* @return A future completed with the outcome.
*/
- public static CompletableFuture withdrawIfSufficientAsync(String name, UUID playerId, BigDecimal amount, String reason) {
+ @NotNull
+ public static CompletableFuture withdrawIfSufficientAsync(@NotNull String name, @NotNull UUID playerId, @NotNull BigDecimal amount, @Nullable String reason) {
return require(name).withdrawIfSufficientAsync(playerId, amount, reason);
}
@@ -179,7 +181,7 @@ public static CompletableFuture withdrawIfSufficientAsync(Str
* @throws IllegalStateException if the name matches neither.
*/
@NotNull
- public static CurrencyProvider resolve(String name, String currencyName) {
+ public static CurrencyProvider resolve(@NotNull String name, @Nullable String currencyName) {
if (name != null) {
try {
Currencies currency = Currencies.fromName(name.trim().toUpperCase(java.util.Locale.ROOT));
diff --git a/src/main/java/fr/traqueur/currencies/TransactionResult.java b/src/main/java/fr/traqueur/currencies/TransactionResult.java
index c2a5033..695cc18 100644
--- a/src/main/java/fr/traqueur/currencies/TransactionResult.java
+++ b/src/main/java/fr/traqueur/currencies/TransactionResult.java
@@ -62,7 +62,8 @@ private TransactionResult(Status status, BigDecimal amount, BigDecimal balance,
* @param guarantee How strong the promise behind the operation is.
* @return The result.
*/
- public static TransactionResult success(BigDecimal amount, BigDecimal balance, Guarantee guarantee) {
+ @NotNull
+ public static TransactionResult success(@Nullable BigDecimal amount, @Nullable BigDecimal balance, @NotNull Guarantee guarantee) {
return new TransactionResult(Status.SUCCESS, amount, balance, null, guarantee);
}
@@ -74,7 +75,8 @@ public static TransactionResult success(BigDecimal amount, BigDecimal balance, G
* @param guarantee How strong the promise behind the check is.
* @return The result.
*/
- public static TransactionResult insufficientFunds(BigDecimal amount, BigDecimal balance, Guarantee guarantee) {
+ @NotNull
+ public static TransactionResult insufficientFunds(@Nullable BigDecimal amount, @Nullable BigDecimal balance, @NotNull Guarantee guarantee) {
return new TransactionResult(Status.INSUFFICIENT_FUNDS, amount, balance, null, guarantee);
}
@@ -85,7 +87,8 @@ public static TransactionResult insufficientFunds(BigDecimal amount, BigDecimal
* @param errorMessage A human readable explanation.
* @return The result.
*/
- public static TransactionResult unsupported(BigDecimal amount, String errorMessage) {
+ @NotNull
+ public static TransactionResult unsupported(@Nullable BigDecimal amount, @Nullable String errorMessage) {
return new TransactionResult(Status.UNSUPPORTED, amount, null, errorMessage, Guarantee.EMULATED);
}
@@ -96,7 +99,8 @@ public static TransactionResult unsupported(BigDecimal amount, String errorMessa
* @param errorMessage A human readable explanation.
* @return The result.
*/
- public static TransactionResult failed(BigDecimal amount, String errorMessage) {
+ @NotNull
+ public static TransactionResult failed(@Nullable BigDecimal amount, @Nullable String errorMessage) {
return new TransactionResult(Status.FAILED, amount, null, errorMessage, Guarantee.EMULATED);
}
@@ -150,6 +154,7 @@ public String getErrorMessage() {
}
@Override
+ @NotNull
public String toString() {
return "TransactionResult{status=" + this.status
+ ", amount=" + this.amount