From c6f826bb4112236b413e736e8d2593fa457d10a6 Mon Sep 17 00:00:00 2001 From: 1robie <97293924+1robie@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:03:57 +0200 Subject: [PATCH 1/7] feat: Implement withdrawIfSufficient method for currency providers and add CurrenciesAPI for main thread scheduling --- build.gradle.kts | 4 +- gradle.properties | 2 +- readme.md | 130 +++++++++++++ .../fr/traqueur/currencies/Currencies.java | 84 ++++++++- .../fr/traqueur/currencies/CurrenciesAPI.java | 47 +++++ .../currencies/CurrencyArgumentChecks.java | 38 ++++ .../fr/traqueur/currencies/CurrencyLocks.java | 49 +++++ .../traqueur/currencies/CurrencyProvider.java | 127 +++++++++++++ .../traqueur/currencies/CurrencyRegistry.java | 163 ++++++++++++++++ .../currencies/TransactionResult.java | 174 ++++++++++++++++++ .../providers/ExcellentEconomyProvider.java | 68 +++++++ .../providers/ExperienceProvider.java | 48 ++++- .../currencies/providers/ItemProvider.java | 58 +++++- .../currencies/providers/LevelProvider.java | 39 ++++ .../providers/PlayerPointsProvider.java | 32 ++++ .../providers/RedisEconomyProvider.java | 52 ++++++ .../currencies/providers/VaultProvider.java | 45 +++++ .../currencies/providers/VotingProvider.java | 32 ++++ .../providers/ZEssentialsProvider.java | 38 +++- 19 files changed, 1196 insertions(+), 34 deletions(-) create mode 100644 src/main/java/fr/traqueur/currencies/CurrenciesAPI.java create mode 100644 src/main/java/fr/traqueur/currencies/CurrencyArgumentChecks.java create mode 100644 src/main/java/fr/traqueur/currencies/CurrencyLocks.java create mode 100644 src/main/java/fr/traqueur/currencies/CurrencyRegistry.java create mode 100644 src/main/java/fr/traqueur/currencies/TransactionResult.java diff --git a/build.gradle.kts b/build.gradle.kts index 376f295..a1c41b9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,7 +46,9 @@ dependencies { compileOnly("com.github.MilkBowl:VaultAPI:1.7") compileOnly("com.github.PlayerNguyen:OptEco:2.1.4b") compileOnly("com.willfp:EcoBits:1.8.4") - compileOnly("com.bencodez:votingplugin:6.17.2") + compileOnly("com.bencodez:votingplugin:6.17.2") { + exclude(group = "org.mozilla", module = "rhino") + } compileOnly("com.github.Emibergo02:RedisEconomy:4.3.19") compileOnly("io.lettuce:lettuce-core:6.4.0.RELEASE") compileOnly("su.nightexpress.excellenteconomy:ExcellentEconomy:2.8.0") diff --git a/gradle.properties b/gradle.properties index 1284b4e..eef2451 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -version=1.0.14 \ No newline at end of file +version=1.0.15 \ No newline at end of file diff --git a/readme.md b/readme.md index af1a420..f2a1775 100644 --- a/readme.md +++ b/readme.md @@ -136,6 +136,135 @@ Currencies.ZESSENTIALS.getBalance(player, "coins"); ``` +### Safe Purchases: `withdrawIfSufficient` + +`withdraw` does **not** check whether the player can afford the amount. Most backends will happily +drive a balance negative or silently clamp it to zero. Checking the balance first and then calling +`withdraw` is not safe either, because anything can happen between the two calls: a second click, a +second server, or an economy plugin that commits its writes asynchronously. That gap is a +double-spend. + +Use `withdrawIfSufficient` for anything that is paying for something. It performs the check and the +debit as one operation and tells you what happened: + +```java +TransactionResult result = Currencies.VAULT.withdrawIfSufficient( + playerId, new BigDecimal("1000"), "Shop purchase"); + +switch (result.getStatus()) { + case SUCCESS: + // The money is gone. Only now hand over the goods. + break; + case INSUFFICIENT_FUNDS: + player.sendMessage("You cannot afford this."); + break; + case UNSUPPORTED: + // The backend cannot do this at all. Nothing was debited. + break; + case FAILED: + // Something went wrong. Nothing was debited. + break; +} +``` + +**Nothing is ever debited unless the status is `SUCCESS`.** That is the only case where you should +hand out the goods. + +An asynchronous variant is available and never completes exceptionally, failures come back through +the result: + +```java +Currencies.VAULT.withdrawIfSufficientAsync(playerId, amount, "default", "Shop purchase") + .thenAccept(result -> { /* ... */ }); +``` + +### Atomicity Per Backend + +Some backends can refuse a withdrawal themselves, others cannot. When a backend cannot, the library +emulates the operation by locking around a balance read and a withdraw. That stops one server racing +itself, but it **cannot** stop a second server acting on the same shared economy. + +Ask before you rely on it, either up front or from the result: + +```java +if (!Currencies.VAULT.hasNativeConditionalWithdraw("default")) { + getLogger().warning("This currency cannot guarantee atomic purchases across servers."); +} + +result.isBackendGuaranteed(); // false when the library had to emulate the operation +``` + +| Currency | Atomic | Notes | +| --- | --- | --- | +| `VAULT` | yes | As atomic as the underlying economy plugin | +| `PLAYERPOINTS` | yes | `take` refuses when the balance is too low | +| `ZESSENTIALS` | yes | `withdraw` reports the outcome | +| `REDISECONOMY` | yes | Validated in Redis, so it holds across servers | +| `EXCELLENTECONOMY` | yes | Native async operation with a result | +| `VOTINGPLUGIN` | yes | `removePoints` reports the outcome | +| `ITEM`, `ZMENUITEMS` | yes | Player inventory, main thread only | +| `LEVEL`, `EXPERIENCE` | yes | Player state, main thread only | +| `COINSENGINE` | no | Its boolean means "currency found", not "could afford" | +| `ECOBITS` | no | `adjustBalance` returns nothing | +| `BEASTTOKENS` | no | `removeTokens` returns nothing | +| `ROYALEECONOMY` | no | `removeBalance` returns nothing | +| `ELEMENTALTOKENS`, `ELEMENTALGEMS` | no | `removeTokens` / `removeGems` return nothing | + +If you run a network where several servers share one economy database, only the currencies marked +atomic are safe against cross-server double spends. For the others the fix has to come from the +economy plugin itself. + +### Custom Economies + +`Currencies` is an enum, so it cannot be extended. To plug in your own economy, implement +`CurrencyProvider` and register the instance: + +```java +public class MyGemsProvider implements CurrencyProvider { + public void deposit(UUID playerId, BigDecimal amount, String reason) { /* ... */ } + public void withdraw(UUID playerId, BigDecimal amount, String reason) { /* ... */ } + public BigDecimal getBalance(UUID playerId) { /* ... */ } +} + +CurrencyRegistry.register("my_gems", new MyGemsProvider()); + +TransactionResult result = CurrencyRegistry.withdrawIfSufficient( + "my_gems", playerId, BigDecimal.TEN, "Shop purchase"); +``` + +When you override `withdrawIfSufficient`, build the result with the factory that matches who made +the decision: `TransactionResult.nativeSuccess(...)` / `nativeInsufficientFunds(...)` when your +backend checked the funds itself, or `emulatedSuccess(...)` / `emulatedInsufficientFunds(...)` when +you checked them yourself. `unsupported(...)` and `failed(...)` cover the rest. That is what +`isBackendGuaranteed()` reports back to the caller. + +To look a registered currency up, `CurrencyRegistry.require(name)` throws when there is none and +`CurrencyRegistry.find(name)` returns null. Use `registerOrReplace(...)` to deliberately swap an +implementation, for example on a config reload. + +Those three methods are all you have to write. Everything else has a default implementation, so an +existing provider keeps working unchanged. Two optional overrides are worth knowing about: + +- `hasNativeConditionalWithdraw()` and `withdrawIfSufficient(...)`: override both when your backend can + refuse a withdrawal itself. You get a real guarantee instead of the emulated one. Call + `CurrencyArgumentChecks.findProblem(playerId, amount)` first so your implementation rejects the same bad + inputs as every other provider. +- `requiresMainThread()`: **defaults to `true`**, because most Bukkit APIs are not thread safe. + Override it to return `false` only if your backend is documented as safe for concurrent access. + Leaving it `true` means `withdrawIfSufficientAsync` hops back to the main thread for you. + +### Asynchronous Access and `CurrenciesAPI.init` + +Scheduling work back onto the main server thread needs a plugin instance. If you intend to use the +asynchronous API with a main-thread-bound currency, call this once in `onEnable`: + +```java +CurrenciesAPI.init(this); +``` + +Without it, an asynchronous call on such a currency returns a `FAILED` result explaining what is +missing, rather than touching player state from the wrong thread. + ### Example Usage Here is a more complete example of how to use the `Currencies` class within a Minecraft plugin. In this example, we create an economy instance with `zEssentials` and provide a command that allows players to choose between `Vault` and `zEssentials` to deposit or withdraw an amount. @@ -221,3 +350,4 @@ public class MyPlugin extends JavaPlugin { } } +``` \ No newline at end of file diff --git a/src/main/java/fr/traqueur/currencies/Currencies.java b/src/main/java/fr/traqueur/currencies/Currencies.java index 53bb13f..3ee3f34 100644 --- a/src/main/java/fr/traqueur/currencies/Currencies.java +++ b/src/main/java/fr/traqueur/currencies/Currencies.java @@ -11,6 +11,7 @@ import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.concurrent.CompletableFuture; /** * The list of all the currencies that can be used in the plugin. @@ -92,6 +93,9 @@ public enum Currencies { EXCELLENTEECONOMY("ExcellentEconomy", ExcellentEconomyProvider.class, true, true, EXCELLENTECONOMY) ; + private final static String DEFAULT_CURRENCY_NAME = "default"; + private final static String DEFAULT_REASON = "No reason"; + static { Updater.checkUpdates(); } @@ -194,7 +198,7 @@ private boolean isDisable() { * @param reason The reason of the deposit. */ public void deposit(UUID playerId, BigDecimal amount, String reason) { - this.deposit(playerId, amount, "default", reason); + this.deposit(playerId, amount, DEFAULT_CURRENCY_NAME, reason); } /** @@ -205,7 +209,7 @@ public void deposit(UUID playerId, BigDecimal amount, String reason) { * @param reason The reason of the withdrawal. */ public void withdraw(UUID playerId, BigDecimal amount, String reason) { - this.withdraw(playerId, amount, "default", reason); + this.withdraw(playerId, amount, DEFAULT_CURRENCY_NAME, reason); } /** @@ -215,7 +219,7 @@ public void withdraw(UUID playerId, BigDecimal amount, String reason) { * @param amount The amount of money to add. */ public void deposit(UUID playerId, BigDecimal amount) { - this.deposit(playerId, amount, "default", "No reason"); + this.deposit(playerId, amount, DEFAULT_CURRENCY_NAME, DEFAULT_REASON); } /** @@ -225,7 +229,7 @@ public void deposit(UUID playerId, BigDecimal amount) { * @param amount The amount of money to remove. */ public void withdraw(UUID playerId, BigDecimal amount) { - this.withdraw(playerId, amount, "default", "No reason"); + this.withdraw(playerId, amount, DEFAULT_CURRENCY_NAME, DEFAULT_REASON); } /** @@ -235,7 +239,7 @@ public void withdraw(UUID playerId, BigDecimal amount) { * @return The balance of the player. */ public BigDecimal getBalance(UUID playerId) { - return getBalance(playerId, "default"); + return this.getBalance(playerId, DEFAULT_CURRENCY_NAME); } /** @@ -276,19 +280,79 @@ public BigDecimal getBalance(UUID playerId, String currencyName) { return this.providers.get(currencyName).getBalance(playerId); } + /** + * Remove some money from a player, but only if the player can actually afford it. + * + *

This is the operation to use for a purchase. Unlike calling {@link #getBalance} and then + * {@link #withdraw}, nothing can slip in between the check and the debit.

+ * + * @param playerId The UUID of the player to debit. + * @param amount The amount to debit, must be strictly positive. + * @param reason The reason of the withdrawal. + * @return The outcome. Nothing is debited unless the status is + * {@link TransactionResult.Status#SUCCESS}. + */ + public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) { + return this.withdrawIfSufficient(playerId, amount, DEFAULT_CURRENCY_NAME, reason); + } + + /** + * Remove some money from a player, but only if the player can actually afford it. + * + * @param playerId The UUID of the player to debit. + * @param amount The amount to debit, must be strictly positive. + * @param currencyName The name of the currency. + * @param reason The reason of the withdrawal. + * @return The outcome. Nothing is debited unless the status is + * {@link TransactionResult.Status#SUCCESS}. + */ + public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String currencyName, String reason) { + this.canBeUse(currencyName); + return this.providers.get(currencyName).withdrawIfSufficient(playerId, amount, reason); + } + + /** + * Asynchronous variant of {@link #withdrawIfSufficient(UUID, BigDecimal, String, String)}. + * + * @param playerId The UUID of the player to debit. + * @param amount The amount to debit, must be strictly positive. + * @param currencyName The name of the currency. + * @param reason The reason of the withdrawal. + * @return A future completed with the outcome. + */ + public CompletableFuture withdrawIfSufficientAsync(UUID playerId, BigDecimal amount, String currencyName, String reason) { + this.canBeUse(currencyName); + return this.providers.get(currencyName).withdrawIfSufficientAsync(playerId, amount, reason); + } + + /** + * Whether this currency can check the balance and apply the debit as one indivisible + * operation, rather than having the library emulate it. + * + *

Worth checking on a network where several servers share one economy: an emulated + * operation is only protected against races inside this server.

+ * + * @param currencyName The name of the currency. + * @return True when the backend itself guarantees the operation. + */ + public boolean hasNativeConditionalWithdraw(String currencyName) { + this.canBeUse(currencyName); + return this.providers.get(currencyName).hasNativeConditionalWithdraw(); + } + private void canBeUse(String currencyName) { if (this.isDisable()) { throw new IllegalStateException("The plugin " + this.name + " is not enable."); } - if (autoCreate) { + if (this.autoCreate) { - if (currencySpecific) { - registerProvider(currencyName, currencyName); + if (this.currencySpecific) { + this.registerProvider(currencyName, currencyName); } else { - registerProvider(currencyName); + this.registerProvider(currencyName); } } else if (!this.providers.containsKey(currencyName)) { - String currency = name.equalsIgnoreCase("default") ? "" : " and for the currency " + name; + String currency = this.name.equalsIgnoreCase(DEFAULT_CURRENCY_NAME) ? "" : " and for the currency " + name; throw new IllegalStateException("You must create the provider for the plugin " + this.name + currency + " before using it."); } } diff --git a/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java b/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java new file mode 100644 index 0000000..02958c3 --- /dev/null +++ b/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java @@ -0,0 +1,47 @@ +package fr.traqueur.currencies; + +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; + +public final class CurrenciesAPI { + + private static volatile Plugin plugin; + + private CurrenciesAPI() { + } + + /** + * Register the plugin instance used to schedule work on the main server thread. + * + * @param owningPlugin The plugin instance, must not be null. + */ + public static void init(Plugin owningPlugin) { + if (owningPlugin == null) { + throw new IllegalArgumentException("The plugin instance cannot be null."); + } + if (plugin != null) { + throw new IllegalStateException("The plugin instance has already been set by " + plugin.getName() + "."); + } + plugin = owningPlugin; + } + + /** + * @return The registered plugin instance, or null when {@link #init(Plugin)} was never called. + */ + public static Plugin getPlugin() { + return plugin; + } + + /** + * Whether the caller is on the main server thread. + * + * @return True when the caller is running on the main server thread. + */ + static boolean isMainThread() { + try { + return Bukkit.isPrimaryThread(); + } catch (Throwable throwable) { + return false; + } + } +} diff --git a/src/main/java/fr/traqueur/currencies/CurrencyArgumentChecks.java b/src/main/java/fr/traqueur/currencies/CurrencyArgumentChecks.java new file mode 100644 index 0000000..340cf18 --- /dev/null +++ b/src/main/java/fr/traqueur/currencies/CurrencyArgumentChecks.java @@ -0,0 +1,38 @@ +package fr.traqueur.currencies; + +import java.math.BigDecimal; +import java.util.UUID; + +/** + * Argument checks shared by every conditional currency operation. + * + *

Custom providers that override + * {@link CurrencyProvider#withdrawIfSufficient(UUID, BigDecimal, String)} should call + * {@link #findProblem(UUID, BigDecimal)} first, so that every implementation rejects the same + * nonsensical inputs in the same way.

+ */ +public final class CurrencyArgumentChecks { + + private CurrencyArgumentChecks() { + } + + /** + * Looks for a problem with the arguments to a conditional currency operation. + * + * @param playerId The player. + * @param amount The requested amount. + * @return A result describing the problem, or null when the arguments are usable. + */ + public static TransactionResult findProblem(UUID playerId, BigDecimal amount) { + if (playerId == null) { + return TransactionResult.failed(amount, "The player UUID cannot be null."); + } + if (amount == null) { + return TransactionResult.failed(BigDecimal.ZERO, "The amount cannot be null."); + } + if (amount.signum() <= 0) { + return TransactionResult.failed(amount, "The amount must be strictly positive, was " + amount + "."); + } + return null; + } +} diff --git a/src/main/java/fr/traqueur/currencies/CurrencyLocks.java b/src/main/java/fr/traqueur/currencies/CurrencyLocks.java new file mode 100644 index 0000000..d62bf73 --- /dev/null +++ b/src/main/java/fr/traqueur/currencies/CurrencyLocks.java @@ -0,0 +1,49 @@ +package fr.traqueur.currencies; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +final class CurrencyLocks { + + private static final int STRIPES = 64; + private static final long LOCK_TIMEOUT_MILLIS = 250L; + private static final ReentrantLock[] LOCKS = new ReentrantLock[STRIPES]; + + static { + for (int i = 0; i < STRIPES; i++) { + LOCKS[i] = new ReentrantLock(); + } + } + + private CurrencyLocks() { + } + + /** + * Resolve the lock guarding a given provider and player pair. + * + * @param provider The provider performing the operation. + * @param playerId The player being debited. + * @return The lock to use. + */ + static ReentrantLock lockFor(CurrencyProvider provider, UUID playerId) { + int hash = System.identityHashCode(provider) * 31 + (playerId == null ? 0 : playerId.hashCode()); + hash ^= (hash >>> 16); + return LOCKS[hash & (STRIPES - 1)]; + } + + /** + * Try to acquire a lock within the configured timeout. + * + * @param lock The lock to acquire. + * @return True when the lock was acquired and must be released by the caller. + */ + static boolean tryLock(ReentrantLock lock) { + try { + return lock.tryLock(LOCK_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return false; + } + } +} diff --git a/src/main/java/fr/traqueur/currencies/CurrencyProvider.java b/src/main/java/fr/traqueur/currencies/CurrencyProvider.java index 49873f8..7afb949 100644 --- a/src/main/java/fr/traqueur/currencies/CurrencyProvider.java +++ b/src/main/java/fr/traqueur/currencies/CurrencyProvider.java @@ -1,7 +1,11 @@ package fr.traqueur.currencies; +import org.bukkit.Bukkit; + import java.math.BigDecimal; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.locks.ReentrantLock; /** * Interface used to interact with a currency provider. @@ -36,4 +40,127 @@ public interface CurrencyProvider { */ BigDecimal getBalance(UUID playerId); + /** + * Whether this provider can check the balance and apply the debit as one indivisible + * operation. + * + *

When this returns false, {@link #withdrawIfSufficient} still works but is emulated by + * the library: the sequence is serialized inside this JVM, which stops one server racing + * itself, but it cannot stop a second server acting on the same shared economy.

+ * + * @return True when the backend itself guarantees the operation. + */ + default boolean hasNativeConditionalWithdraw() { + return false; + } + + /** + * Whether this provider must be used from the main server thread. + * + *

This defaults to true on purpose. Most Bukkit plugin APIs are not thread safe, + * and a provider that reads or writes live player state, such as inventory contents or + * experience levels, will corrupt that state if it is touched from another thread. Assuming + * the unsafe case by default means an unknown third party provider is never called off the + * main thread by accident.

+ * + *

Override this to return false only when the backend is documented as safe for + * concurrent access, for example one that talks to Redis or to its own thread safe storage. + * Doing so lets {@link #withdrawIfSufficientAsync} keep the work off the main thread.

+ * + * @return True when every call has to happen on the main server thread. + */ + default boolean requiresMainThread() { + return true; + } + + /** + * Debit a player, but only if the funds are actually available. + * + *

This is the operation to use for a purchase. Unlike a balance check followed by a + * separate {@link #withdraw}, nothing can slip between the two steps.

+ * + *

The default implementation emulates the operation by reading the balance and then + * withdrawing, with the whole sequence held under a lock so that two threads cannot both + * observe the same balance and both debit. The returned result reports + * {@link TransactionResult#isBackendGuaranteed()} as false to make that limitation visible.

+ * + * @param playerId The UUID of the player to debit. + * @param amount The amount to debit, must be strictly positive. + * @param reason The reason of the withdrawal. + * @return The outcome. Nothing is debited unless the status is + * {@link TransactionResult.Status#SUCCESS}. + */ + default TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return invalid; + } + + ReentrantLock lock = CurrencyLocks.lockFor(this, playerId); + if (!CurrencyLocks.tryLock(lock)) { + return TransactionResult.failed(amount, "Timed out waiting for a concurrent operation on the same balance."); + } + + try { + BigDecimal balance = this.getBalance(playerId); + if (balance == null) { + balance = BigDecimal.ZERO; + } + + if (balance.compareTo(amount) < 0) { + return TransactionResult.emulatedInsufficientFunds(amount, balance); + } + + this.withdraw(playerId, amount, reason); + return TransactionResult.emulatedSuccess(amount, balance.subtract(amount)); + } catch (Exception exception) { + return TransactionResult.failed(amount, "The backend threw while withdrawing: " + exception.getMessage()); + } finally { + lock.unlock(); + } + } + + /** + * Asynchronous variant of {@link #withdrawIfSufficient}. + * + *

For a provider that must run on the main server thread, the work is scheduled back onto + * it, which requires {@link CurrenciesAPI#init(org.bukkit.plugin.Plugin)} to have been called. + * Without it the returned result is a failure rather than an unsafe off thread call.

+ * + * @param playerId The UUID of the player to debit. + * @param amount The amount to debit, must be strictly positive. + * @param reason The reason of the withdrawal. + * @return A future completed with the outcome. The future itself never completes + * exceptionally, failures are reported through the result. + */ + default CompletableFuture withdrawIfSufficientAsync(UUID playerId, BigDecimal amount, String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return CompletableFuture.completedFuture(invalid); + } + + if (!this.requiresMainThread()) { + return CompletableFuture.supplyAsync(() -> CurrencyProvider.this.withdrawIfSufficient(playerId, amount, reason)); + } + + if (CurrenciesAPI.isMainThread()) { + return CompletableFuture.completedFuture(this.withdrawIfSufficient(playerId, amount, reason)); + } + + if (CurrenciesAPI.getPlugin() == null) { + return CompletableFuture.completedFuture(TransactionResult.failed(amount, + "This currency must be used on the main server thread. Call CurrenciesAPI.init(plugin) " + + "to enable asynchronous access to it.")); + } + + final CompletableFuture future = new CompletableFuture(); + Bukkit.getScheduler().runTask(CurrenciesAPI.getPlugin(), () -> { + try { + future.complete(CurrencyProvider.this.withdrawIfSufficient(playerId, amount, reason)); + } catch (Exception exception) { + future.complete(TransactionResult.failed(amount, "The backend threw while withdrawing: " + exception.getMessage())); + } + }); + return future; + } } diff --git a/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java b/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java new file mode 100644 index 0000000..27b2787 --- /dev/null +++ b/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java @@ -0,0 +1,163 @@ +package fr.traqueur.currencies; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.math.BigDecimal; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Registry for currencies that are not one of the built in {@link Currencies} constants. + * + *
{@code
+ * CurrencyRegistry.register("my_gems", new MyGemsProvider());
+ *
+ * TransactionResult result = CurrencyRegistry.require("my_gems")
+ *         .withdrawIfSufficient(playerId, BigDecimal.TEN, "Shop purchase");
+ * if (result.isSuccess()) {
+ *     // give the goods
+ * }
+ * }
+ * + *

A custom provider only has to implement {@code deposit}, {@code withdraw} and + * {@code getBalance}. It inherits an emulated {@link CurrencyProvider#withdrawIfSufficient} which + * is serialized inside this JVM. If the backend can refuse a withdrawal itself, override + * {@code withdrawIfSufficient} and {@code hasNativeConditionalWithdraw} to get a real guarantee. If the + * backend is safe to use off the main server thread, also override + * {@link CurrencyProvider#requiresMainThread()} to return false.

+ */ +public final class CurrencyRegistry { + + private static final Map PROVIDERS = new ConcurrentHashMap(); + + private CurrencyRegistry() { + } + + /** + * Register a custom provider under a name. + * + * @param name The name used to look the provider up. Case-insensitive. + * @param provider The provider instance. + * @throws IllegalArgumentException if the name or the provider is null or the name is blank. + * @throws IllegalStateException if a different provider is already registered under this name. + */ + public static void register(String name, CurrencyProvider provider) { + String key = normalize(name); + if (provider == null) { + throw new IllegalArgumentException("The provider cannot be null."); + } + + CurrencyProvider existing = PROVIDERS.putIfAbsent(key, provider); + if (existing != null && existing != provider) { + throw new IllegalStateException("A different provider is already registered for the currency " + name + "."); + } + } + + /** + * Register a custom provider, replacing any provider already registered under this name. + * + * @param name The name used to look the provider up. Case-insensitive. + * @param provider The provider instance. + * @return The provider that was previously registered, or null. + */ + public static CurrencyProvider registerOrReplace(String name, CurrencyProvider provider) { + String key = normalize(name); + if (provider == null) { + throw new IllegalArgumentException("The provider cannot be null."); + } + return PROVIDERS.put(key, provider); + } + + /** + * Remove a registered provider. + * + * @param name The name it was registered under. + * @return The removed provider, or null when nothing was registered. + */ + public static CurrencyProvider unregister(String name) { + return PROVIDERS.remove(normalize(name)); + } + + /** + * Look a registered provider up, failing when there is none. + * + * @param name The name it was registered under. + * @return The provider, never null. + * @throws IllegalStateException if nothing is registered under this name. + */ + @NotNull + public static CurrencyProvider require(String name) { + CurrencyProvider provider = find(name); + if (provider == null) { + throw new IllegalStateException("No custom currency is registered under the name " + name + + ". Register one with CurrencyRegistry.register(name, provider) first."); + } + return provider; + } + + /** + * Look a registered provider up, returning null when there is none. + * + * @param name The name it was registered under. + * @return The provider, or null. + */ + @Nullable + public static CurrencyProvider find(String name) { + return PROVIDERS.get(normalize(name)); + } + + /** + * @param name The name to look up. + * @return True when a provider is registered under this name. + */ + public static boolean isRegistered(String name) { + return name != null && PROVIDERS.containsKey(normalize(name)); + } + + /** + * @return The names of every registered custom currency. The returned set is a snapshot. + */ + @NotNull + public static Set getRegisteredNames() { + return Collections.unmodifiableSet(new java.util.HashSet<>(PROVIDERS.keySet())); + } + + /** + * Debit a registered custom currency, but only if the funds are available. + * + * @param name The name the provider was registered under. + * @param playerId The UUID of the player to debit. + * @param amount The amount to debit, must be strictly positive. + * @param reason The reason of the withdrawal. + * @return The outcome. Nothing is debited unless the status is + * {@link TransactionResult.Status#SUCCESS}. + */ + public static TransactionResult withdrawIfSufficient(String name, UUID playerId, BigDecimal amount, String reason) { + return require(name).withdrawIfSufficient(playerId, amount, reason); + } + + /** + * Asynchronous variant of {@link #withdrawIfSufficient(String, UUID, BigDecimal, String)}. + * + * @param name The name the provider was registered under. + * @param playerId The UUID of the player to debit. + * @param amount The amount to debit, must be strictly positive. + * @param reason The reason of the withdrawal. + * @return A future completed with the outcome. + */ + public static CompletableFuture withdrawIfSufficientAsync(String name, UUID playerId, BigDecimal amount, String reason) { + return require(name).withdrawIfSufficientAsync(playerId, amount, reason); + } + + private static String normalize(String name) { + if (name == null || name.trim().isEmpty()) { + throw new IllegalArgumentException("The currency name cannot be null or blank."); + } + return name.trim().toLowerCase(java.util.Locale.ROOT); + } +} diff --git a/src/main/java/fr/traqueur/currencies/TransactionResult.java b/src/main/java/fr/traqueur/currencies/TransactionResult.java new file mode 100644 index 0000000..99adf53 --- /dev/null +++ b/src/main/java/fr/traqueur/currencies/TransactionResult.java @@ -0,0 +1,174 @@ +package fr.traqueur.currencies; + +import java.math.BigDecimal; + +public final class TransactionResult { + + public enum Status { + + /** + * The funds were available and the debit was applied. + */ + SUCCESS, + + /** + * The player did not have enough funds. Nothing was debited. + */ + INSUFFICIENT_FUNDS, + + /** + * The backend does not implement this operation at all. Nothing was debited. + * Callers must decide for themselves whether to fall back to a non-atomic + * balance check followed by a plain withdraw, or to refuse the operation. + */ + UNSUPPORTED, + + /** + * The operation could not be completed for any other reason, for example the + * economy plugin returned an error or the player data could not be loaded. + * Nothing was debited. + */ + FAILED + } + + private final Status status; + private final BigDecimal amount; + private final BigDecimal balance; + private final String errorMessage; + private final boolean backendGuaranteed; + + private TransactionResult(Status status, BigDecimal amount, BigDecimal balance, String errorMessage, boolean backendGuaranteed) { + this.status = status; + this.amount = amount == null ? BigDecimal.ZERO : amount; + this.balance = balance; + this.errorMessage = errorMessage; + this.backendGuaranteed = backendGuaranteed; + } + + /** + * Build a successful result for a backend that applied the check and the debit itself. + * + *

Use this from a provider that overrides + * {@link CurrencyProvider#withdrawIfSufficient(java.util.UUID, BigDecimal, String)} because its + * backend can refuse a withdrawal on its own.

+ * + * @param amount The amount that was debited. + * @param balance The resulting balance, or null when the backend does not report it. + * @return The result. + */ + public static TransactionResult nativeSuccess(BigDecimal amount, BigDecimal balance) { + return new TransactionResult(Status.SUCCESS, amount, balance, null, true); + } + + /** + * Build a successful result for an operation the library emulated with a balance read followed + * by a withdraw. + * + * @param amount The amount that was debited. + * @param balance The resulting balance, or null when it is not known. + * @return The result. + */ + public static TransactionResult emulatedSuccess(BigDecimal amount, BigDecimal balance) { + return new TransactionResult(Status.SUCCESS, amount, balance, null, false); + } + + /** + * Build an insufficient funds result for a backend that made the decision itself. Nothing was + * debited. + * + * @param amount The amount that was requested. + * @param balance The balance that was observed, or null when it is not known. + * @return The result. + */ + public static TransactionResult nativeInsufficientFunds(BigDecimal amount, BigDecimal balance) { + return new TransactionResult(Status.INSUFFICIENT_FUNDS, amount, balance, null, true); + } + + /** + * Build an insufficient funds result for a check the library performed itself. Nothing was + * debited. + * + * @param amount The amount that was requested. + * @param balance The balance that was observed, or null when it is not known. + * @return The result. + */ + public static TransactionResult emulatedInsufficientFunds(BigDecimal amount, BigDecimal balance) { + return new TransactionResult(Status.INSUFFICIENT_FUNDS, amount, balance, null, false); + } + + /** + * Build a result for a backend that cannot perform this operation. Nothing was debited. + * + * @param amount The amount that was requested. + * @param errorMessage A human readable explanation. + * @return The result. + */ + public static TransactionResult unsupported(BigDecimal amount, String errorMessage) { + return new TransactionResult(Status.UNSUPPORTED, amount, null, errorMessage, false); + } + + /** + * Build a failed result. Nothing was debited. + * + * @param amount The amount that was requested. + * @param errorMessage A human readable explanation. + * @return The result. + */ + public static TransactionResult failed(BigDecimal amount, String errorMessage) { + return new TransactionResult(Status.FAILED, amount, null, errorMessage, false); + } + + /** + * @return The outcome of the operation. + */ + public Status getStatus() { + return this.status; + } + + /** + * @return True only when the funds were actually debited. + */ + public boolean isSuccess() { + return this.status == Status.SUCCESS; + } + + /** + * @return True when the backend guaranteed that the check and the debit were indivisible. + * False means the library emulated the operation and it is only safe against concurrent + * access from inside this server. + */ + public boolean isBackendGuaranteed() { + return this.backendGuaranteed; + } + + /** + * @return The amount that was requested. + */ + public BigDecimal getAmount() { + return this.amount; + } + + /** + * @return The resulting balance, or null when the backend does not report one. + */ + public BigDecimal getBalance() { + return this.balance; + } + + /** + * @return A human-readable explanation for a failure, or null. + */ + public String getErrorMessage() { + return this.errorMessage; + } + + @Override + public String toString() { + return "TransactionResult{status=" + this.status + + ", amount=" + this.amount + + ", balance=" + this.balance + + ", backendGuaranteed=" + this.backendGuaranteed + + (this.errorMessage == null ? "" : ", error='" + this.errorMessage + "'") + + '}'; + } +} diff --git a/src/main/java/fr/traqueur/currencies/providers/ExcellentEconomyProvider.java b/src/main/java/fr/traqueur/currencies/providers/ExcellentEconomyProvider.java index 3b26755..4116f7e 100644 --- a/src/main/java/fr/traqueur/currencies/providers/ExcellentEconomyProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/ExcellentEconomyProvider.java @@ -1,13 +1,18 @@ package fr.traqueur.currencies.providers; +import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.TransactionResult; import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.plugin.RegisteredServiceProvider; import su.nightexpress.excellenteconomy.api.ExcellentEconomyAPI; import su.nightexpress.excellenteconomy.api.currency.operation.OperationContext; +import su.nightexpress.excellenteconomy.api.currency.operation.OperationResult; import java.math.BigDecimal; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiFunction; import java.util.UUID; public class ExcellentEconomyProvider implements CurrencyProvider { @@ -53,4 +58,67 @@ public BigDecimal getBalance(UUID playerId) { : this.api.getBalanceAsync(playerId, this.currencyName).join(); return BigDecimal.valueOf(raw); } + + @Override + public boolean hasNativeConditionalWithdraw() { + return true; + } + + @Override + public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return invalid; + } + + try { + OperationContext ctx = OperationContext.custom(reason); + Player player = Bukkit.getPlayer(playerId); + + boolean success; + if (player != null) { + success = this.api.withdraw(player, this.currencyName, amount.doubleValue(), ctx); + } else { + OperationResult result = this.api.withdrawAsync(playerId, this.currencyName, amount.doubleValue(), ctx).join(); + success = result != null && result.success(); + } + + if (success) { + return TransactionResult.nativeSuccess(amount, this.getBalance(playerId)); + } + return TransactionResult.nativeInsufficientFunds(amount, this.getBalance(playerId)); + } catch (Exception exception) { + return TransactionResult.failed(amount, "ExcellentEconomy threw while withdrawing: " + exception.getMessage()); + } + } + + @Override + public CompletableFuture withdrawIfSufficientAsync(UUID playerId, BigDecimal amount, String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return CompletableFuture.completedFuture(invalid); + } + + try { + OperationContext ctx = OperationContext.custom(reason); + return this.api.withdrawAsync(playerId, this.currencyName, amount.doubleValue(), ctx) + .handle((result, throwable) -> { + if (throwable != null) { + return TransactionResult.failed(amount, "ExcellentEconomy threw while withdrawing: " + throwable.getMessage()); + } + if (result != null && result.success()) { + return TransactionResult.nativeSuccess(amount, null); + } + return TransactionResult.nativeInsufficientFunds(amount, null); + }); + } catch (Exception exception) { + return CompletableFuture.completedFuture( + TransactionResult.failed(amount, "ExcellentEconomy threw while withdrawing: " + exception.getMessage())); + } + } + + @Override + public boolean requiresMainThread() { + return false; + } } diff --git a/src/main/java/fr/traqueur/currencies/providers/ExperienceProvider.java b/src/main/java/fr/traqueur/currencies/providers/ExperienceProvider.java index bad37b2..47c8afc 100644 --- a/src/main/java/fr/traqueur/currencies/providers/ExperienceProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/ExperienceProvider.java @@ -1,7 +1,10 @@ package fr.traqueur.currencies.providers; +import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.TransactionResult; import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; import java.math.BigDecimal; @@ -13,8 +16,8 @@ public class ExperienceProvider implements CurrencyProvider { public void deposit(UUID playerId, BigDecimal amount, String reason) { Player player = Bukkit.getPlayer(playerId); if (player != null) { - BigDecimal totalExperience = BigDecimal.valueOf(getTotalExperience(player)); - setTotalExperience(player, totalExperience.add(amount).intValue()); + BigDecimal totalExperience = BigDecimal.valueOf(this.getTotalExperience(player)); + this.setTotalExperience(player, totalExperience.add(amount).intValue()); } } @@ -22,16 +25,16 @@ public void deposit(UUID playerId, BigDecimal amount, String reason) { public void withdraw(UUID playerId, BigDecimal amount, String reason) { Player player = Bukkit.getPlayer(playerId); if (player != null) { - BigDecimal totalExperience = BigDecimal.valueOf(getTotalExperience(player)); + BigDecimal totalExperience = BigDecimal.valueOf(this.getTotalExperience(player)); BigDecimal newExperience = totalExperience.subtract(amount); - setTotalExperience(player, newExperience.max(BigDecimal.ZERO).intValue()); + this.setTotalExperience(player, newExperience.max(BigDecimal.ZERO).intValue()); } } @Override public BigDecimal getBalance(UUID playerId) { Player player = Bukkit.getPlayer(playerId); - return player != null ? BigDecimal.valueOf(getTotalExperience(player)) : BigDecimal.ZERO; + return player != null ? BigDecimal.valueOf(this.getTotalExperience(player)) : BigDecimal.ZERO; } private void setTotalExperience(Player player, int experience) { @@ -41,7 +44,7 @@ private void setTotalExperience(Player player, int experience) { player.setTotalExperience(0); int currentExperience = experience; while (currentExperience > 0) { - int j = getExpAtLevel(player); + int j = this.getExpAtLevel(player); currentExperience -= j; if (currentExperience >= 0) { player.giveExp(j); @@ -54,7 +57,7 @@ private void setTotalExperience(Player player, int experience) { } private int getExpAtLevel(Player player) { - return getExpAtLevel(player.getLevel()); + return this.getExpAtLevel(player.getLevel()); } private int getExpAtLevel(int experience) { @@ -64,15 +67,42 @@ private int getExpAtLevel(int experience) { } private int getTotalExperience(Player player) { - int experience = Math.round(getExpAtLevel(player) * player.getExp()); + int experience = Math.round(this.getExpAtLevel(player) * player.getExp()); int playerLevel = player.getLevel(); while (playerLevel > 0) { playerLevel--; - experience += getExpAtLevel(playerLevel); + experience += this.getExpAtLevel(playerLevel); } if (experience < 0) { experience = Integer.MAX_VALUE; } return experience; } + + @Override + public boolean hasNativeConditionalWithdraw() { + return true; + } + + @Override + public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return invalid; + } + + Player player = Bukkit.getPlayer(playerId); + if (player == null) { + return TransactionResult.failed(amount, "Experience can only be taken from an online player."); + } + + BigDecimal current = BigDecimal.valueOf(this.getTotalExperience(player)); + if (current.compareTo(amount) < 0) { + return TransactionResult.nativeInsufficientFunds(amount, current); + } + + BigDecimal remaining = current.subtract(amount); + this.setTotalExperience(player, remaining.intValue()); + return TransactionResult.nativeSuccess(amount, remaining); + } } diff --git a/src/main/java/fr/traqueur/currencies/providers/ItemProvider.java b/src/main/java/fr/traqueur/currencies/providers/ItemProvider.java index f5f0deb..a71d442 100644 --- a/src/main/java/fr/traqueur/currencies/providers/ItemProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/ItemProvider.java @@ -1,6 +1,8 @@ package fr.traqueur.currencies.providers; +import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.TransactionResult; import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; @@ -24,7 +26,7 @@ public ItemProvider(Plugin plugin, ItemStack itemStack) { public void deposit(UUID playerId, BigDecimal amount, String reason) { Player player = Bukkit.getPlayer(playerId); if (player != null) { - giveItem(player, amount.intValue(), this.itemStack); + this.giveItem(player, amount.intValue(), this.itemStack); } else{ this.plugin.getLogger().severe("Deposit items to " + playerId + " but is offline"); } @@ -34,7 +36,7 @@ public void deposit(UUID playerId, BigDecimal amount, String reason) { public void withdraw(UUID playerId, BigDecimal amount, String reason) { Player player = Bukkit.getPlayer(playerId); if (player != null) { - removeItems(player, this.itemStack, amount.intValue()); + this.removeItems(player, this.itemStack, amount.intValue()); } else { this.plugin.getLogger().severe("Withdraw items from " + playerId + " but is offline"); } @@ -44,7 +46,7 @@ public void withdraw(UUID playerId, BigDecimal amount, String reason) { public BigDecimal getBalance(UUID playerId) { Player player = Bukkit.getPlayer(playerId); if (player != null) { - return BigDecimal.valueOf(getAmount(player, this.itemStack)); + return BigDecimal.valueOf(this.getAmount(player, this.itemStack)); } else return BigDecimal.ZERO; } @@ -89,11 +91,11 @@ protected void giveItem(Player player, long value, ItemStack itemStack) { if (value > 64) { value -= 64; itemStack.setAmount(64); - give(player, itemStack); - giveItem(player, value, itemStack); + this.give(player, itemStack); + this.giveItem(player, value, itemStack); } else { itemStack.setAmount((int) value); - give(player, itemStack); + this.give(player, itemStack); } } @@ -102,7 +104,7 @@ public ItemStack getItemStack(Player player) { } private void give(Player player, ItemStack item) { - if (hasInventoryFull(player)) player.getWorld().dropItem(player.getLocation(), item); + if (this.hasInventoryFull(player)) player.getWorld().dropItem(player.getLocation(), item); else player.getInventory().addItem(item); } @@ -115,4 +117,46 @@ private boolean hasInventoryFull(Player player) { } return slot == 0; } + + @Override + public boolean hasNativeConditionalWithdraw() { + return true; + } + + @Override + public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return invalid; + } + + Player player = Bukkit.getPlayer(playerId); + if (player == null) { + return TransactionResult.failed(amount, "Items can only be taken from an online player."); + } + + if (amount.stripTrailingZeros().scale() > 0) { + return TransactionResult.failed(amount, "An item currency only supports whole amounts, got " + amount + "."); + } + + int cost; + try { + cost = amount.intValueExact(); + } catch (ArithmeticException exception) { + return TransactionResult.failed(amount, "The amount does not fit in an integer item count: " + amount + "."); + } + + ItemStack currencyItem = this.getItemStack(player); + if (currencyItem == null) { + return TransactionResult.failed(amount, "The currency item could not be resolved for " + player.getName() + "."); + } + + int held = this.getAmount(player, currencyItem); + if (held < cost) { + return TransactionResult.nativeInsufficientFunds(amount, BigDecimal.valueOf(held)); + } + + this.removeItems(player, currencyItem, cost); + return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(held - cost)); + } } diff --git a/src/main/java/fr/traqueur/currencies/providers/LevelProvider.java b/src/main/java/fr/traqueur/currencies/providers/LevelProvider.java index 023d324..3e60725 100644 --- a/src/main/java/fr/traqueur/currencies/providers/LevelProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/LevelProvider.java @@ -1,6 +1,8 @@ package fr.traqueur.currencies.providers; +import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.TransactionResult; import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -32,4 +34,41 @@ public BigDecimal getBalance(UUID playerId) { Player player = Bukkit.getPlayer(playerId); return BigDecimal.valueOf(player != null ? player.getLevel() : 0); } + + @Override + public boolean hasNativeConditionalWithdraw() { + return true; + } + + @Override + public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return invalid; + } + + Player player = Bukkit.getPlayer(playerId); + if (player == null) { + return TransactionResult.failed(amount, "Levels can only be taken from an online player."); + } + + if (amount.stripTrailingZeros().scale() > 0) { + return TransactionResult.failed(amount, "Levels only support whole amounts, got " + amount + "."); + } + + int cost; + try { + cost = amount.intValueExact(); + } catch (ArithmeticException exception) { + return TransactionResult.failed(amount, "The amount does not fit in an integer level: " + amount + "."); + } + + int current = player.getLevel(); + if (current < cost) { + return TransactionResult.nativeInsufficientFunds(amount, BigDecimal.valueOf(current)); + } + + player.setLevel(current - cost); + return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(current - cost)); + } } diff --git a/src/main/java/fr/traqueur/currencies/providers/PlayerPointsProvider.java b/src/main/java/fr/traqueur/currencies/providers/PlayerPointsProvider.java index 3de7f77..8b10053 100644 --- a/src/main/java/fr/traqueur/currencies/providers/PlayerPointsProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/PlayerPointsProvider.java @@ -1,6 +1,8 @@ package fr.traqueur.currencies.providers; +import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.TransactionResult; import org.black_ixx.playerpoints.PlayerPoints; import org.black_ixx.playerpoints.PlayerPointsAPI; import org.bukkit.plugin.java.JavaPlugin; @@ -35,4 +37,34 @@ public void withdraw(UUID playerId, BigDecimal amount, String reason) { public BigDecimal getBalance(UUID playerId) { return BigDecimal.valueOf(this.getAPI().look(playerId)); } + + @Override + public boolean hasNativeConditionalWithdraw() { + return true; + } + + @Override + public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return invalid; + } + + try { + if (amount.stripTrailingZeros().scale() > 0) { + return TransactionResult.failed(amount, "PlayerPoints only supports whole amounts, got " + amount + "."); + } + + int points = amount.intValueExact(); + if (this.getAPI().take(playerId, points)) { + return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(this.getAPI().look(playerId))); + } + + return TransactionResult.nativeInsufficientFunds(amount, BigDecimal.valueOf(this.getAPI().look(playerId))); + } catch (ArithmeticException exception) { + return TransactionResult.failed(amount, "The amount does not fit in a PlayerPoints integer: " + amount + "."); + } catch (Exception exception) { + return TransactionResult.failed(amount, "PlayerPoints threw while withdrawing: " + exception.getMessage()); + } + } } diff --git a/src/main/java/fr/traqueur/currencies/providers/RedisEconomyProvider.java b/src/main/java/fr/traqueur/currencies/providers/RedisEconomyProvider.java index 72564fc..c7074e3 100644 --- a/src/main/java/fr/traqueur/currencies/providers/RedisEconomyProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/RedisEconomyProvider.java @@ -2,7 +2,10 @@ import dev.unnm3d.rediseconomy.api.RedisEconomyAPI; import dev.unnm3d.rediseconomy.currency.Currency; +import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.TransactionResult; +import net.milkbowl.vault.economy.EconomyResponse; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; @@ -57,4 +60,53 @@ public BigDecimal getBalance(UUID playerId) { } return BigDecimal.ZERO; } + + @Override + public boolean hasNativeConditionalWithdraw() { + return true; + } + + @Override + public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return invalid; + } + + try { + Currency currency = this.getCurrency(); + if (currency == null) { + return TransactionResult.failed(amount, "The RedisEconomy currency " + this.economyName + " was not found."); + } + + OfflinePlayer offlinePlayer = Bukkit.getOfflinePlayer(playerId); + EconomyResponse response = currency.withdrawPlayer(offlinePlayer, amount.doubleValue()); + if (response == null) { + return TransactionResult.failed(amount, "RedisEconomy returned no response."); + } + + if (response.type == EconomyResponse.ResponseType.SUCCESS) { + return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(response.balance)); + } + + if (response.type == EconomyResponse.ResponseType.NOT_IMPLEMENTED) { + return TransactionResult.unsupported(amount, "RedisEconomy does not implement withdrawPlayer."); + } + + if (!currency.has(playerId, amount.doubleValue())) { + return TransactionResult.nativeInsufficientFunds(amount, BigDecimal.valueOf(currency.getBalance(playerId))); + } + + return TransactionResult.failed(amount, response.errorMessage == null + ? "RedisEconomy refused the withdrawal." + : response.errorMessage); + } catch (Exception exception) { + return TransactionResult.failed(amount, "RedisEconomy threw while withdrawing: " + exception.getMessage()); + } + } + + @Override + public boolean requiresMainThread() { + return false; + } } diff --git a/src/main/java/fr/traqueur/currencies/providers/VaultProvider.java b/src/main/java/fr/traqueur/currencies/providers/VaultProvider.java index 371f92f..5187e31 100644 --- a/src/main/java/fr/traqueur/currencies/providers/VaultProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/VaultProvider.java @@ -1,7 +1,10 @@ package fr.traqueur.currencies.providers; +import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.TransactionResult; import net.milkbowl.vault.economy.Economy; +import net.milkbowl.vault.economy.EconomyResponse; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.plugin.RegisteredServiceProvider; @@ -43,4 +46,46 @@ public BigDecimal getBalance(UUID playerId) { OfflinePlayer offlinePlayer = Bukkit.getOfflinePlayer(playerId); return BigDecimal.valueOf(this.getEconomy().getBalance(offlinePlayer)); } + + @Override + public boolean hasNativeConditionalWithdraw() { + return true; + } + + @Override + public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return invalid; + } + + try { + OfflinePlayer offlinePlayer = Bukkit.getOfflinePlayer(playerId); + Economy vaultEconomy = this.getEconomy(); + + EconomyResponse response = vaultEconomy.withdrawPlayer(offlinePlayer, amount.doubleValue()); + if (response == null) { + return TransactionResult.failed(amount, "The Vault economy returned no response."); + } + + if (response.type == EconomyResponse.ResponseType.SUCCESS) { + return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(response.balance)); + } + + if (response.type == EconomyResponse.ResponseType.NOT_IMPLEMENTED) { + return TransactionResult.unsupported(amount, "The Vault economy does not implement withdrawPlayer."); + } + + BigDecimal balance = BigDecimal.valueOf(vaultEconomy.getBalance(offlinePlayer)); + if (balance.compareTo(amount) < 0) { + return TransactionResult.nativeInsufficientFunds(amount, balance); + } + + return TransactionResult.failed(amount, response.errorMessage == null + ? "The Vault economy refused the withdrawal." + : response.errorMessage); + } catch (Exception exception) { + return TransactionResult.failed(amount, "The Vault economy threw while withdrawing: " + exception.getMessage()); + } + } } diff --git a/src/main/java/fr/traqueur/currencies/providers/VotingProvider.java b/src/main/java/fr/traqueur/currencies/providers/VotingProvider.java index 2d299b1..44bca3b 100644 --- a/src/main/java/fr/traqueur/currencies/providers/VotingProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/VotingProvider.java @@ -2,7 +2,10 @@ import com.bencodez.votingplugin.VotingPluginHooks; import com.bencodez.votingplugin.user.UserManager; +import com.bencodez.votingplugin.user.VotingPluginUser; +import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.TransactionResult; import java.math.BigDecimal; import java.util.UUID; @@ -25,4 +28,33 @@ public void withdraw(UUID playerId, BigDecimal amount, String reason) { public BigDecimal getBalance(UUID playerId) { return BigDecimal.valueOf(this.userManager.getVotingPluginUser(playerId).getPoints()); } + + @Override + public boolean hasNativeConditionalWithdraw() { + return true; + } + + @Override + public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return invalid; + } + + try { + if (amount.stripTrailingZeros().scale() > 0) { + return TransactionResult.failed(amount, "VotingPlugin only supports whole amounts, got " + amount + "."); + } + + VotingPluginUser user = this.userManager.getVotingPluginUser(playerId); + if (user.removePoints(amount.intValueExact())) { + return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(user.getPoints())); + } + return TransactionResult.nativeInsufficientFunds(amount, BigDecimal.valueOf(user.getPoints())); + } catch (ArithmeticException exception) { + return TransactionResult.failed(amount, "The amount does not fit in a VotingPlugin integer: " + amount + "."); + } catch (Exception exception) { + return TransactionResult.failed(amount, "VotingPlugin threw while withdrawing: " + exception.getMessage()); + } + } } diff --git a/src/main/java/fr/traqueur/currencies/providers/ZEssentialsProvider.java b/src/main/java/fr/traqueur/currencies/providers/ZEssentialsProvider.java index 949c502..839fd75 100644 --- a/src/main/java/fr/traqueur/currencies/providers/ZEssentialsProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/ZEssentialsProvider.java @@ -3,7 +3,9 @@ import fr.maxlego08.essentials.api.EssentialsPlugin; import fr.maxlego08.essentials.api.economy.Economy; import fr.maxlego08.essentials.api.economy.EconomyManager; +import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.TransactionResult; import org.bukkit.Bukkit; import java.math.BigDecimal; @@ -21,33 +23,57 @@ public ZEssentialsProvider(String economyName) { } private void initialize() { - if (economyManager == null || economy == null) { + if (this.economyManager == null || this.economy == null) { EssentialsPlugin essentialsPlugin = (EssentialsPlugin) Bukkit.getPluginManager().getPlugin("zEssentials"); + assert essentialsPlugin != null : "zEssentials plugin not found"; this.economyManager = essentialsPlugin.getEconomyManager(); - Optional optional = economyManager.getEconomy(economyName); + Optional optional = this.economyManager.getEconomy(this.economyName); if (optional.isPresent()) { this.economy = optional.get(); } else { - throw new NullPointerException("ZEssentials economy " + economyName + " not found"); + throw new NullPointerException("ZEssentials economy " + this.economyName + " not found"); } } } @Override public void deposit(UUID playerId, BigDecimal amount, String reason) { - initialize(); + this.initialize(); this.economyManager.deposit(playerId, this.economy, amount, reason); } @Override public void withdraw(UUID playerId, BigDecimal amount, String reason) { - initialize(); + this.initialize(); this.economyManager.withdraw(playerId, this.economy, amount, reason); } @Override public BigDecimal getBalance(UUID playerId) { - initialize(); + this.initialize(); return this.economyManager.getBalance(Bukkit.getOfflinePlayer(playerId), this.economy); } + + @Override + public boolean hasNativeConditionalWithdraw() { + return true; + } + + @Override + public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return invalid; + } + + try { + this.initialize(); + if (this.economyManager.withdraw(playerId, this.economy, amount, reason)) { + return TransactionResult.nativeSuccess(amount, this.getBalance(playerId)); + } + return TransactionResult.nativeInsufficientFunds(amount, this.getBalance(playerId)); + } catch (Exception exception) { + return TransactionResult.failed(amount, "zEssentials threw while withdrawing: " + exception.getMessage()); + } + } } From 541919573333ca85ace4745803b46305d3d94a3b Mon Sep 17 00:00:00 2001 From: 1robie <97293924+1robie@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:42:57 +0200 Subject: [PATCH 2/7] fix: Prevent setting plugin instance multiple times in CurrenciesAPI --- src/main/java/fr/traqueur/currencies/CurrenciesAPI.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java b/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java index 02958c3..95e32f4 100644 --- a/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java +++ b/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java @@ -20,7 +20,7 @@ public static void init(Plugin owningPlugin) { throw new IllegalArgumentException("The plugin instance cannot be null."); } if (plugin != null) { - throw new IllegalStateException("The plugin instance has already been set by " + plugin.getName() + "."); + return; } plugin = owningPlugin; } From 80ffea376be97de91f89641956ede7f227c79fde Mon Sep 17 00:00:00 2001 From: 1robie <97293924+1robie@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:43:42 +0200 Subject: [PATCH 3/7] fix: Increase lock stripes and adjust timeout for main thread in CurrencyLocks --- src/main/java/fr/traqueur/currencies/CurrencyLocks.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/fr/traqueur/currencies/CurrencyLocks.java b/src/main/java/fr/traqueur/currencies/CurrencyLocks.java index d62bf73..6beeaff 100644 --- a/src/main/java/fr/traqueur/currencies/CurrencyLocks.java +++ b/src/main/java/fr/traqueur/currencies/CurrencyLocks.java @@ -6,8 +6,9 @@ final class CurrencyLocks { - private static final int STRIPES = 64; + private static final int STRIPES = 1024; private static final long LOCK_TIMEOUT_MILLIS = 250L; + private static final long MAIN_THREAD_LOCK_TIMEOUT_MILLIS = 25L; private static final ReentrantLock[] LOCKS = new ReentrantLock[STRIPES]; static { @@ -39,8 +40,9 @@ static ReentrantLock lockFor(CurrencyProvider provider, UUID playerId) { * @return True when the lock was acquired and must be released by the caller. */ static boolean tryLock(ReentrantLock lock) { + long timeout = CurrenciesAPI.isMainThread() ? MAIN_THREAD_LOCK_TIMEOUT_MILLIS : LOCK_TIMEOUT_MILLIS; try { - return lock.tryLock(LOCK_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); + return lock.tryLock(timeout, TimeUnit.MILLISECONDS); } catch (InterruptedException exception) { Thread.currentThread().interrupt(); return false; From 8d927e21fbc5e709068227480848544e5b45a15a Mon Sep 17 00:00:00 2001 From: 1robie <97293924+1robie@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:44:28 +0200 Subject: [PATCH 4/7] feat: Add JUnit 5 dependencies and configure test task to use JUnit platform --- build.gradle.kts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index a1c41b9..0950655 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -64,6 +64,16 @@ dependencies { compileOnly(files("libs/CoinsEngine-2.4.2.jar")) compileOnly(files("libs/nightcore-2.7.1.jar")) compileOnly(files("libs/RoyaleEconomyAPI.jar")) + + testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.2") + testImplementation("org.junit.jupiter:junit-jupiter-params:5.8.2") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.8.2") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.8.2") + testCompileOnly("org.spigotmc:spigot-api:1.21.1-R0.1-SNAPSHOT") +} + +tasks.test { + useJUnitPlatform() } val targetJavaVersion = 8 From 5ca28c2dfa313ec559557099757edacc9c45fa96 Mon Sep 17 00:00:00 2001 From: 1robie <97293924+1robie@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:57:27 +0200 Subject: [PATCH 5/7] feat: Enhance currency provider functionality with guarantee levels and improved withdrawal handling --- gradle.properties | 2 +- readme.md | 74 +++++---- .../fr/traqueur/currencies/Currencies.java | 23 ++- .../traqueur/currencies/CurrencyExecutor.java | 44 +++++ .../traqueur/currencies/CurrencyProvider.java | 31 ++-- .../traqueur/currencies/CurrencyRegistry.java | 52 +++++- .../fr/traqueur/currencies/Guarantee.java | 49 ++++++ .../currencies/TransactionResult.java | 93 +++++------ .../providers/ExcellentEconomyProvider.java | 45 +++-- .../providers/ExperienceProvider.java | 14 +- .../currencies/providers/ItemProvider.java | 9 +- .../currencies/providers/LevelProvider.java | 9 +- .../providers/PlayerPointsProvider.java | 9 +- .../providers/RedisEconomyProvider.java | 9 +- .../currencies/providers/VaultProvider.java | 9 +- .../currencies/providers/VotingProvider.java | 9 +- .../providers/ZEssentialsProvider.java | 15 +- .../CurrencyArgumentChecksTest.java | 47 ++++++ .../fr/traqueur/currencies/FakeProvider.java | 65 ++++++++ .../currencies/TransactionResultTest.java | 63 +++++++ .../currencies/WithdrawIfSufficientTest.java | 156 ++++++++++++++++++ 21 files changed, 664 insertions(+), 163 deletions(-) create mode 100644 src/main/java/fr/traqueur/currencies/CurrencyExecutor.java create mode 100644 src/main/java/fr/traqueur/currencies/Guarantee.java create mode 100644 src/test/java/fr/traqueur/currencies/CurrencyArgumentChecksTest.java create mode 100644 src/test/java/fr/traqueur/currencies/FakeProvider.java create mode 100644 src/test/java/fr/traqueur/currencies/TransactionResultTest.java create mode 100644 src/test/java/fr/traqueur/currencies/WithdrawIfSufficientTest.java diff --git a/gradle.properties b/gradle.properties index eef2451..1284b4e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -version=1.0.15 \ No newline at end of file +version=1.0.14 \ No newline at end of file diff --git a/readme.md b/readme.md index f2a1775..b57c38c 100644 --- a/readme.md +++ b/readme.md @@ -167,8 +167,10 @@ switch (result.getStatus()) { } ``` -**Nothing is ever debited unless the status is `SUCCESS`.** That is the only case where you should -hand out the goods. +**Only hand out the goods on `SUCCESS`.** `INSUFFICIENT_FUNDS` and `UNSUPPORTED` never debit +anything. `FAILED` normally does not either, but it cannot promise it: a backend that throws after +it has already applied the withdrawal is indistinguishable from one that failed cleanly, so treat +`FAILED` as "no goods, and worth logging" rather than as proof the balance is untouched. An asynchronous variant is available and never completes exceptionally, failures come back through the result: @@ -178,41 +180,47 @@ Currencies.VAULT.withdrawIfSufficientAsync(playerId, amount, "default", "Shop pu .thenAccept(result -> { /* ... */ }); ``` -### Atomicity Per Backend +### Guarantee Per Backend -Some backends can refuse a withdrawal themselves, others cannot. When a backend cannot, the library -emulates the operation by locking around a balance read and a withdraw. That stops one server racing -itself, but it **cannot** stop a second server acting on the same shared economy. +Backends differ in how strong a promise they can make, and it is not a yes or no question. Three +levels, reported by `Guarantee`: -Ask before you rely on it, either up front or from the result: +| Level | Meaning | +| --- | --- | +| `NATIVE` | The backend validated the funds inside storage every server shares. Safe against a cross-server double spend. | +| `DELEGATED` | The backend reported the outcome, but does not promise the check and the debit were indivisible. Trustworthy for one request, not a cross-server guarantee. | +| `EMULATED` | This library did the check and the debit itself under a lock. Protects one server against racing itself only. | + +Ask up front, or read it off the result: ```java -if (!Currencies.VAULT.hasNativeConditionalWithdraw("default")) { - getLogger().warning("This currency cannot guarantee atomic purchases across servers."); +if (!Currencies.VAULT.getWithdrawGuarantee("default").isCrossServerSafe()) { + getLogger().warning("This currency cannot guarantee purchases across servers."); } -result.isBackendGuaranteed(); // false when the library had to emulate the operation +result.getGuarantee(); // NATIVE, DELEGATED or EMULATED ``` -| Currency | Atomic | Notes | +| Currency | Guarantee | Notes | | --- | --- | --- | -| `VAULT` | yes | As atomic as the underlying economy plugin | -| `PLAYERPOINTS` | yes | `take` refuses when the balance is too low | -| `ZESSENTIALS` | yes | `withdraw` reports the outcome | -| `REDISECONOMY` | yes | Validated in Redis, so it holds across servers | -| `EXCELLENTECONOMY` | yes | Native async operation with a result | -| `VOTINGPLUGIN` | yes | `removePoints` reports the outcome | -| `ITEM`, `ZMENUITEMS` | yes | Player inventory, main thread only | -| `LEVEL`, `EXPERIENCE` | yes | Player state, main thread only | -| `COINSENGINE` | no | Its boolean means "currency found", not "could afford" | -| `ECOBITS` | no | `adjustBalance` returns nothing | -| `BEASTTOKENS` | no | `removeTokens` returns nothing | -| `ROYALEECONOMY` | no | `removeBalance` returns nothing | -| `ELEMENTALTOKENS`, `ELEMENTALGEMS` | no | `removeTokens` / `removeGems` return nothing | - -If you run a network where several servers share one economy database, only the currencies marked -atomic are safe against cross-server double spends. For the others the fix has to come from the -economy plugin itself. +| `REDISECONOMY` | `NATIVE` | Validated in Redis, so it holds across servers | +| `EXCELLENTECONOMY` | `NATIVE` | Native async operation with a result | +| `ITEM`, `ZMENUITEMS` | `NATIVE` | Player inventory, local to this server, main thread only | +| `LEVEL`, `EXPERIENCE` | `NATIVE` | Player state, local to this server, main thread only | +| `VAULT` | `DELEGATED` | `withdrawPlayer` reports failure, but Vault delegates to whichever economy plugin is installed and most do a plain read-modify-write | +| `ZESSENTIALS` | `DELEGATED` | `withdraw` returns a boolean, indivisibility is not promised | +| `PLAYERPOINTS` | `DELEGATED` | `take` refuses when the balance is too low | +| `VOTINGPLUGIN` | `DELEGATED` | `removePoints` reports the outcome | +| `COINSENGINE` | `EMULATED` | Its boolean means "currency found", not "could afford" | +| `ECOBITS` | `EMULATED` | `adjustBalance` returns nothing | +| `BEASTTOKENS` | `EMULATED` | `removeTokens` returns nothing | +| `ROYALEECONOMY` | `EMULATED` | `removeBalance` returns nothing | +| `ELEMENTALTOKENS`, `ELEMENTALGEMS` | `EMULATED` | `removeTokens` / `removeGems` return nothing | + +If several servers share one economy database, only `NATIVE` is safe against a cross-server double +spend. `DELEGATED` is the honest answer for Vault: it does tell you whether the withdrawal worked, +which is strictly better than guessing, but the economy plugin behind it is usually not atomic. For +`EMULATED` the fix has to come from the economy plugin itself. ### Custom Economies @@ -233,10 +241,10 @@ TransactionResult result = CurrencyRegistry.withdrawIfSufficient( ``` When you override `withdrawIfSufficient`, build the result with the factory that matches who made -the decision: `TransactionResult.nativeSuccess(...)` / `nativeInsufficientFunds(...)` when your -backend checked the funds itself, or `emulatedSuccess(...)` / `emulatedInsufficientFunds(...)` when -you checked them yourself. `unsupported(...)` and `failed(...)` cover the rest. That is what -`isBackendGuaranteed()` reports back to the caller. +the level your backend can actually promise: `TransactionResult.success(amount, balance, guarantee)` +and `insufficientFunds(amount, balance, guarantee)`, passing `Guarantee.NATIVE`, `DELEGATED` or +`EMULATED`. `unsupported(...)` and `failed(...)` cover the rest. That is what `getGuarantee()` +reports back to the caller, so be honest about it. To look a registered currency up, `CurrencyRegistry.require(name)` throws when there is none and `CurrencyRegistry.find(name)` returns null. Use `registerOrReplace(...)` to deliberately swap an @@ -245,7 +253,7 @@ implementation, for example on a config reload. Those three methods are all you have to write. Everything else has a default implementation, so an existing provider keeps working unchanged. Two optional overrides are worth knowing about: -- `hasNativeConditionalWithdraw()` and `withdrawIfSufficient(...)`: override both when your backend can +- `getWithdrawGuarantee()` and `withdrawIfSufficient(...)`: override both when your backend can refuse a withdrawal itself. You get a real guarantee instead of the emulated one. Call `CurrencyArgumentChecks.findProblem(playerId, amount)` first so your implementation rejects the same bad inputs as every other provider. diff --git a/src/main/java/fr/traqueur/currencies/Currencies.java b/src/main/java/fr/traqueur/currencies/Currencies.java index 3ee3f34..b5573c4 100644 --- a/src/main/java/fr/traqueur/currencies/Currencies.java +++ b/src/main/java/fr/traqueur/currencies/Currencies.java @@ -2,6 +2,7 @@ import fr.traqueur.currencies.providers.*; import org.bukkit.Bukkit; +import org.jetbrains.annotations.NotNull; import java.lang.reflect.Constructor; import java.math.BigDecimal; @@ -93,7 +94,7 @@ public enum Currencies { EXCELLENTEECONOMY("ExcellentEconomy", ExcellentEconomyProvider.class, true, true, EXCELLENTECONOMY) ; - private final static String DEFAULT_CURRENCY_NAME = "default"; + final static String DEFAULT_CURRENCY_NAME = "default"; private final static String DEFAULT_REASON = "No reason"; static { @@ -326,18 +327,24 @@ public CompletableFuture withdrawIfSufficientAsync(UUID playe } /** - * Whether this currency can check the balance and apply the debit as one indivisible - * operation, rather than having the library emulate it. + * Returns the provider backing this currency, creating it if necessary. * - *

Worth checking on a network where several servers share one economy: an emulated - * operation is only protected against races inside this server.

+ *

Useful for inspecting a provider's capabilities, and the point + * {@link CurrencyRegistry#resolve(String, String)} bridges to so that a built-in currency and a + * custom one can be looked up the same way.

* * @param currencyName The name of the currency. - * @return True when the backend itself guarantees the operation. + * @return The provider. */ - public boolean hasNativeConditionalWithdraw(String currencyName) { + @NotNull + public CurrencyProvider getProvider(String currencyName) { this.canBeUse(currencyName); - return this.providers.get(currencyName).hasNativeConditionalWithdraw(); + return this.providers.get(currencyName); + } + + public Guarantee getWithdrawGuarantee(String currencyName) { + this.canBeUse(currencyName); + return this.providers.get(currencyName).getWithdrawGuarantee(); } private void canBeUse(String currencyName) { diff --git a/src/main/java/fr/traqueur/currencies/CurrencyExecutor.java b/src/main/java/fr/traqueur/currencies/CurrencyExecutor.java new file mode 100644 index 0000000..2bdca2e --- /dev/null +++ b/src/main/java/fr/traqueur/currencies/CurrencyExecutor.java @@ -0,0 +1,44 @@ +package fr.traqueur.currencies; + +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * The thread pool used by the asynchronous currency operations. + * + *

Deliberately not the common {@link java.util.concurrent.ForkJoinPool}, which is what + * {@code CompletableFuture.supplyAsync} uses when no executor is given. That pool is shared with + * everything else in the JVM and sized for CPU bound work, so on a machine with few cores its + * parallelism can be as low as one. A withdrawal waiting on a SQL or Redis round trip would then + * block the whole queue, including work that has nothing to do with currencies.

+ * + *

The threads are daemons, so they never hold the server open on shutdown, and they are named so + * that a thread dump from a user reporting lag is actually readable.

+ */ +final class CurrencyExecutor { + private static final int THREADS = Math.max(2, Math.min(8, Runtime.getRuntime().availableProcessors())); + + private static final Executor EXECUTOR = Executors.newFixedThreadPool(THREADS, new ThreadFactory() { + + private final AtomicInteger counter = new AtomicInteger(); + + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "CurrenciesAPI-async-" + this.counter.incrementAndGet()); + thread.setDaemon(true); + return thread; + } + }); + + private CurrencyExecutor() { + } + + /** + * @return The executor for asynchronous currency operations. + */ + static Executor get() { + return EXECUTOR; + } +} diff --git a/src/main/java/fr/traqueur/currencies/CurrencyProvider.java b/src/main/java/fr/traqueur/currencies/CurrencyProvider.java index 7afb949..7b01a81 100644 --- a/src/main/java/fr/traqueur/currencies/CurrencyProvider.java +++ b/src/main/java/fr/traqueur/currencies/CurrencyProvider.java @@ -41,17 +41,21 @@ public interface CurrencyProvider { BigDecimal getBalance(UUID playerId); /** - * Whether this provider can check the balance and apply the debit as one indivisible - * operation. + * How strong a promise this provider can make about {@link #withdrawIfSufficient}. * - *

When this returns false, {@link #withdrawIfSufficient} still works but is emulated by - * the library: the sequence is serialized inside this JVM, which stops one server racing - * itself, but it cannot stop a second server acting on the same shared economy.

+ *

Every provider supports the operation, this only says who guarantee it. The default is + * {@link Guarantee#EMULATED}, meaning the library performs the check and the debit itself under + * a lock, which stops one server racing itself but cannot stop a second server acting on the + * same shared economy.

* - * @return True when the backend itself guarantees the operation. + *

Override with {@link Guarantee#DELEGATED} when the backend reports the outcome of the + * withdrawal but does not promise the check and the debit were indivisible, and with + * {@link Guarantee#NATIVE} only when it validates inside storage that every server shares.

+ * + * @return The level of guarantee behind a conditional withdrawal. */ - default boolean hasNativeConditionalWithdraw() { - return false; + default Guarantee getWithdrawGuarantee() { + return Guarantee.EMULATED; } /** @@ -82,7 +86,8 @@ default boolean requiresMainThread() { *

The default implementation emulates the operation by reading the balance and then * withdrawing, with the whole sequence held under a lock so that two threads cannot both * observe the same balance and both debit. The returned result reports - * {@link TransactionResult#isBackendGuaranteed()} as false to make that limitation visible.

+ * {@link TransactionResult#getGuarantee()} as {@link Guarantee#EMULATED} to make that limitation + * visible.

* * @param playerId The UUID of the player to debit. * @param amount The amount to debit, must be strictly positive. @@ -98,7 +103,7 @@ default TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, ReentrantLock lock = CurrencyLocks.lockFor(this, playerId); if (!CurrencyLocks.tryLock(lock)) { - return TransactionResult.failed(amount, "Timed out waiting for a concurrent operation on the same balance."); + return TransactionResult.failed(amount, "Timed out waiting for the currency lock, nothing was taken."); } try { @@ -108,11 +113,11 @@ default TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, } if (balance.compareTo(amount) < 0) { - return TransactionResult.emulatedInsufficientFunds(amount, balance); + return TransactionResult.insufficientFunds(amount, balance, Guarantee.EMULATED); } this.withdraw(playerId, amount, reason); - return TransactionResult.emulatedSuccess(amount, balance.subtract(amount)); + return TransactionResult.success(amount, balance.subtract(amount), Guarantee.EMULATED); } catch (Exception exception) { return TransactionResult.failed(amount, "The backend threw while withdrawing: " + exception.getMessage()); } finally { @@ -140,7 +145,7 @@ default CompletableFuture withdrawIfSufficientAsync(UUID play } if (!this.requiresMainThread()) { - return CompletableFuture.supplyAsync(() -> CurrencyProvider.this.withdrawIfSufficient(playerId, amount, reason)); + return CompletableFuture.supplyAsync(() -> CurrencyProvider.this.withdrawIfSufficient(playerId, amount, reason), CurrencyExecutor.get()); } if (CurrenciesAPI.isMainThread()) { diff --git a/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java b/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java index 27b2787..863a3ba 100644 --- a/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java +++ b/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java @@ -27,7 +27,7 @@ *

A custom provider only has to implement {@code deposit}, {@code withdraw} and * {@code getBalance}. It inherits an emulated {@link CurrencyProvider#withdrawIfSufficient} which * is serialized inside this JVM. If the backend can refuse a withdrawal itself, override - * {@code withdrawIfSufficient} and {@code hasNativeConditionalWithdraw} to get a real guarantee. If the + * {@code withdrawIfSufficient} and {@code getWithdrawGuarantee} to report a real guarantee. If the * backend is safe to use off the main server thread, also override * {@link CurrencyProvider#requiresMainThread()} to return false.

*/ @@ -47,10 +47,12 @@ private CurrencyRegistry() { * @throws IllegalStateException if a different provider is already registered under this name. */ public static void register(String name, CurrencyProvider provider) { - String key = normalize(name); + // Provider checked before the name, so register(null, null) reports the provider rather + // than blaming the name. if (provider == null) { throw new IllegalArgumentException("The provider cannot be null."); } + String key = normalize(name); CurrencyProvider existing = PROVIDERS.putIfAbsent(key, provider); if (existing != null && existing != provider) { @@ -66,11 +68,10 @@ public static void register(String name, CurrencyProvider provider) { * @return The provider that was previously registered, or null. */ public static CurrencyProvider registerOrReplace(String name, CurrencyProvider provider) { - String key = normalize(name); if (provider == null) { throw new IllegalArgumentException("The provider cannot be null."); } - return PROVIDERS.put(key, provider); + return PROVIDERS.put(normalize(name), provider); } /** @@ -120,7 +121,12 @@ public static boolean isRegistered(String name) { } /** - * @return The names of every registered custom currency. The returned set is a snapshot. + * The names of every registered custom currency. + * + *

The names come back normalized, trimmed and lower cased, because that is the form + * lookups use. Registering {@code "MyGems"} and reading this back gives {@code "mygems"}.

+ * + * @return A snapshot of the registered names, in their normalized form. */ @NotNull public static Set getRegisteredNames() { @@ -154,6 +160,42 @@ public static CompletableFuture withdrawIfSufficientAsync(Str return require(name).withdrawIfSufficientAsync(playerId, amount, reason); } + /** + * Resolves a currency by name, whether it is one of the built in {@link Currencies} constants + * or a custom provider registered here. + * + *

This is the bridge between the two. Without it a caller holding a currency name from a + * configuration file would have to know in advance which of the two mechanisms it belongs to, + * and there would be two unrelated ways to do the same thing.

+ * + *

The built in constants win when a name matches both, so a custom registration cannot + * silently shadow {@code VAULT}. Note that a built in currency also needs a currency name for + * the economies that support several, which is why {@code currencyName} is separate: it is + * ignored for a custom provider, which is registered per currency already.

+ * + * @param name The currency name, either a {@link Currencies} constant or a registered custom name. + * @param currencyName The sub currency for a built in provider, or null for the default one. + * @return The provider backing that name. + * @throws IllegalStateException if the name matches neither. + */ + @NotNull + public static CurrencyProvider resolve(String name, String currencyName) { + if (name != null) { + try { + Currencies currency = Currencies.fromName(name.trim().toUpperCase(java.util.Locale.ROOT)); + return currency.getProvider(currencyName == null ? Currencies.DEFAULT_CURRENCY_NAME : currencyName); + } catch (IllegalArgumentException ignored) { + } + } + + CurrencyProvider provider = find(name); + if (provider == null) { + throw new IllegalStateException("No currency named " + name + " is known, either as a built in " + + "Currencies constant or as a provider registered with CurrencyRegistry.register(name, provider)."); + } + return provider; + } + private static String normalize(String name) { if (name == null || name.trim().isEmpty()) { throw new IllegalArgumentException("The currency name cannot be null or blank."); diff --git a/src/main/java/fr/traqueur/currencies/Guarantee.java b/src/main/java/fr/traqueur/currencies/Guarantee.java new file mode 100644 index 0000000..1222ed5 --- /dev/null +++ b/src/main/java/fr/traqueur/currencies/Guarantee.java @@ -0,0 +1,49 @@ +package fr.traqueur.currencies; + +/** + * How strong the promise behind a conditional currency operation actually is. + * + *

A two state boolean was not enough to describe the backends honestly. Some of them validate + * the funds inside their own shared storage, some merely report an outcome without promising the + * check and the debit were indivisible, and for the rest this library has to emulate the operation. + * Collapsing the middle case into "guaranteed" told callers they were safe against a cross server + * double spend when they were not.

+ */ +public enum Guarantee { + + /** + * The backend applied the check and the debit as one indivisible operation, inside storage that + * every server shares. This is the only level that is safe against a second server acting on + * the same balance at the same time. + */ + NATIVE, + + /** + * The backend reported whether the withdrawal succeeded, but does not promise that the check + * and the debit were indivisible. + * + *

Vault is the clearest example: {@code withdrawPlayer} returns a response, but Vault is an + * abstraction over whichever economy plugin is installed, and most of them do a plain read, + * modify and write. The result is trustworthy for a single request, and it is better than an + * emulated check because the decision was made by the thing that owns the money, but it is not + * a cross server guarantee.

+ */ + DELEGATED, + + /** + * This library performed the check and the debit itself, serialized under a lock. + * + *

Protects one server against racing itself. A second server sharing the same economy does + * not see that lock.

+ */ + EMULATED; + + /** + * Whether this level is safe when several servers share one economy. + * + * @return True only for {@link #NATIVE}. + */ + public boolean isCrossServerSafe() { + return this == NATIVE; + } +} diff --git a/src/main/java/fr/traqueur/currencies/TransactionResult.java b/src/main/java/fr/traqueur/currencies/TransactionResult.java index 99adf53..c2a5033 100644 --- a/src/main/java/fr/traqueur/currencies/TransactionResult.java +++ b/src/main/java/fr/traqueur/currencies/TransactionResult.java @@ -1,5 +1,8 @@ package fr.traqueur.currencies; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + import java.math.BigDecimal; public final class TransactionResult { @@ -24,9 +27,15 @@ public enum Status { UNSUPPORTED, /** - * The operation could not be completed for any other reason, for example the - * economy plugin returned an error or the player data could not be loaded. - * Nothing was debited. + * The operation could not be completed for any other reason, for example the economy + * plugin returned an error or the player data could not be loaded. + * + *

Nothing was debited in the ordinary case. The one exception worth knowing about is a + * backend that throws after it has already applied the withdrawal, for instance a + * committed transaction followed by an error on the way back. The library cannot tell that + * apart from a clean failure, so a caller handing out something valuable should treat a + * FAILED result as "no goods, and worth logging" rather than as proof the money is + * untouched.

*/ FAILED } @@ -35,65 +44,38 @@ public enum Status { private final BigDecimal amount; private final BigDecimal balance; private final String errorMessage; - private final boolean backendGuaranteed; + private final Guarantee guarantee; - private TransactionResult(Status status, BigDecimal amount, BigDecimal balance, String errorMessage, boolean backendGuaranteed) { + private TransactionResult(Status status, BigDecimal amount, BigDecimal balance, String errorMessage, Guarantee guarantee) { this.status = status; this.amount = amount == null ? BigDecimal.ZERO : amount; this.balance = balance; this.errorMessage = errorMessage; - this.backendGuaranteed = backendGuaranteed; - } - - /** - * Build a successful result for a backend that applied the check and the debit itself. - * - *

Use this from a provider that overrides - * {@link CurrencyProvider#withdrawIfSufficient(java.util.UUID, BigDecimal, String)} because its - * backend can refuse a withdrawal on its own.

- * - * @param amount The amount that was debited. - * @param balance The resulting balance, or null when the backend does not report it. - * @return The result. - */ - public static TransactionResult nativeSuccess(BigDecimal amount, BigDecimal balance) { - return new TransactionResult(Status.SUCCESS, amount, balance, null, true); - } - - /** - * Build a successful result for an operation the library emulated with a balance read followed - * by a withdraw. - * - * @param amount The amount that was debited. - * @param balance The resulting balance, or null when it is not known. - * @return The result. - */ - public static TransactionResult emulatedSuccess(BigDecimal amount, BigDecimal balance) { - return new TransactionResult(Status.SUCCESS, amount, balance, null, false); + this.guarantee = guarantee; } /** - * Build an insufficient funds result for a backend that made the decision itself. Nothing was - * debited. + * Build a successful result. * - * @param amount The amount that was requested. - * @param balance The balance that was observed, or null when it is not known. + * @param amount The amount that was debited. + * @param balance The resulting balance, or null when it is not known. + * @param guarantee How strong the promise behind the operation is. * @return The result. */ - public static TransactionResult nativeInsufficientFunds(BigDecimal amount, BigDecimal balance) { - return new TransactionResult(Status.INSUFFICIENT_FUNDS, amount, balance, null, true); + public static TransactionResult success(BigDecimal amount, BigDecimal balance, Guarantee guarantee) { + return new TransactionResult(Status.SUCCESS, amount, balance, null, guarantee); } /** - * Build an insufficient funds result for a check the library performed itself. Nothing was - * debited. + * Build a result for a player who could not afford the amount. Nothing was debited. * - * @param amount The amount that was requested. - * @param balance The balance that was observed, or null when it is not known. + * @param amount The amount that was requested. + * @param balance The balance that was observed, or null when it is not known. + * @param guarantee How strong the promise behind the check is. * @return The result. */ - public static TransactionResult emulatedInsufficientFunds(BigDecimal amount, BigDecimal balance) { - return new TransactionResult(Status.INSUFFICIENT_FUNDS, amount, balance, null, false); + public static TransactionResult insufficientFunds(BigDecimal amount, BigDecimal balance, Guarantee guarantee) { + return new TransactionResult(Status.INSUFFICIENT_FUNDS, amount, balance, null, guarantee); } /** @@ -104,7 +86,7 @@ public static TransactionResult emulatedInsufficientFunds(BigDecimal amount, Big * @return The result. */ public static TransactionResult unsupported(BigDecimal amount, String errorMessage) { - return new TransactionResult(Status.UNSUPPORTED, amount, null, errorMessage, false); + return new TransactionResult(Status.UNSUPPORTED, amount, null, errorMessage, Guarantee.EMULATED); } /** @@ -115,12 +97,13 @@ public static TransactionResult unsupported(BigDecimal amount, String errorMessa * @return The result. */ public static TransactionResult failed(BigDecimal amount, String errorMessage) { - return new TransactionResult(Status.FAILED, amount, null, errorMessage, false); + return new TransactionResult(Status.FAILED, amount, null, errorMessage, Guarantee.EMULATED); } /** * @return The outcome of the operation. */ + @NotNull public Status getStatus() { return this.status; } @@ -133,17 +116,19 @@ public boolean isSuccess() { } /** - * @return True when the backend guaranteed that the check and the debit were indivisible. - * False means the library emulated the operation and it is only safe against concurrent - * access from inside this server. + * @return How strong the promise behind this operation was. Use + * {@link Guarantee#isCrossServerSafe()} to decide whether it holds on a network where several + * servers share one economy. */ - public boolean isBackendGuaranteed() { - return this.backendGuaranteed; + @NotNull + public Guarantee getGuarantee() { + return this.guarantee; } /** * @return The amount that was requested. */ + @NotNull public BigDecimal getAmount() { return this.amount; } @@ -151,6 +136,7 @@ public BigDecimal getAmount() { /** * @return The resulting balance, or null when the backend does not report one. */ + @Nullable public BigDecimal getBalance() { return this.balance; } @@ -158,6 +144,7 @@ public BigDecimal getBalance() { /** * @return A human-readable explanation for a failure, or null. */ + @Nullable public String getErrorMessage() { return this.errorMessage; } @@ -167,7 +154,7 @@ public String toString() { return "TransactionResult{status=" + this.status + ", amount=" + this.amount + ", balance=" + this.balance - + ", backendGuaranteed=" + this.backendGuaranteed + + ", guarantee=" + this.guarantee + (this.errorMessage == null ? "" : ", error='" + this.errorMessage + "'") + '}'; } diff --git a/src/main/java/fr/traqueur/currencies/providers/ExcellentEconomyProvider.java b/src/main/java/fr/traqueur/currencies/providers/ExcellentEconomyProvider.java index 4116f7e..95faece 100644 --- a/src/main/java/fr/traqueur/currencies/providers/ExcellentEconomyProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/ExcellentEconomyProvider.java @@ -2,6 +2,7 @@ import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.Guarantee; import fr.traqueur.currencies.TransactionResult; import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -59,9 +60,18 @@ public BigDecimal getBalance(UUID playerId) { return BigDecimal.valueOf(raw); } + private BigDecimal getBalanceWithoutBukkit(UUID playerId) { + try { + Double raw = this.api.getBalanceAsync(playerId, this.currencyName).join(); + return raw == null ? null : BigDecimal.valueOf(raw); + } catch (Exception exception) { + return null; + } + } + @Override - public boolean hasNativeConditionalWithdraw() { - return true; + public Guarantee getWithdrawGuarantee() { + return Guarantee.NATIVE; } @Override @@ -73,20 +83,20 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, try { OperationContext ctx = OperationContext.custom(reason); - Player player = Bukkit.getPlayer(playerId); - - boolean success; - if (player != null) { - success = this.api.withdraw(player, this.currencyName, amount.doubleValue(), ctx); - } else { - OperationResult result = this.api.withdrawAsync(playerId, this.currencyName, amount.doubleValue(), ctx).join(); - success = result != null && result.success(); + + OperationResult result = this.api.withdrawAsync(playerId, this.currencyName, amount.doubleValue(), ctx).join(); + + if (result != null && result.success()) { + return TransactionResult.success(amount, null, Guarantee.NATIVE); } - if (success) { - return TransactionResult.nativeSuccess(amount, this.getBalance(playerId)); + BigDecimal balance = this.getBalanceWithoutBukkit(playerId); + if (balance != null && balance.compareTo(amount) < 0) { + return TransactionResult.insufficientFunds(amount, balance, Guarantee.NATIVE); } - return TransactionResult.nativeInsufficientFunds(amount, this.getBalance(playerId)); + + return TransactionResult.failed(amount, "ExcellentEconomy refused the withdrawal and the player could afford it, " + + "so the currency " + this.currencyName + " may be unknown or the player data may not be loaded."); } catch (Exception exception) { return TransactionResult.failed(amount, "ExcellentEconomy threw while withdrawing: " + exception.getMessage()); } @@ -107,9 +117,14 @@ public CompletableFuture withdrawIfSufficientAsync(UUID playe return TransactionResult.failed(amount, "ExcellentEconomy threw while withdrawing: " + throwable.getMessage()); } if (result != null && result.success()) { - return TransactionResult.nativeSuccess(amount, null); + return TransactionResult.success(amount, null, Guarantee.NATIVE); + } + BigDecimal balance = this.getBalanceWithoutBukkit(playerId); + if (balance != null && balance.compareTo(amount) < 0) { + return TransactionResult.insufficientFunds(amount, balance, Guarantee.NATIVE); } - return TransactionResult.nativeInsufficientFunds(amount, null); + return TransactionResult.failed(amount, "ExcellentEconomy refused the withdrawal and the player could afford it, " + + "so the currency " + this.currencyName + " may be unknown or the player data may not be loaded."); }); } catch (Exception exception) { return CompletableFuture.completedFuture( diff --git a/src/main/java/fr/traqueur/currencies/providers/ExperienceProvider.java b/src/main/java/fr/traqueur/currencies/providers/ExperienceProvider.java index 47c8afc..e779f73 100644 --- a/src/main/java/fr/traqueur/currencies/providers/ExperienceProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/ExperienceProvider.java @@ -2,9 +2,9 @@ import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.Guarantee; import fr.traqueur.currencies.TransactionResult; import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; import java.math.BigDecimal; @@ -80,8 +80,8 @@ private int getTotalExperience(Player player) { } @Override - public boolean hasNativeConditionalWithdraw() { - return true; + public Guarantee getWithdrawGuarantee() { + return Guarantee.NATIVE; } @Override @@ -96,13 +96,17 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, return TransactionResult.failed(amount, "Experience can only be taken from an online player."); } + if (amount.stripTrailingZeros().scale() > 0) { + return TransactionResult.failed(amount, "Experience only supports whole amounts, got " + amount + "."); + } + BigDecimal current = BigDecimal.valueOf(this.getTotalExperience(player)); if (current.compareTo(amount) < 0) { - return TransactionResult.nativeInsufficientFunds(amount, current); + return TransactionResult.insufficientFunds(amount, current, Guarantee.NATIVE); } BigDecimal remaining = current.subtract(amount); this.setTotalExperience(player, remaining.intValue()); - return TransactionResult.nativeSuccess(amount, remaining); + return TransactionResult.success(amount, remaining, Guarantee.NATIVE); } } diff --git a/src/main/java/fr/traqueur/currencies/providers/ItemProvider.java b/src/main/java/fr/traqueur/currencies/providers/ItemProvider.java index a71d442..a8603ca 100644 --- a/src/main/java/fr/traqueur/currencies/providers/ItemProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/ItemProvider.java @@ -2,6 +2,7 @@ import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.Guarantee; import fr.traqueur.currencies.TransactionResult; import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -119,8 +120,8 @@ private boolean hasInventoryFull(Player player) { } @Override - public boolean hasNativeConditionalWithdraw() { - return true; + public Guarantee getWithdrawGuarantee() { + return Guarantee.NATIVE; } @Override @@ -153,10 +154,10 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, int held = this.getAmount(player, currencyItem); if (held < cost) { - return TransactionResult.nativeInsufficientFunds(amount, BigDecimal.valueOf(held)); + return TransactionResult.insufficientFunds(amount, BigDecimal.valueOf(held), Guarantee.NATIVE); } this.removeItems(player, currencyItem, cost); - return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(held - cost)); + return TransactionResult.success(amount, BigDecimal.valueOf(held - cost), Guarantee.NATIVE); } } diff --git a/src/main/java/fr/traqueur/currencies/providers/LevelProvider.java b/src/main/java/fr/traqueur/currencies/providers/LevelProvider.java index 3e60725..56fbb52 100644 --- a/src/main/java/fr/traqueur/currencies/providers/LevelProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/LevelProvider.java @@ -2,6 +2,7 @@ import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.Guarantee; import fr.traqueur.currencies.TransactionResult; import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -36,8 +37,8 @@ public BigDecimal getBalance(UUID playerId) { } @Override - public boolean hasNativeConditionalWithdraw() { - return true; + public Guarantee getWithdrawGuarantee() { + return Guarantee.NATIVE; } @Override @@ -65,10 +66,10 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, int current = player.getLevel(); if (current < cost) { - return TransactionResult.nativeInsufficientFunds(amount, BigDecimal.valueOf(current)); + return TransactionResult.insufficientFunds(amount, BigDecimal.valueOf(current), Guarantee.NATIVE); } player.setLevel(current - cost); - return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(current - cost)); + return TransactionResult.success(amount, BigDecimal.valueOf(current - cost), Guarantee.NATIVE); } } diff --git a/src/main/java/fr/traqueur/currencies/providers/PlayerPointsProvider.java b/src/main/java/fr/traqueur/currencies/providers/PlayerPointsProvider.java index 8b10053..80baa58 100644 --- a/src/main/java/fr/traqueur/currencies/providers/PlayerPointsProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/PlayerPointsProvider.java @@ -2,6 +2,7 @@ import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.Guarantee; import fr.traqueur.currencies.TransactionResult; import org.black_ixx.playerpoints.PlayerPoints; import org.black_ixx.playerpoints.PlayerPointsAPI; @@ -39,8 +40,8 @@ public BigDecimal getBalance(UUID playerId) { } @Override - public boolean hasNativeConditionalWithdraw() { - return true; + public Guarantee getWithdrawGuarantee() { + return Guarantee.DELEGATED; } @Override @@ -57,10 +58,10 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, int points = amount.intValueExact(); if (this.getAPI().take(playerId, points)) { - return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(this.getAPI().look(playerId))); + return TransactionResult.success(amount, BigDecimal.valueOf(this.getAPI().look(playerId)), Guarantee.DELEGATED); } - return TransactionResult.nativeInsufficientFunds(amount, BigDecimal.valueOf(this.getAPI().look(playerId))); + return TransactionResult.insufficientFunds(amount, BigDecimal.valueOf(this.getAPI().look(playerId)), Guarantee.DELEGATED); } catch (ArithmeticException exception) { return TransactionResult.failed(amount, "The amount does not fit in a PlayerPoints integer: " + amount + "."); } catch (Exception exception) { diff --git a/src/main/java/fr/traqueur/currencies/providers/RedisEconomyProvider.java b/src/main/java/fr/traqueur/currencies/providers/RedisEconomyProvider.java index c7074e3..e5b14c7 100644 --- a/src/main/java/fr/traqueur/currencies/providers/RedisEconomyProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/RedisEconomyProvider.java @@ -4,6 +4,7 @@ import dev.unnm3d.rediseconomy.currency.Currency; import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.Guarantee; import fr.traqueur.currencies.TransactionResult; import net.milkbowl.vault.economy.EconomyResponse; import org.bukkit.Bukkit; @@ -62,8 +63,8 @@ public BigDecimal getBalance(UUID playerId) { } @Override - public boolean hasNativeConditionalWithdraw() { - return true; + public Guarantee getWithdrawGuarantee() { + return Guarantee.NATIVE; } @Override @@ -86,7 +87,7 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, } if (response.type == EconomyResponse.ResponseType.SUCCESS) { - return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(response.balance)); + return TransactionResult.success(amount, BigDecimal.valueOf(response.balance), Guarantee.NATIVE); } if (response.type == EconomyResponse.ResponseType.NOT_IMPLEMENTED) { @@ -94,7 +95,7 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, } if (!currency.has(playerId, amount.doubleValue())) { - return TransactionResult.nativeInsufficientFunds(amount, BigDecimal.valueOf(currency.getBalance(playerId))); + return TransactionResult.insufficientFunds(amount, BigDecimal.valueOf(currency.getBalance(playerId)), Guarantee.NATIVE); } return TransactionResult.failed(amount, response.errorMessage == null diff --git a/src/main/java/fr/traqueur/currencies/providers/VaultProvider.java b/src/main/java/fr/traqueur/currencies/providers/VaultProvider.java index 5187e31..13f1c04 100644 --- a/src/main/java/fr/traqueur/currencies/providers/VaultProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/VaultProvider.java @@ -2,6 +2,7 @@ import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.Guarantee; import fr.traqueur.currencies.TransactionResult; import net.milkbowl.vault.economy.Economy; import net.milkbowl.vault.economy.EconomyResponse; @@ -48,8 +49,8 @@ public BigDecimal getBalance(UUID playerId) { } @Override - public boolean hasNativeConditionalWithdraw() { - return true; + public Guarantee getWithdrawGuarantee() { + return Guarantee.DELEGATED; } @Override @@ -69,7 +70,7 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, } if (response.type == EconomyResponse.ResponseType.SUCCESS) { - return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(response.balance)); + return TransactionResult.success(amount, BigDecimal.valueOf(response.balance), Guarantee.DELEGATED); } if (response.type == EconomyResponse.ResponseType.NOT_IMPLEMENTED) { @@ -78,7 +79,7 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, BigDecimal balance = BigDecimal.valueOf(vaultEconomy.getBalance(offlinePlayer)); if (balance.compareTo(amount) < 0) { - return TransactionResult.nativeInsufficientFunds(amount, balance); + return TransactionResult.insufficientFunds(amount, balance, Guarantee.DELEGATED); } return TransactionResult.failed(amount, response.errorMessage == null diff --git a/src/main/java/fr/traqueur/currencies/providers/VotingProvider.java b/src/main/java/fr/traqueur/currencies/providers/VotingProvider.java index 44bca3b..c6b029d 100644 --- a/src/main/java/fr/traqueur/currencies/providers/VotingProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/VotingProvider.java @@ -5,6 +5,7 @@ import com.bencodez.votingplugin.user.VotingPluginUser; import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.Guarantee; import fr.traqueur.currencies.TransactionResult; import java.math.BigDecimal; @@ -30,8 +31,8 @@ public BigDecimal getBalance(UUID playerId) { } @Override - public boolean hasNativeConditionalWithdraw() { - return true; + public Guarantee getWithdrawGuarantee() { + return Guarantee.DELEGATED; } @Override @@ -48,9 +49,9 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, VotingPluginUser user = this.userManager.getVotingPluginUser(playerId); if (user.removePoints(amount.intValueExact())) { - return TransactionResult.nativeSuccess(amount, BigDecimal.valueOf(user.getPoints())); + return TransactionResult.success(amount, BigDecimal.valueOf(user.getPoints()), Guarantee.DELEGATED); } - return TransactionResult.nativeInsufficientFunds(amount, BigDecimal.valueOf(user.getPoints())); + return TransactionResult.insufficientFunds(amount, BigDecimal.valueOf(user.getPoints()), Guarantee.DELEGATED); } catch (ArithmeticException exception) { return TransactionResult.failed(amount, "The amount does not fit in a VotingPlugin integer: " + amount + "."); } catch (Exception exception) { diff --git a/src/main/java/fr/traqueur/currencies/providers/ZEssentialsProvider.java b/src/main/java/fr/traqueur/currencies/providers/ZEssentialsProvider.java index 839fd75..3ae0dc7 100644 --- a/src/main/java/fr/traqueur/currencies/providers/ZEssentialsProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/ZEssentialsProvider.java @@ -5,6 +5,7 @@ import fr.maxlego08.essentials.api.economy.EconomyManager; import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.Guarantee; import fr.traqueur.currencies.TransactionResult; import org.bukkit.Bukkit; @@ -25,13 +26,15 @@ public ZEssentialsProvider(String economyName) { private void initialize() { if (this.economyManager == null || this.economy == null) { EssentialsPlugin essentialsPlugin = (EssentialsPlugin) Bukkit.getPluginManager().getPlugin("zEssentials"); - assert essentialsPlugin != null : "zEssentials plugin not found"; + if (essentialsPlugin == null) { + throw new IllegalStateException("The plugin zEssentials is not installed."); + } this.economyManager = essentialsPlugin.getEconomyManager(); Optional optional = this.economyManager.getEconomy(this.economyName); if (optional.isPresent()) { this.economy = optional.get(); } else { - throw new NullPointerException("ZEssentials economy " + this.economyName + " not found"); + throw new IllegalStateException("The zEssentials economy " + this.economyName + " was not found."); } } } @@ -55,8 +58,8 @@ public BigDecimal getBalance(UUID playerId) { } @Override - public boolean hasNativeConditionalWithdraw() { - return true; + public Guarantee getWithdrawGuarantee() { + return Guarantee.DELEGATED; } @Override @@ -69,9 +72,9 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, try { this.initialize(); if (this.economyManager.withdraw(playerId, this.economy, amount, reason)) { - return TransactionResult.nativeSuccess(amount, this.getBalance(playerId)); + return TransactionResult.success(amount, this.getBalance(playerId), Guarantee.DELEGATED); } - return TransactionResult.nativeInsufficientFunds(amount, this.getBalance(playerId)); + return TransactionResult.insufficientFunds(amount, this.getBalance(playerId), Guarantee.DELEGATED); } catch (Exception exception) { return TransactionResult.failed(amount, "zEssentials threw while withdrawing: " + exception.getMessage()); } diff --git a/src/test/java/fr/traqueur/currencies/CurrencyArgumentChecksTest.java b/src/test/java/fr/traqueur/currencies/CurrencyArgumentChecksTest.java new file mode 100644 index 0000000..55a4b4b --- /dev/null +++ b/src/test/java/fr/traqueur/currencies/CurrencyArgumentChecksTest.java @@ -0,0 +1,47 @@ +package fr.traqueur.currencies; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.math.BigDecimal; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +class CurrencyArgumentChecksTest { + + @Test + @DisplayName("usable arguments report no problem") + void usableArgumentsReportNoProblem() { + assertNull(CurrencyArgumentChecks.findProblem(UUID.randomUUID(), BigDecimal.TEN)); + } + + @Test + @DisplayName("a null player is a problem") + void nullPlayerIsAProblem() { + TransactionResult problem = CurrencyArgumentChecks.findProblem(null, BigDecimal.TEN); + assertNotNull(problem); + assertSame(TransactionResult.Status.FAILED, problem.getStatus()); + } + + @Test + @DisplayName("a null amount is a problem") + void nullAmountIsAProblem() { + TransactionResult problem = CurrencyArgumentChecks.findProblem(UUID.randomUUID(), null); + assertNotNull(problem); + assertSame(TransactionResult.Status.FAILED, problem.getStatus()); + } + + @ParameterizedTest + @ValueSource(strings = {"0", "-0.01", "-1", "-1000000"}) + @DisplayName("a zero or negative amount is a problem") + void nonPositiveAmountIsAProblem(String amount) { + TransactionResult problem = CurrencyArgumentChecks.findProblem(UUID.randomUUID(), new BigDecimal(amount)); + assertNotNull(problem, amount + " should be refused"); + assertSame(TransactionResult.Status.FAILED, problem.getStatus()); + } +} diff --git a/src/test/java/fr/traqueur/currencies/FakeProvider.java b/src/test/java/fr/traqueur/currencies/FakeProvider.java new file mode 100644 index 0000000..8d3947d --- /dev/null +++ b/src/test/java/fr/traqueur/currencies/FakeProvider.java @@ -0,0 +1,65 @@ +package fr.traqueur.currencies; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * An in-memory provider used to exercise the emulated {@link CurrencyProvider#withdrawIfSufficient} + * without a server. + * + *

{@link #withdraw} deliberately reads the balance, pauses, then writes the result back. That is + * how a real backend behaves when the write goes over a network, and it is what makes an + * unsynchronized check-then-withdraw lose updates. Without the pause the race almost never shows up + * and the test would pass for the wrong reason.

+ */ +final class FakeProvider implements CurrencyProvider { + + private final Map balances = new ConcurrentHashMap<>(); + private final AtomicInteger withdrawCalls = new AtomicInteger(); + private final long writeDelayMillis; + + FakeProvider(long writeDelayMillis) { + this.writeDelayMillis = writeDelayMillis; + } + + @Override + public void deposit(UUID playerId, BigDecimal amount, String reason) { + this.balances.merge(playerId, amount, BigDecimal::add); + } + + @Override + public void withdraw(UUID playerId, BigDecimal amount, String reason) { + this.withdrawCalls.incrementAndGet(); + BigDecimal current = this.getBalance(playerId); + if (this.writeDelayMillis > 0) { + try { + Thread.sleep(this.writeDelayMillis); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + } + this.balances.put(playerId, current.subtract(amount)); + } + + @Override + public BigDecimal getBalance(UUID playerId) { + BigDecimal balance = this.balances.get(playerId); + return balance == null ? BigDecimal.ZERO : balance; + } + + /** + * False so the tests exercise the real asynchronous path instead of the main thread hop, which + * would need a running server. + */ + @Override + public boolean requiresMainThread() { + return false; + } + + int getWithdrawCalls() { + return this.withdrawCalls.get(); + } +} diff --git a/src/test/java/fr/traqueur/currencies/TransactionResultTest.java b/src/test/java/fr/traqueur/currencies/TransactionResultTest.java new file mode 100644 index 0000000..2b212bd --- /dev/null +++ b/src/test/java/fr/traqueur/currencies/TransactionResultTest.java @@ -0,0 +1,63 @@ +package fr.traqueur.currencies; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TransactionResultTest { + + @Test + @DisplayName("success is the only status that reports isSuccess") + void onlySuccessIsSuccessful() { + assertTrue(TransactionResult.success(BigDecimal.TEN, BigDecimal.ONE, Guarantee.NATIVE).isSuccess()); + assertFalse(TransactionResult.insufficientFunds(BigDecimal.TEN, BigDecimal.ONE, Guarantee.NATIVE).isSuccess()); + assertFalse(TransactionResult.unsupported(BigDecimal.TEN, "nope").isSuccess()); + assertFalse(TransactionResult.failed(BigDecimal.TEN, "boom").isSuccess()); + } + + @Test + @DisplayName("the guarantee is carried through unchanged") + void guaranteeIsCarriedThrough() { + for (Guarantee guarantee : Guarantee.values()) { + assertSame(guarantee, TransactionResult.success(BigDecimal.TEN, null, guarantee).getGuarantee()); + assertSame(guarantee, TransactionResult.insufficientFunds(BigDecimal.TEN, null, guarantee).getGuarantee()); + } + } + + @Test + @DisplayName("only NATIVE is cross-server safe") + void onlyNativeIsCrossServerSafe() { + assertTrue(Guarantee.NATIVE.isCrossServerSafe()); + assertFalse(Guarantee.DELEGATED.isCrossServerSafe(), "delegated does not promise indivisibility"); + assertFalse(Guarantee.EMULATED.isCrossServerSafe(), "emulated only covers this JVM"); + } + + @Test + @DisplayName("a null amount is normalised to zero rather than kept null") + void nullAmountBecomesZero() { + assertEquals(0, TransactionResult.failed(null, "boom").getAmount().compareTo(BigDecimal.ZERO)); + } + + @Test + @DisplayName("an unreported balance stays null instead of being invented") + void unreportedBalanceStaysNull() { + assertNull(TransactionResult.success(BigDecimal.TEN, null, Guarantee.NATIVE).getBalance()); + assertNull(TransactionResult.failed(BigDecimal.TEN, "boom").getBalance()); + } + + @Test + @DisplayName("failure statuses carry an explanation") + void failuresCarryAnExplanation() { + assertNotNull(TransactionResult.unsupported(BigDecimal.TEN, "not implemented").getErrorMessage()); + assertNotNull(TransactionResult.failed(BigDecimal.TEN, "boom").getErrorMessage()); + assertNull(TransactionResult.success(BigDecimal.TEN, null, Guarantee.NATIVE).getErrorMessage()); + } +} diff --git a/src/test/java/fr/traqueur/currencies/WithdrawIfSufficientTest.java b/src/test/java/fr/traqueur/currencies/WithdrawIfSufficientTest.java new file mode 100644 index 0000000..90e3c35 --- /dev/null +++ b/src/test/java/fr/traqueur/currencies/WithdrawIfSufficientTest.java @@ -0,0 +1,156 @@ +package fr.traqueur.currencies; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the emulated conditional withdrawal, which is the whole point of this feature: a provider + * that cannot refuse a withdrawal itself must still not allow a double spend on one server. + */ +class WithdrawIfSufficientTest { + + private static final String REASON = "test"; + + @Test + @DisplayName("concurrent purchases cannot overspend the balance") + void concurrentPurchasesCannotOverspend() throws Exception { + FakeProvider provider = new FakeProvider(30); + UUID player = UUID.randomUUID(); + provider.deposit(player, new BigDecimal("1000"), REASON); + + int threads = 8; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + try { + for (int i = 0; i < threads; i++) { + futures.add(pool.submit(() -> { + start.await(); + return provider.withdrawIfSufficient(player, new BigDecimal("1000"), REASON); + })); + } + start.countDown(); + + int successes = 0; + for (Future future : futures) { + if (future.get(30, TimeUnit.SECONDS).isSuccess()) { + successes++; + } + } + + assertEquals(1, successes, "exactly one purchase should succeed"); + assertEquals(0, provider.getBalance(player).compareTo(BigDecimal.ZERO), "balance should be exactly zero"); + } finally { + pool.shutdownNow(); + } + } + + @Test + @DisplayName("an unsynchronised check then withdraw does overspend, so the test above is meaningful") + void unsynchronisedPatternOverspends() throws Exception { + FakeProvider provider = new FakeProvider(30); + UUID player = UUID.randomUUID(); + provider.deposit(player, new BigDecimal("1000"), REASON); + + int threads = 8; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + try { + for (int i = 0; i < threads; i++) { + futures.add(pool.submit(() -> { + start.await(); + if (provider.getBalance(player).compareTo(new BigDecimal("1000")) >= 0) { + provider.withdraw(player, new BigDecimal("1000"), REASON); + return true; + } + return false; + })); + } + start.countDown(); + + int successes = 0; + for (Future future : futures) { + if (future.get(30, TimeUnit.SECONDS)) { + successes++; + } + } + + assertTrue(successes > 1, "the unsynchronised pattern is expected to overspend, got " + successes); + } finally { + pool.shutdownNow(); + } + } + + @Test + @DisplayName("insufficient funds never reaches the backend") + void insufficientFundsNeverDebits() { + FakeProvider provider = new FakeProvider(0); + UUID player = UUID.randomUUID(); + provider.deposit(player, new BigDecimal("5"), REASON); + + TransactionResult result = provider.withdrawIfSufficient(player, BigDecimal.TEN, REASON); + + assertSame(TransactionResult.Status.INSUFFICIENT_FUNDS, result.getStatus()); + assertEquals(0, provider.getWithdrawCalls(), "withdraw must not be called when the funds are short"); + assertEquals(0, provider.getBalance(player).compareTo(new BigDecimal("5")), "balance must be untouched"); + } + + @Test + @DisplayName("a negative amount is refused instead of crediting the player") + void negativeAmountIsRefused() { + FakeProvider provider = new FakeProvider(0); + UUID player = UUID.randomUUID(); + provider.deposit(player, new BigDecimal("100"), REASON); + + TransactionResult result = provider.withdrawIfSufficient(player, new BigDecimal("-50"), REASON); + + assertSame(TransactionResult.Status.FAILED, result.getStatus()); + assertEquals(0, provider.getWithdrawCalls(), "a negative withdrawal must never reach the backend"); + assertEquals(0, provider.getBalance(player).compareTo(new BigDecimal("100")), "balance must be untouched"); + } + + @Test + @DisplayName("the emulated path reports EMULATED, not a guarantee it cannot make") + void emulatedPathIsHonest() { + FakeProvider provider = new FakeProvider(0); + UUID player = UUID.randomUUID(); + provider.deposit(player, new BigDecimal("100"), REASON); + + TransactionResult result = provider.withdrawIfSufficient(player, BigDecimal.TEN, REASON); + + assertTrue(result.isSuccess()); + assertSame(Guarantee.EMULATED, result.getGuarantee()); + assertSame(Guarantee.EMULATED, provider.getWithdrawGuarantee()); + assertFalse(result.getGuarantee().isCrossServerSafe(), "an emulated result is not cross-server safe"); + assertNotEquals(Guarantee.NATIVE, result.getGuarantee()); + } + + @Test + @DisplayName("the asynchronous variant completes with a result rather than throwing") + void asyncCompletesWithResult() throws Exception { + FakeProvider provider = new FakeProvider(0); + UUID player = UUID.randomUUID(); + provider.deposit(player, new BigDecimal("100"), REASON); + + TransactionResult result = provider.withdrawIfSufficientAsync(player, new BigDecimal("40"), REASON) + .get(30, TimeUnit.SECONDS); + + assertTrue(result.isSuccess()); + assertEquals(0, provider.getBalance(player).compareTo(new BigDecimal("60"))); + } +} From a1ffda36922715891d8a2a8304707264b09b0dc2 Mon Sep 17 00:00:00 2001 From: 1robie <97293924+1robie@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:11:20 +0200 Subject: [PATCH 6/7] feat: Refactor currency locking mechanism to use a map for dynamic lock management and improve concurrency handling --- .../fr/traqueur/currencies/CurrencyLocks.java | 151 +++++++++++++++--- .../traqueur/currencies/CurrencyProvider.java | 12 +- .../currencies/CurrencyLocksTest.java | 144 +++++++++++++++++ 3 files changed, 278 insertions(+), 29 deletions(-) create mode 100644 src/test/java/fr/traqueur/currencies/CurrencyLocksTest.java diff --git a/src/main/java/fr/traqueur/currencies/CurrencyLocks.java b/src/main/java/fr/traqueur/currencies/CurrencyLocks.java index 6beeaff..0b6d8e0 100644 --- a/src/main/java/fr/traqueur/currencies/CurrencyLocks.java +++ b/src/main/java/fr/traqueur/currencies/CurrencyLocks.java @@ -1,51 +1,156 @@ package fr.traqueur.currencies; +import java.util.Map; +import java.util.Objects; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; +/** + * One lock per provider and player pair, used to serialize emulated conditional operations. + * + *

Backends that cannot perform an atomic "debit only if the funds are there" have to be emulated + * as a balance read followed by a withdraw. Two threads running that sequence at the same time for + * the same player can both observe the same balance and both debit, which is the classic double + * spend. Holding a lock for the duration of the sequence removes that window.

+ * + *

The lock is keyed on the exact pair being operated on, so two unrelated players never wait on + * each other and a timeout genuinely means there was a competing operation on that same balance. + * An earlier version used a fixed set of striped locks, which was cheap but let independent players + * collide, producing a failed purchase whose message claimed a conflict that had not happened.

+ * + *

The map does not grow without bound despite having no fixed size: each entry is reference + * counted and removed as soon as the last holder or waiter is done with it, so its size tracks the + * number of operations currently in flight rather than the number of players ever seen.

+ * + *

This only protects against concurrency inside this JVM. When several servers share one + * economy database, a lock held here is invisible to the other servers. That limitation is why an + * emulated result reports {@link Guarantee#EMULATED}.

+ */ final class CurrencyLocks { - private static final int STRIPES = 1024; private static final long LOCK_TIMEOUT_MILLIS = 250L; private static final long MAIN_THREAD_LOCK_TIMEOUT_MILLIS = 25L; - private static final ReentrantLock[] LOCKS = new ReentrantLock[STRIPES]; - static { - for (int i = 0; i < STRIPES; i++) { - LOCKS[i] = new ReentrantLock(); - } - } + private static final Map LOCKS = new ConcurrentHashMap<>(); private CurrencyLocks() { } /** - * Resolve the lock guarding a given provider and player pair. + * Takes the lock guarding one provider and player pair. * * @param provider The provider performing the operation. * @param playerId The player being debited. - * @return The lock to use. + * @return A handle that must be released in a finally block, or null when the lock could not be + * taken in time. Null means no operation was performed. */ - static ReentrantLock lockFor(CurrencyProvider provider, UUID playerId) { - int hash = System.identityHashCode(provider) * 31 + (playerId == null ? 0 : playerId.hashCode()); - hash ^= (hash >>> 16); - return LOCKS[hash & (STRIPES - 1)]; - } + static Handle tryAcquire(CurrencyProvider provider, UUID playerId) { + Key key = new Key(System.identityHashCode(provider), playerId); + + CountedLock entry = LOCKS.compute(key, (k, existing) -> { + CountedLock counted = existing == null ? new CountedLock() : existing; + counted.references++; + return counted; + }); - /** - * Try to acquire a lock within the configured timeout. - * - * @param lock The lock to acquire. - * @return True when the lock was acquired and must be released by the caller. - */ - static boolean tryLock(ReentrantLock lock) { long timeout = CurrenciesAPI.isMainThread() ? MAIN_THREAD_LOCK_TIMEOUT_MILLIS : LOCK_TIMEOUT_MILLIS; + boolean acquired; try { - return lock.tryLock(timeout, TimeUnit.MILLISECONDS); + acquired = entry.lock.tryLock(timeout, TimeUnit.MILLISECONDS); } catch (InterruptedException exception) { Thread.currentThread().interrupt(); - return false; + acquired = false; + } + + if (!acquired) { + release(key); + return null; + } + + return new Handle(key, entry); + } + + /** + * Drops one reference to an entry, removing it once nobody is using it any more. + */ + private static void release(Key key) { + LOCKS.compute(key, (k, existing) -> { + if (existing == null) { + return null; + } + existing.references--; + return existing.references <= 0 ? null : existing; + }); + } + + /** + * Number of live lock entries. For tests, to prove entries do not accumulate. + */ + static int activeLockCount() { + return LOCKS.size(); + } + + /** + * A held lock. Release it in a finally block. + */ + static final class Handle { + + private final Key key; + private final CountedLock entry; + + private Handle(Key key, CountedLock entry) { + this.key = key; + this.entry = entry; + } + + void release() { + this.entry.lock.unlock(); + CurrencyLocks.release(this.key); + } + } + + private static final class CountedLock { + + private final ReentrantLock lock = new ReentrantLock(); + + private int references; + } + + /** + * Identifies one provider and player pair. + * + *

The provider is identified by its identity hash rather than by equality, because two + * distinct provider instances for the same economy are genuinely separate paths to the same + * money only by coincidence, and providers do not define equals.

+ */ + private static final class Key { + + private final int providerIdentity; + private final UUID playerId; + + private Key(int providerIdentity, UUID playerId) { + this.providerIdentity = providerIdentity; + this.playerId = playerId; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof Key)) { + return false; + } + Key key = (Key) other; + return this.providerIdentity == key.providerIdentity + && (Objects.equals(this.playerId, key.playerId)); + } + + @Override + public int hashCode() { + return this.providerIdentity * 31 + (this.playerId == null ? 0 : this.playerId.hashCode()); } } } diff --git a/src/main/java/fr/traqueur/currencies/CurrencyProvider.java b/src/main/java/fr/traqueur/currencies/CurrencyProvider.java index 7b01a81..dcf98f9 100644 --- a/src/main/java/fr/traqueur/currencies/CurrencyProvider.java +++ b/src/main/java/fr/traqueur/currencies/CurrencyProvider.java @@ -5,7 +5,6 @@ import java.math.BigDecimal; import java.util.UUID; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.locks.ReentrantLock; /** * Interface used to interact with a currency provider. @@ -100,10 +99,11 @@ default TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, if (invalid != null) { return invalid; } - - ReentrantLock lock = CurrencyLocks.lockFor(this, playerId); - if (!CurrencyLocks.tryLock(lock)) { - return TransactionResult.failed(amount, "Timed out waiting for the currency lock, nothing was taken."); + + CurrencyLocks.Handle handle = CurrencyLocks.tryAcquire(this, playerId); + if (handle == null) { + return TransactionResult.failed(amount, + "Timed out waiting for another operation on the same balance, nothing was taken."); } try { @@ -121,7 +121,7 @@ default TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, } catch (Exception exception) { return TransactionResult.failed(amount, "The backend threw while withdrawing: " + exception.getMessage()); } finally { - lock.unlock(); + handle.release(); } } diff --git a/src/test/java/fr/traqueur/currencies/CurrencyLocksTest.java b/src/test/java/fr/traqueur/currencies/CurrencyLocksTest.java new file mode 100644 index 0000000..35fe3cd --- /dev/null +++ b/src/test/java/fr/traqueur/currencies/CurrencyLocksTest.java @@ -0,0 +1,144 @@ +package fr.traqueur.currencies; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The lock is keyed per provider and player, so it has two properties worth pinning down: unrelated + * players must never wait on each other, and the entries must not pile up over time. + */ +class CurrencyLocksTest { + + private static final String REASON = "test"; + + @Test + @DisplayName("two different players do not block each other") + void differentPlayersDoNotBlockEachOther() throws Exception { + FakeProvider provider = new FakeProvider(0); + UUID first = UUID.randomUUID(); + + CurrencyLocks.Handle held = CurrencyLocks.tryAcquire(provider, first); + assertNotNull(held, "the first acquisition should succeed"); + + try { + UUID second = UUID.randomUUID(); + CurrencyLocks.Handle other = CurrencyLocks.tryAcquire(provider, second); + assertNotNull(other, "an unrelated player must not be blocked"); + other.release(); + } finally { + held.release(); + } + } + + @Test + @DisplayName("the same player is serialized, and a timeout means a real conflict") + void samePlayerIsSerialized() throws Exception { + FakeProvider provider = new FakeProvider(0); + UUID player = UUID.randomUUID(); + + CurrencyLocks.Handle held = CurrencyLocks.tryAcquire(provider, player); + assertNotNull(held); + + try { + ExecutorService pool = Executors.newSingleThreadExecutor(); + try { + Future attempt = pool.submit(() -> CurrencyLocks.tryAcquire(provider, player)); + assertNull(attempt.get(30, TimeUnit.SECONDS), "the same balance must be serialized"); + } finally { + pool.shutdownNow(); + } + } finally { + held.release(); + } + } + + @Test + @DisplayName("lock entries are released, so the map does not grow with the number of players") + void lockEntriesDoNotAccumulate() throws Exception { + FakeProvider provider = new FakeProvider(0); + + int before = CurrencyLocks.activeLockCount(); + + for (int i = 0; i < 2000; i++) { + UUID player = UUID.randomUUID(); + provider.deposit(player, BigDecimal.TEN, REASON); + provider.withdrawIfSufficient(player, BigDecimal.ONE, REASON); + } + + assertEquals(before, CurrencyLocks.activeLockCount(), + "entries must be removed once no thread holds or waits for them"); + } + + @Test + @DisplayName("entries are also released when the acquisition times out") + void timedOutAcquisitionReleasesItsEntry() throws Exception { + FakeProvider provider = new FakeProvider(0); + UUID player = UUID.randomUUID(); + + int before = CurrencyLocks.activeLockCount(); + + CurrencyLocks.Handle held = CurrencyLocks.tryAcquire(provider, player); + assertNotNull(held); + + ExecutorService pool = Executors.newSingleThreadExecutor(); + try { + assertNull(pool.submit(() -> CurrencyLocks.tryAcquire(provider, player)).get(30, TimeUnit.SECONDS)); + } finally { + pool.shutdownNow(); + } + + held.release(); + + assertEquals(before, CurrencyLocks.activeLockCount(), "a timed out attempt must not leak an entry"); + } + + @Test + @DisplayName("concurrent traffic across many players leaves no entries behind") + void concurrentTrafficLeavesNoEntries() throws Exception { + FakeProvider provider = new FakeProvider(1); + int before = CurrencyLocks.activeLockCount(); + + int threads = 8; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + try { + for (int i = 0; i < threads; i++) { + futures.add(pool.submit(() -> { + start.await(); + for (int n = 0; n < 100; n++) { + UUID player = UUID.randomUUID(); + provider.deposit(player, BigDecimal.TEN, REASON); + provider.withdrawIfSufficient(player, BigDecimal.ONE, REASON); + } + return null; + })); + } + start.countDown(); + for (Future future : futures) { + future.get(60, TimeUnit.SECONDS); + } + } finally { + pool.shutdownNow(); + } + + assertTrue(CurrencyLocks.activeLockCount() <= before, + "expected no leftover entries, got " + CurrencyLocks.activeLockCount()); + } +} From 775d5b5e8c759ff953418988dfab38962dba4482 Mon Sep 17 00:00:00 2001 From: 1robie <97293924+1robie@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:21:06 +0200 Subject: [PATCH 7/7] feat: Add nullability annotations to method parameters and return types for improved API clarity --- readme.md | 37 +++++++++++++++++++ .../fr/traqueur/currencies/Currencies.java | 15 +++++--- .../fr/traqueur/currencies/CurrenciesAPI.java | 6 ++- .../currencies/CurrencyArgumentChecks.java | 5 ++- .../traqueur/currencies/CurrencyProvider.java | 9 ++++- .../traqueur/currencies/CurrencyRegistry.java | 24 ++++++------ .../currencies/TransactionResult.java | 13 +++++-- 7 files changed, 85 insertions(+), 24 deletions(-) diff --git a/readme.md b/readme.md index b57c38c..985457a 100644 --- a/readme.md +++ b/readme.md @@ -136,6 +136,26 @@ Currencies.ZESSENTIALS.getBalance(player, "coins"); ``` +#### The default economy + +Every method that takes a currency name has an overload that does not. Those overloads use the +currency named `"default"`, which is why `"default"` shows up in the examples further down: + +```java +// These two are the same call +Currencies.VAULT.getBalance(playerId); +Currencies.VAULT.getBalance(playerId, "default"); + +// And so are these +Currencies.VAULT.withdrawIfSufficient(playerId, amount, "Shop purchase"); +Currencies.VAULT.withdrawIfSufficient(playerId, amount, "default", "Shop purchase"); +``` + +For a single-currency backend such as Vault there is nothing else to know: everything lives under +`"default"` and the short overloads are all you need. For a multi-currency backend the name selects +which currency you mean, and the short overloads would look for one actually called `"default"`, so +pass the name explicitly. + ### Safe Purchases: `withdrawIfSufficient` `withdraw` does **not** check whether the player can afford the amount. Most backends will happily @@ -250,6 +270,23 @@ To look a registered currency up, `CurrencyRegistry.require(name)` throws when t `CurrencyRegistry.find(name)` returns null. Use `registerOrReplace(...)` to deliberately swap an implementation, for example on a config reload. +#### Looking up either kind by name + +A currency name read from a config file could be a built-in constant or one of your own +registrations, and the caller usually should not have to care. `resolve(...)` handles both: + +```java +// "VAULT", "COINSENGINE", "my_gems" — all work, whichever mechanism they came from +CurrencyProvider provider = CurrencyRegistry.resolve(nameFromConfig, null); + +TransactionResult result = provider.withdrawIfSufficient(playerId, amount, "Shop purchase"); +``` + +The second argument is the currency name for a multi-currency built-in backend; pass `null` for the +default economy, and it is ignored for a custom provider since those are registered per currency +already. Built-in constants win when a name matches both, so a custom registration cannot silently +shadow `VAULT`. + Those three methods are all you have to write. Everything else has a default implementation, so an existing provider keeps working unchanged. Two optional overrides are worth knowing about: diff --git a/src/main/java/fr/traqueur/currencies/Currencies.java b/src/main/java/fr/traqueur/currencies/Currencies.java index b5573c4..3401310 100644 --- a/src/main/java/fr/traqueur/currencies/Currencies.java +++ b/src/main/java/fr/traqueur/currencies/Currencies.java @@ -3,6 +3,7 @@ import fr.traqueur.currencies.providers.*; import org.bukkit.Bukkit; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.lang.reflect.Constructor; import java.math.BigDecimal; @@ -293,7 +294,8 @@ public BigDecimal getBalance(UUID playerId, String currencyName) { * @return The outcome. Nothing is debited unless the status is * {@link TransactionResult.Status#SUCCESS}. */ - public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) { + @NotNull + public TransactionResult withdrawIfSufficient(@NotNull UUID playerId, @NotNull BigDecimal amount, @Nullable String reason) { return this.withdrawIfSufficient(playerId, amount, DEFAULT_CURRENCY_NAME, reason); } @@ -307,7 +309,8 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, * @return The outcome. Nothing is debited unless the status is * {@link TransactionResult.Status#SUCCESS}. */ - public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String currencyName, String reason) { + @NotNull + public TransactionResult withdrawIfSufficient(@NotNull UUID playerId, @NotNull BigDecimal amount, @NotNull String currencyName, @Nullable String reason) { this.canBeUse(currencyName); return this.providers.get(currencyName).withdrawIfSufficient(playerId, amount, reason); } @@ -321,7 +324,8 @@ public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, * @param reason The reason of the withdrawal. * @return A future completed with the outcome. */ - public CompletableFuture withdrawIfSufficientAsync(UUID playerId, BigDecimal amount, String currencyName, String reason) { + @NotNull + public CompletableFuture withdrawIfSufficientAsync(@NotNull UUID playerId, @NotNull BigDecimal amount, @NotNull String currencyName, @Nullable String reason) { this.canBeUse(currencyName); return this.providers.get(currencyName).withdrawIfSufficientAsync(playerId, amount, reason); } @@ -337,12 +341,13 @@ public CompletableFuture withdrawIfSufficientAsync(UUID playe * @return The provider. */ @NotNull - public CurrencyProvider getProvider(String currencyName) { + public CurrencyProvider getProvider(@NotNull String currencyName) { this.canBeUse(currencyName); return this.providers.get(currencyName); } - public Guarantee getWithdrawGuarantee(String currencyName) { + @NotNull + public Guarantee getWithdrawGuarantee(@NotNull String currencyName) { this.canBeUse(currencyName); return this.providers.get(currencyName).getWithdrawGuarantee(); } diff --git a/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java b/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java index 95e32f4..e03173c 100644 --- a/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java +++ b/src/main/java/fr/traqueur/currencies/CurrenciesAPI.java @@ -1,5 +1,8 @@ package fr.traqueur.currencies; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + import org.bukkit.Bukkit; import org.bukkit.plugin.Plugin; @@ -15,7 +18,7 @@ private CurrenciesAPI() { * * @param owningPlugin The plugin instance, must not be null. */ - public static void init(Plugin owningPlugin) { + public static void init(@NotNull Plugin owningPlugin) { if (owningPlugin == null) { throw new IllegalArgumentException("The plugin instance cannot be null."); } @@ -28,6 +31,7 @@ public static void init(Plugin owningPlugin) { /** * @return The registered plugin instance, or null when {@link #init(Plugin)} was never called. */ + @Nullable public static Plugin getPlugin() { return plugin; } diff --git a/src/main/java/fr/traqueur/currencies/CurrencyArgumentChecks.java b/src/main/java/fr/traqueur/currencies/CurrencyArgumentChecks.java index 340cf18..ae1c99c 100644 --- a/src/main/java/fr/traqueur/currencies/CurrencyArgumentChecks.java +++ b/src/main/java/fr/traqueur/currencies/CurrencyArgumentChecks.java @@ -1,5 +1,7 @@ package fr.traqueur.currencies; +import org.jetbrains.annotations.Nullable; + import java.math.BigDecimal; import java.util.UUID; @@ -23,7 +25,8 @@ private CurrencyArgumentChecks() { * @param amount The requested amount. * @return A result describing the problem, or null when the arguments are usable. */ - public static TransactionResult findProblem(UUID playerId, BigDecimal amount) { + @Nullable + public static TransactionResult findProblem(@Nullable UUID playerId, @Nullable BigDecimal amount) { if (playerId == null) { return TransactionResult.failed(amount, "The player UUID cannot be null."); } diff --git a/src/main/java/fr/traqueur/currencies/CurrencyProvider.java b/src/main/java/fr/traqueur/currencies/CurrencyProvider.java index dcf98f9..6862164 100644 --- a/src/main/java/fr/traqueur/currencies/CurrencyProvider.java +++ b/src/main/java/fr/traqueur/currencies/CurrencyProvider.java @@ -1,6 +1,8 @@ package fr.traqueur.currencies; import org.bukkit.Bukkit; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.math.BigDecimal; import java.util.UUID; @@ -53,6 +55,7 @@ public interface CurrencyProvider { * * @return The level of guarantee behind a conditional withdrawal. */ + @NotNull default Guarantee getWithdrawGuarantee() { return Guarantee.EMULATED; } @@ -94,7 +97,8 @@ default boolean requiresMainThread() { * @return The outcome. Nothing is debited unless the status is * {@link TransactionResult.Status#SUCCESS}. */ - default TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) { + @NotNull + default TransactionResult withdrawIfSufficient(@NotNull UUID playerId, @NotNull BigDecimal amount, @Nullable String reason) { TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); if (invalid != null) { return invalid; @@ -138,7 +142,8 @@ default TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, * @return A future completed with the outcome. The future itself never completes * exceptionally, failures are reported through the result. */ - default CompletableFuture withdrawIfSufficientAsync(UUID playerId, BigDecimal amount, String reason) { + @NotNull + default CompletableFuture withdrawIfSufficientAsync(@NotNull UUID playerId, @NotNull BigDecimal amount, @Nullable String reason) { TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); if (invalid != null) { return CompletableFuture.completedFuture(invalid); diff --git a/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java b/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java index 863a3ba..f4744c3 100644 --- a/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java +++ b/src/main/java/fr/traqueur/currencies/CurrencyRegistry.java @@ -46,9 +46,7 @@ private CurrencyRegistry() { * @throws IllegalArgumentException if the name or the provider is null or the name is blank. * @throws IllegalStateException if a different provider is already registered under this name. */ - public static void register(String name, CurrencyProvider provider) { - // Provider checked before the name, so register(null, null) reports the provider rather - // than blaming the name. + public static void register(@NotNull String name, @NotNull CurrencyProvider provider) { if (provider == null) { throw new IllegalArgumentException("The provider cannot be null."); } @@ -67,7 +65,8 @@ public static void register(String name, CurrencyProvider provider) { * @param provider The provider instance. * @return The provider that was previously registered, or null. */ - public static CurrencyProvider registerOrReplace(String name, CurrencyProvider provider) { + @Nullable + public static CurrencyProvider registerOrReplace(@NotNull String name, @NotNull CurrencyProvider provider) { if (provider == null) { throw new IllegalArgumentException("The provider cannot be null."); } @@ -80,7 +79,8 @@ public static CurrencyProvider registerOrReplace(String name, CurrencyProvider p * @param name The name it was registered under. * @return The removed provider, or null when nothing was registered. */ - public static CurrencyProvider unregister(String name) { + @Nullable + public static CurrencyProvider unregister(@NotNull String name) { return PROVIDERS.remove(normalize(name)); } @@ -92,7 +92,7 @@ public static CurrencyProvider unregister(String name) { * @throws IllegalStateException if nothing is registered under this name. */ @NotNull - public static CurrencyProvider require(String name) { + public static CurrencyProvider require(@NotNull String name) { CurrencyProvider provider = find(name); if (provider == null) { throw new IllegalStateException("No custom currency is registered under the name " + name @@ -108,7 +108,7 @@ public static CurrencyProvider require(String name) { * @return The provider, or null. */ @Nullable - public static CurrencyProvider find(String name) { + public static CurrencyProvider find(@NotNull String name) { return PROVIDERS.get(normalize(name)); } @@ -116,7 +116,7 @@ public static CurrencyProvider find(String name) { * @param name The name to look up. * @return True when a provider is registered under this name. */ - public static boolean isRegistered(String name) { + public static boolean isRegistered(@Nullable String name) { return name != null && PROVIDERS.containsKey(normalize(name)); } @@ -143,7 +143,8 @@ public static Set getRegisteredNames() { * @return The outcome. Nothing is debited unless the status is * {@link TransactionResult.Status#SUCCESS}. */ - public static TransactionResult withdrawIfSufficient(String name, UUID playerId, BigDecimal amount, String reason) { + @NotNull + public static TransactionResult withdrawIfSufficient(@NotNull String name, @NotNull UUID playerId, @NotNull BigDecimal amount, @Nullable String reason) { return require(name).withdrawIfSufficient(playerId, amount, reason); } @@ -156,7 +157,8 @@ public static TransactionResult withdrawIfSufficient(String name, UUID playerId, * @param reason The reason of the withdrawal. * @return A future completed with the outcome. */ - public static CompletableFuture withdrawIfSufficientAsync(String name, UUID playerId, BigDecimal amount, String reason) { + @NotNull + public static CompletableFuture withdrawIfSufficientAsync(@NotNull String name, @NotNull UUID playerId, @NotNull BigDecimal amount, @Nullable String reason) { return require(name).withdrawIfSufficientAsync(playerId, amount, reason); } @@ -179,7 +181,7 @@ public static CompletableFuture withdrawIfSufficientAsync(Str * @throws IllegalStateException if the name matches neither. */ @NotNull - public static CurrencyProvider resolve(String name, String currencyName) { + public static CurrencyProvider resolve(@NotNull String name, @Nullable String currencyName) { if (name != null) { try { Currencies currency = Currencies.fromName(name.trim().toUpperCase(java.util.Locale.ROOT)); diff --git a/src/main/java/fr/traqueur/currencies/TransactionResult.java b/src/main/java/fr/traqueur/currencies/TransactionResult.java index c2a5033..695cc18 100644 --- a/src/main/java/fr/traqueur/currencies/TransactionResult.java +++ b/src/main/java/fr/traqueur/currencies/TransactionResult.java @@ -62,7 +62,8 @@ private TransactionResult(Status status, BigDecimal amount, BigDecimal balance, * @param guarantee How strong the promise behind the operation is. * @return The result. */ - public static TransactionResult success(BigDecimal amount, BigDecimal balance, Guarantee guarantee) { + @NotNull + public static TransactionResult success(@Nullable BigDecimal amount, @Nullable BigDecimal balance, @NotNull Guarantee guarantee) { return new TransactionResult(Status.SUCCESS, amount, balance, null, guarantee); } @@ -74,7 +75,8 @@ public static TransactionResult success(BigDecimal amount, BigDecimal balance, G * @param guarantee How strong the promise behind the check is. * @return The result. */ - public static TransactionResult insufficientFunds(BigDecimal amount, BigDecimal balance, Guarantee guarantee) { + @NotNull + public static TransactionResult insufficientFunds(@Nullable BigDecimal amount, @Nullable BigDecimal balance, @NotNull Guarantee guarantee) { return new TransactionResult(Status.INSUFFICIENT_FUNDS, amount, balance, null, guarantee); } @@ -85,7 +87,8 @@ public static TransactionResult insufficientFunds(BigDecimal amount, BigDecimal * @param errorMessage A human readable explanation. * @return The result. */ - public static TransactionResult unsupported(BigDecimal amount, String errorMessage) { + @NotNull + public static TransactionResult unsupported(@Nullable BigDecimal amount, @Nullable String errorMessage) { return new TransactionResult(Status.UNSUPPORTED, amount, null, errorMessage, Guarantee.EMULATED); } @@ -96,7 +99,8 @@ public static TransactionResult unsupported(BigDecimal amount, String errorMessa * @param errorMessage A human readable explanation. * @return The result. */ - public static TransactionResult failed(BigDecimal amount, String errorMessage) { + @NotNull + public static TransactionResult failed(@Nullable BigDecimal amount, @Nullable String errorMessage) { return new TransactionResult(Status.FAILED, amount, null, errorMessage, Guarantee.EMULATED); } @@ -150,6 +154,7 @@ public String getErrorMessage() { } @Override + @NotNull public String toString() { return "TransactionResult{status=" + this.status + ", amount=" + this.amount