diff --git a/build.gradle.kts b/build.gradle.kts
index 376f295..0950655 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")
@@ -62,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
diff --git a/readme.md b/readme.md
index af1a420..985457a 100644
--- a/readme.md
+++ b/readme.md
@@ -136,6 +136,180 @@ 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
+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;
+}
+```
+
+**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:
+
+```java
+Currencies.VAULT.withdrawIfSufficientAsync(playerId, amount, "default", "Shop purchase")
+ .thenAccept(result -> { /* ... */ });
+```
+
+### Guarantee Per Backend
+
+Backends differ in how strong a promise they can make, and it is not a yes or no question. Three
+levels, reported by `Guarantee`:
+
+| 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.getWithdrawGuarantee("default").isCrossServerSafe()) {
+ getLogger().warning("This currency cannot guarantee purchases across servers.");
+}
+
+result.getGuarantee(); // NATIVE, DELEGATED or EMULATED
+```
+
+| Currency | Guarantee | Notes |
+| --- | --- | --- |
+| `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
+
+`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 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
+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:
+
+- `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.
+- `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 +395,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..3401310 100644
--- a/src/main/java/fr/traqueur/currencies/Currencies.java
+++ b/src/main/java/fr/traqueur/currencies/Currencies.java
@@ -2,6 +2,8 @@
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;
@@ -11,6 +13,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 +95,9 @@ public enum Currencies {
EXCELLENTEECONOMY("ExcellentEconomy", ExcellentEconomyProvider.class, true, true, EXCELLENTECONOMY)
;
+ final static String DEFAULT_CURRENCY_NAME = "default";
+ private final static String DEFAULT_REASON = "No reason";
+
static {
Updater.checkUpdates();
}
@@ -194,7 +200,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 +211,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 +221,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 +231,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 +241,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 +282,89 @@ 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}.
+ */
+ @NotNull
+ public TransactionResult withdrawIfSufficient(@NotNull UUID playerId, @NotNull BigDecimal amount, @Nullable 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}.
+ */
+ @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);
+ }
+
+ /**
+ * 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.
+ */
+ @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);
+ }
+
+ /**
+ * Returns the provider backing this currency, creating it if necessary.
+ *
+ * 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 The provider.
+ */
+ @NotNull
+ public CurrencyProvider getProvider(@NotNull String currencyName) {
+ this.canBeUse(currencyName);
+ return this.providers.get(currencyName);
+ }
+
+ @NotNull
+ public Guarantee getWithdrawGuarantee(@NotNull String currencyName) {
+ this.canBeUse(currencyName);
+ return this.providers.get(currencyName).getWithdrawGuarantee();
+ }
+
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..e03173c
--- /dev/null
+++ b/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java
@@ -0,0 +1,51 @@
+package fr.traqueur.currencies;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+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(@NotNull Plugin owningPlugin) {
+ if (owningPlugin == null) {
+ throw new IllegalArgumentException("The plugin instance cannot be null.");
+ }
+ if (plugin != null) {
+ return;
+ }
+ plugin = owningPlugin;
+ }
+
+ /**
+ * @return The registered plugin instance, or null when {@link #init(Plugin)} was never called.
+ */
+ @Nullable
+ 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..ae1c99c
--- /dev/null
+++ b/src/main/java/fr/traqueur/currencies/CurrencyArgumentChecks.java
@@ -0,0 +1,41 @@
+package fr.traqueur.currencies;
+
+import org.jetbrains.annotations.Nullable;
+
+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.
+ */
+ @Nullable
+ public static TransactionResult findProblem(@Nullable UUID playerId, @Nullable 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/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/CurrencyLocks.java b/src/main/java/fr/traqueur/currencies/CurrencyLocks.java
new file mode 100644
index 0000000..0b6d8e0
--- /dev/null
+++ b/src/main/java/fr/traqueur/currencies/CurrencyLocks.java
@@ -0,0 +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 long LOCK_TIMEOUT_MILLIS = 250L;
+ private static final long MAIN_THREAD_LOCK_TIMEOUT_MILLIS = 25L;
+
+ private static final Map LOCKS = new ConcurrentHashMap<>();
+
+ private CurrencyLocks() {
+ }
+
+ /**
+ * Takes the lock guarding one provider and player pair.
+ *
+ * @param provider The provider performing the operation.
+ * @param playerId The player being debited.
+ * @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 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;
+ });
+
+ long timeout = CurrenciesAPI.isMainThread() ? MAIN_THREAD_LOCK_TIMEOUT_MILLIS : LOCK_TIMEOUT_MILLIS;
+ boolean acquired;
+ try {
+ acquired = entry.lock.tryLock(timeout, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException exception) {
+ Thread.currentThread().interrupt();
+ 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 49873f8..6862164 100644
--- a/src/main/java/fr/traqueur/currencies/CurrencyProvider.java
+++ b/src/main/java/fr/traqueur/currencies/CurrencyProvider.java
@@ -1,7 +1,12 @@
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;
+import java.util.concurrent.CompletableFuture;
/**
* Interface used to interact with a currency provider.
@@ -36,4 +41,136 @@ public interface CurrencyProvider {
*/
BigDecimal getBalance(UUID playerId);
+ /**
+ * How strong a promise this provider can make about {@link #withdrawIfSufficient}.
+ *
+ * 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.
+ *
+ * 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.
+ */
+ @NotNull
+ default Guarantee getWithdrawGuarantee() {
+ return Guarantee.EMULATED;
+ }
+
+ /**
+ * 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#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.
+ * @param reason The reason of the withdrawal.
+ * @return The outcome. Nothing is debited unless the status is
+ * {@link TransactionResult.Status#SUCCESS}.
+ */
+ @NotNull
+ default TransactionResult withdrawIfSufficient(@NotNull UUID playerId, @NotNull BigDecimal amount, @Nullable String reason) {
+ TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount);
+ if (invalid != null) {
+ return invalid;
+ }
+
+ 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 {
+ BigDecimal balance = this.getBalance(playerId);
+ if (balance == null) {
+ balance = BigDecimal.ZERO;
+ }
+
+ if (balance.compareTo(amount) < 0) {
+ return TransactionResult.insufficientFunds(amount, balance, Guarantee.EMULATED);
+ }
+
+ this.withdraw(playerId, amount, reason);
+ return TransactionResult.success(amount, balance.subtract(amount), Guarantee.EMULATED);
+ } catch (Exception exception) {
+ return TransactionResult.failed(amount, "The backend threw while withdrawing: " + exception.getMessage());
+ } finally {
+ handle.release();
+ }
+ }
+
+ /**
+ * 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.
+ */
+ @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);
+ }
+
+ if (!this.requiresMainThread()) {
+ return CompletableFuture.supplyAsync(() -> CurrencyProvider.this.withdrawIfSufficient(playerId, amount, reason), CurrencyExecutor.get());
+ }
+
+ 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..f4744c3
--- /dev/null
+++ b/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java
@@ -0,0 +1,207 @@
+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 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.
+ */
+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(@NotNull String name, @NotNull CurrencyProvider provider) {
+ 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) {
+ 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.
+ */
+ @Nullable
+ public static CurrencyProvider registerOrReplace(@NotNull String name, @NotNull CurrencyProvider provider) {
+ if (provider == null) {
+ throw new IllegalArgumentException("The provider cannot be null.");
+ }
+ return PROVIDERS.put(normalize(name), provider);
+ }
+
+ /**
+ * Remove a registered provider.
+ *
+ * @param name The name it was registered under.
+ * @return The removed provider, or null when nothing was registered.
+ */
+ @Nullable
+ public static CurrencyProvider unregister(@NotNull 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(@NotNull 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(@NotNull 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(@Nullable String name) {
+ return name != null && PROVIDERS.containsKey(normalize(name));
+ }
+
+ /**
+ * 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() {
+ 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}.
+ */
+ @NotNull
+ public static TransactionResult withdrawIfSufficient(@NotNull String name, @NotNull UUID playerId, @NotNull BigDecimal amount, @Nullable 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.
+ */
+ @NotNull
+ public static CompletableFuture withdrawIfSufficientAsync(@NotNull String name, @NotNull UUID playerId, @NotNull BigDecimal amount, @Nullable String reason) {
+ 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(@NotNull String name, @Nullable 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.");
+ }
+ return name.trim().toLowerCase(java.util.Locale.ROOT);
+ }
+}
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
new file mode 100644
index 0000000..695cc18
--- /dev/null
+++ b/src/main/java/fr/traqueur/currencies/TransactionResult.java
@@ -0,0 +1,166 @@
+package fr.traqueur.currencies;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+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 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
+ }
+
+ private final Status status;
+ private final BigDecimal amount;
+ private final BigDecimal balance;
+ private final String errorMessage;
+ private final Guarantee guarantee;
+
+ 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.guarantee = guarantee;
+ }
+
+ /**
+ * Build a successful result.
+ *
+ * @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.
+ */
+ @NotNull
+ public static TransactionResult success(@Nullable BigDecimal amount, @Nullable BigDecimal balance, @NotNull Guarantee guarantee) {
+ return new TransactionResult(Status.SUCCESS, amount, balance, null, guarantee);
+ }
+
+ /**
+ * 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 guarantee How strong the promise behind the check is.
+ * @return The result.
+ */
+ @NotNull
+ public static TransactionResult insufficientFunds(@Nullable BigDecimal amount, @Nullable BigDecimal balance, @NotNull Guarantee guarantee) {
+ return new TransactionResult(Status.INSUFFICIENT_FUNDS, amount, balance, null, guarantee);
+ }
+
+ /**
+ * 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.
+ */
+ @NotNull
+ public static TransactionResult unsupported(@Nullable BigDecimal amount, @Nullable String errorMessage) {
+ return new TransactionResult(Status.UNSUPPORTED, amount, null, errorMessage, Guarantee.EMULATED);
+ }
+
+ /**
+ * Build a failed result. Nothing was debited.
+ *
+ * @param amount The amount that was requested.
+ * @param errorMessage A human readable explanation.
+ * @return The result.
+ */
+ @NotNull
+ public static TransactionResult failed(@Nullable BigDecimal amount, @Nullable String errorMessage) {
+ return new TransactionResult(Status.FAILED, amount, null, errorMessage, Guarantee.EMULATED);
+ }
+
+ /**
+ * @return The outcome of the operation.
+ */
+ @NotNull
+ public Status getStatus() {
+ return this.status;
+ }
+
+ /**
+ * @return True only when the funds were actually debited.
+ */
+ public boolean isSuccess() {
+ return this.status == Status.SUCCESS;
+ }
+
+ /**
+ * @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.
+ */
+ @NotNull
+ public Guarantee getGuarantee() {
+ return this.guarantee;
+ }
+
+ /**
+ * @return The amount that was requested.
+ */
+ @NotNull
+ public BigDecimal getAmount() {
+ return this.amount;
+ }
+
+ /**
+ * @return The resulting balance, or null when the backend does not report one.
+ */
+ @Nullable
+ public BigDecimal getBalance() {
+ return this.balance;
+ }
+
+ /**
+ * @return A human-readable explanation for a failure, or null.
+ */
+ @Nullable
+ public String getErrorMessage() {
+ return this.errorMessage;
+ }
+
+ @Override
+ @NotNull
+ public String toString() {
+ return "TransactionResult{status=" + this.status
+ + ", amount=" + this.amount
+ + ", balance=" + this.balance
+ + ", 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 3b26755..95faece 100644
--- a/src/main/java/fr/traqueur/currencies/providers/ExcellentEconomyProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/ExcellentEconomyProvider.java
@@ -1,13 +1,19 @@
package fr.traqueur.currencies.providers;
+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;
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 +59,81 @@ public BigDecimal getBalance(UUID playerId) {
: this.api.getBalanceAsync(playerId, this.currencyName).join();
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 Guarantee getWithdrawGuarantee() {
+ return Guarantee.NATIVE;
+ }
+
+ @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);
+
+ OperationResult result = this.api.withdrawAsync(playerId, this.currencyName, amount.doubleValue(), ctx).join();
+
+ if (result != null && result.success()) {
+ 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.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());
+ }
+ }
+
+ @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.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.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(
+ 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..e779f73 100644
--- a/src/main/java/fr/traqueur/currencies/providers/ExperienceProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/ExperienceProvider.java
@@ -1,6 +1,9 @@
package fr.traqueur.currencies.providers;
+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;
@@ -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,46 @@ 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 Guarantee getWithdrawGuarantee() {
+ return Guarantee.NATIVE;
+ }
+
+ @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.");
+ }
+
+ 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.insufficientFunds(amount, current, Guarantee.NATIVE);
+ }
+
+ BigDecimal remaining = current.subtract(amount);
+ this.setTotalExperience(player, remaining.intValue());
+ 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 f5f0deb..a8603ca 100644
--- a/src/main/java/fr/traqueur/currencies/providers/ItemProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/ItemProvider.java
@@ -1,6 +1,9 @@
package fr.traqueur.currencies.providers;
+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;
import org.bukkit.inventory.ItemStack;
@@ -24,7 +27,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 +37,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 +47,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 +92,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 +105,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 +118,46 @@ private boolean hasInventoryFull(Player player) {
}
return slot == 0;
}
+
+ @Override
+ public Guarantee getWithdrawGuarantee() {
+ return Guarantee.NATIVE;
+ }
+
+ @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.insufficientFunds(amount, BigDecimal.valueOf(held), Guarantee.NATIVE);
+ }
+
+ this.removeItems(player, currencyItem, 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 023d324..56fbb52 100644
--- a/src/main/java/fr/traqueur/currencies/providers/LevelProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/LevelProvider.java
@@ -1,6 +1,9 @@
package fr.traqueur.currencies.providers;
+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;
@@ -32,4 +35,41 @@ public BigDecimal getBalance(UUID playerId) {
Player player = Bukkit.getPlayer(playerId);
return BigDecimal.valueOf(player != null ? player.getLevel() : 0);
}
+
+ @Override
+ public Guarantee getWithdrawGuarantee() {
+ return Guarantee.NATIVE;
+ }
+
+ @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.insufficientFunds(amount, BigDecimal.valueOf(current), Guarantee.NATIVE);
+ }
+
+ player.setLevel(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 3de7f77..80baa58 100644
--- a/src/main/java/fr/traqueur/currencies/providers/PlayerPointsProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/PlayerPointsProvider.java
@@ -1,6 +1,9 @@
package fr.traqueur.currencies.providers;
+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;
import org.bukkit.plugin.java.JavaPlugin;
@@ -35,4 +38,34 @@ public void withdraw(UUID playerId, BigDecimal amount, String reason) {
public BigDecimal getBalance(UUID playerId) {
return BigDecimal.valueOf(this.getAPI().look(playerId));
}
+
+ @Override
+ public Guarantee getWithdrawGuarantee() {
+ return Guarantee.DELEGATED;
+ }
+
+ @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.success(amount, BigDecimal.valueOf(this.getAPI().look(playerId)), Guarantee.DELEGATED);
+ }
+
+ 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) {
+ 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..e5b14c7 100644
--- a/src/main/java/fr/traqueur/currencies/providers/RedisEconomyProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/RedisEconomyProvider.java
@@ -2,7 +2,11 @@
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.Guarantee;
+import fr.traqueur.currencies.TransactionResult;
+import net.milkbowl.vault.economy.EconomyResponse;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
@@ -57,4 +61,53 @@ public BigDecimal getBalance(UUID playerId) {
}
return BigDecimal.ZERO;
}
+
+ @Override
+ public Guarantee getWithdrawGuarantee() {
+ return Guarantee.NATIVE;
+ }
+
+ @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.success(amount, BigDecimal.valueOf(response.balance), Guarantee.NATIVE);
+ }
+
+ if (response.type == EconomyResponse.ResponseType.NOT_IMPLEMENTED) {
+ return TransactionResult.unsupported(amount, "RedisEconomy does not implement withdrawPlayer.");
+ }
+
+ if (!currency.has(playerId, amount.doubleValue())) {
+ return TransactionResult.insufficientFunds(amount, BigDecimal.valueOf(currency.getBalance(playerId)), Guarantee.NATIVE);
+ }
+
+ 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..13f1c04 100644
--- a/src/main/java/fr/traqueur/currencies/providers/VaultProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/VaultProvider.java
@@ -1,7 +1,11 @@
package fr.traqueur.currencies.providers;
+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;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.plugin.RegisteredServiceProvider;
@@ -43,4 +47,46 @@ public BigDecimal getBalance(UUID playerId) {
OfflinePlayer offlinePlayer = Bukkit.getOfflinePlayer(playerId);
return BigDecimal.valueOf(this.getEconomy().getBalance(offlinePlayer));
}
+
+ @Override
+ public Guarantee getWithdrawGuarantee() {
+ return Guarantee.DELEGATED;
+ }
+
+ @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.success(amount, BigDecimal.valueOf(response.balance), Guarantee.DELEGATED);
+ }
+
+ 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.insufficientFunds(amount, balance, Guarantee.DELEGATED);
+ }
+
+ 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..c6b029d 100644
--- a/src/main/java/fr/traqueur/currencies/providers/VotingProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/VotingProvider.java
@@ -2,7 +2,11 @@
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.Guarantee;
+import fr.traqueur.currencies.TransactionResult;
import java.math.BigDecimal;
import java.util.UUID;
@@ -25,4 +29,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 Guarantee getWithdrawGuarantee() {
+ return Guarantee.DELEGATED;
+ }
+
+ @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.success(amount, BigDecimal.valueOf(user.getPoints()), Guarantee.DELEGATED);
+ }
+ 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) {
+ 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..3ae0dc7 100644
--- a/src/main/java/fr/traqueur/currencies/providers/ZEssentialsProvider.java
+++ b/src/main/java/fr/traqueur/currencies/providers/ZEssentialsProvider.java
@@ -3,7 +3,10 @@
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.Guarantee;
+import fr.traqueur.currencies.TransactionResult;
import org.bukkit.Bukkit;
import java.math.BigDecimal;
@@ -21,33 +24,59 @@ 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");
+ if (essentialsPlugin == null) {
+ throw new IllegalStateException("The plugin zEssentials is not installed.");
+ }
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 IllegalStateException("The zEssentials economy " + this.economyName + " was 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 Guarantee getWithdrawGuarantee() {
+ return Guarantee.DELEGATED;
+ }
+
+ @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.success(amount, this.getBalance(playerId), Guarantee.DELEGATED);
+ }
+ 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/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());
+ }
+}
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")));
+ }
+}