diff --git a/build.gradle.kts b/build.gradle.kts index 376f295..0950655 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,7 +46,9 @@ dependencies { compileOnly("com.github.MilkBowl:VaultAPI:1.7") compileOnly("com.github.PlayerNguyen:OptEco:2.1.4b") compileOnly("com.willfp:EcoBits:1.8.4") - compileOnly("com.bencodez:votingplugin:6.17.2") + compileOnly("com.bencodez:votingplugin:6.17.2") { + exclude(group = "org.mozilla", module = "rhino") + } compileOnly("com.github.Emibergo02:RedisEconomy:4.3.19") compileOnly("io.lettuce:lettuce-core:6.4.0.RELEASE") compileOnly("su.nightexpress.excellenteconomy:ExcellentEconomy:2.8.0") @@ -62,6 +64,16 @@ dependencies { compileOnly(files("libs/CoinsEngine-2.4.2.jar")) compileOnly(files("libs/nightcore-2.7.1.jar")) compileOnly(files("libs/RoyaleEconomyAPI.jar")) + + testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.2") + testImplementation("org.junit.jupiter:junit-jupiter-params:5.8.2") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.8.2") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.8.2") + testCompileOnly("org.spigotmc:spigot-api:1.21.1-R0.1-SNAPSHOT") +} + +tasks.test { + useJUnitPlatform() } val targetJavaVersion = 8 diff --git a/readme.md b/readme.md index af1a420..985457a 100644 --- a/readme.md +++ b/readme.md @@ -136,6 +136,180 @@ Currencies.ZESSENTIALS.getBalance(player, "coins"); ``` +#### The default economy + +Every method that takes a currency name has an overload that does not. Those overloads use the +currency named `"default"`, which is why `"default"` shows up in the examples further down: + +```java +// These two are the same call +Currencies.VAULT.getBalance(playerId); +Currencies.VAULT.getBalance(playerId, "default"); + +// And so are these +Currencies.VAULT.withdrawIfSufficient(playerId, amount, "Shop purchase"); +Currencies.VAULT.withdrawIfSufficient(playerId, amount, "default", "Shop purchase"); +``` + +For a single-currency backend such as Vault there is nothing else to know: everything lives under +`"default"` and the short overloads are all you need. For a multi-currency backend the name selects +which currency you mean, and the short overloads would look for one actually called `"default"`, so +pass the name explicitly. + +### Safe Purchases: `withdrawIfSufficient` + +`withdraw` does **not** check whether the player can afford the amount. Most backends will happily +drive a balance negative or silently clamp it to zero. Checking the balance first and then calling +`withdraw` is not safe either, because anything can happen between the two calls: a second click, a +second server, or an economy plugin that commits its writes asynchronously. That gap is a +double-spend. + +Use `withdrawIfSufficient` for anything that is paying for something. It performs the check and the +debit as one operation and tells you what happened: + +```java +TransactionResult result = Currencies.VAULT.withdrawIfSufficient( + playerId, new BigDecimal("1000"), "Shop purchase"); + +switch (result.getStatus()) { + case SUCCESS: + // The money is gone. Only now hand over the goods. + break; + case INSUFFICIENT_FUNDS: + player.sendMessage("You cannot afford this."); + break; + case UNSUPPORTED: + // The backend cannot do this at all. Nothing was debited. + break; + case FAILED: + // Something went wrong. Nothing was debited. + break; +} +``` + +**Only hand out the goods on `SUCCESS`.** `INSUFFICIENT_FUNDS` and `UNSUPPORTED` never debit +anything. `FAILED` normally does not either, but it cannot promise it: a backend that throws after +it has already applied the withdrawal is indistinguishable from one that failed cleanly, so treat +`FAILED` as "no goods, and worth logging" rather than as proof the balance is untouched. + +An asynchronous variant is available and never completes exceptionally, failures come back through +the result: + +```java +Currencies.VAULT.withdrawIfSufficientAsync(playerId, amount, "default", "Shop purchase") + .thenAccept(result -> { /* ... */ }); +``` + +### Guarantee Per Backend + +Backends differ in how strong a promise they can make, and it is not a yes or no question. Three +levels, reported by `Guarantee`: + +| Level | Meaning | +| --- | --- | +| `NATIVE` | The backend validated the funds inside storage every server shares. Safe against a cross-server double spend. | +| `DELEGATED` | The backend reported the outcome, but does not promise the check and the debit were indivisible. Trustworthy for one request, not a cross-server guarantee. | +| `EMULATED` | This library did the check and the debit itself under a lock. Protects one server against racing itself only. | + +Ask up front, or read it off the result: + +```java +if (!Currencies.VAULT.getWithdrawGuarantee("default").isCrossServerSafe()) { + getLogger().warning("This currency cannot guarantee purchases across servers."); +} + +result.getGuarantee(); // NATIVE, DELEGATED or EMULATED +``` + +| Currency | Guarantee | Notes | +| --- | --- | --- | +| `REDISECONOMY` | `NATIVE` | Validated in Redis, so it holds across servers | +| `EXCELLENTECONOMY` | `NATIVE` | Native async operation with a result | +| `ITEM`, `ZMENUITEMS` | `NATIVE` | Player inventory, local to this server, main thread only | +| `LEVEL`, `EXPERIENCE` | `NATIVE` | Player state, local to this server, main thread only | +| `VAULT` | `DELEGATED` | `withdrawPlayer` reports failure, but Vault delegates to whichever economy plugin is installed and most do a plain read-modify-write | +| `ZESSENTIALS` | `DELEGATED` | `withdraw` returns a boolean, indivisibility is not promised | +| `PLAYERPOINTS` | `DELEGATED` | `take` refuses when the balance is too low | +| `VOTINGPLUGIN` | `DELEGATED` | `removePoints` reports the outcome | +| `COINSENGINE` | `EMULATED` | Its boolean means "currency found", not "could afford" | +| `ECOBITS` | `EMULATED` | `adjustBalance` returns nothing | +| `BEASTTOKENS` | `EMULATED` | `removeTokens` returns nothing | +| `ROYALEECONOMY` | `EMULATED` | `removeBalance` returns nothing | +| `ELEMENTALTOKENS`, `ELEMENTALGEMS` | `EMULATED` | `removeTokens` / `removeGems` return nothing | + +If several servers share one economy database, only `NATIVE` is safe against a cross-server double +spend. `DELEGATED` is the honest answer for Vault: it does tell you whether the withdrawal worked, +which is strictly better than guessing, but the economy plugin behind it is usually not atomic. For +`EMULATED` the fix has to come from the economy plugin itself. + +### Custom Economies + +`Currencies` is an enum, so it cannot be extended. To plug in your own economy, implement +`CurrencyProvider` and register the instance: + +```java +public class MyGemsProvider implements CurrencyProvider { + public void deposit(UUID playerId, BigDecimal amount, String reason) { /* ... */ } + public void withdraw(UUID playerId, BigDecimal amount, String reason) { /* ... */ } + public BigDecimal getBalance(UUID playerId) { /* ... */ } +} + +CurrencyRegistry.register("my_gems", new MyGemsProvider()); + +TransactionResult result = CurrencyRegistry.withdrawIfSufficient( + "my_gems", playerId, BigDecimal.TEN, "Shop purchase"); +``` + +When you override `withdrawIfSufficient`, build the result with the factory that matches who made +the level your backend can actually promise: `TransactionResult.success(amount, balance, guarantee)` +and `insufficientFunds(amount, balance, guarantee)`, passing `Guarantee.NATIVE`, `DELEGATED` or +`EMULATED`. `unsupported(...)` and `failed(...)` cover the rest. That is what `getGuarantee()` +reports back to the caller, so be honest about it. + +To look a registered currency up, `CurrencyRegistry.require(name)` throws when there is none and +`CurrencyRegistry.find(name)` returns null. Use `registerOrReplace(...)` to deliberately swap an +implementation, for example on a config reload. + +#### Looking up either kind by name + +A currency name read from a config file could be a built-in constant or one of your own +registrations, and the caller usually should not have to care. `resolve(...)` handles both: + +```java +// "VAULT", "COINSENGINE", "my_gems" — all work, whichever mechanism they came from +CurrencyProvider provider = CurrencyRegistry.resolve(nameFromConfig, null); + +TransactionResult result = provider.withdrawIfSufficient(playerId, amount, "Shop purchase"); +``` + +The second argument is the currency name for a multi-currency built-in backend; pass `null` for the +default economy, and it is ignored for a custom provider since those are registered per currency +already. Built-in constants win when a name matches both, so a custom registration cannot silently +shadow `VAULT`. + +Those three methods are all you have to write. Everything else has a default implementation, so an +existing provider keeps working unchanged. Two optional overrides are worth knowing about: + +- `getWithdrawGuarantee()` and `withdrawIfSufficient(...)`: override both when your backend can + refuse a withdrawal itself. You get a real guarantee instead of the emulated one. Call + `CurrencyArgumentChecks.findProblem(playerId, amount)` first so your implementation rejects the same bad + inputs as every other provider. +- `requiresMainThread()`: **defaults to `true`**, because most Bukkit APIs are not thread safe. + Override it to return `false` only if your backend is documented as safe for concurrent access. + Leaving it `true` means `withdrawIfSufficientAsync` hops back to the main thread for you. + +### Asynchronous Access and `CurrenciesAPI.init` + +Scheduling work back onto the main server thread needs a plugin instance. If you intend to use the +asynchronous API with a main-thread-bound currency, call this once in `onEnable`: + +```java +CurrenciesAPI.init(this); +``` + +Without it, an asynchronous call on such a currency returns a `FAILED` result explaining what is +missing, rather than touching player state from the wrong thread. + ### Example Usage Here is a more complete example of how to use the `Currencies` class within a Minecraft plugin. In this example, we create an economy instance with `zEssentials` and provide a command that allows players to choose between `Vault` and `zEssentials` to deposit or withdraw an amount. @@ -221,3 +395,4 @@ public class MyPlugin extends JavaPlugin { } } +``` \ No newline at end of file diff --git a/src/main/java/fr/traqueur/currencies/Currencies.java b/src/main/java/fr/traqueur/currencies/Currencies.java index 53bb13f..3401310 100644 --- a/src/main/java/fr/traqueur/currencies/Currencies.java +++ b/src/main/java/fr/traqueur/currencies/Currencies.java @@ -2,6 +2,8 @@ import fr.traqueur.currencies.providers.*; import org.bukkit.Bukkit; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.lang.reflect.Constructor; import java.math.BigDecimal; @@ -11,6 +13,7 @@ import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.concurrent.CompletableFuture; /** * The list of all the currencies that can be used in the plugin. @@ -92,6 +95,9 @@ public enum Currencies { EXCELLENTEECONOMY("ExcellentEconomy", ExcellentEconomyProvider.class, true, true, EXCELLENTECONOMY) ; + final static String DEFAULT_CURRENCY_NAME = "default"; + private final static String DEFAULT_REASON = "No reason"; + static { Updater.checkUpdates(); } @@ -194,7 +200,7 @@ private boolean isDisable() { * @param reason The reason of the deposit. */ public void deposit(UUID playerId, BigDecimal amount, String reason) { - this.deposit(playerId, amount, "default", reason); + this.deposit(playerId, amount, DEFAULT_CURRENCY_NAME, reason); } /** @@ -205,7 +211,7 @@ public void deposit(UUID playerId, BigDecimal amount, String reason) { * @param reason The reason of the withdrawal. */ public void withdraw(UUID playerId, BigDecimal amount, String reason) { - this.withdraw(playerId, amount, "default", reason); + this.withdraw(playerId, amount, DEFAULT_CURRENCY_NAME, reason); } /** @@ -215,7 +221,7 @@ public void withdraw(UUID playerId, BigDecimal amount, String reason) { * @param amount The amount of money to add. */ public void deposit(UUID playerId, BigDecimal amount) { - this.deposit(playerId, amount, "default", "No reason"); + this.deposit(playerId, amount, DEFAULT_CURRENCY_NAME, DEFAULT_REASON); } /** @@ -225,7 +231,7 @@ public void deposit(UUID playerId, BigDecimal amount) { * @param amount The amount of money to remove. */ public void withdraw(UUID playerId, BigDecimal amount) { - this.withdraw(playerId, amount, "default", "No reason"); + this.withdraw(playerId, amount, DEFAULT_CURRENCY_NAME, DEFAULT_REASON); } /** @@ -235,7 +241,7 @@ public void withdraw(UUID playerId, BigDecimal amount) { * @return The balance of the player. */ public BigDecimal getBalance(UUID playerId) { - return getBalance(playerId, "default"); + return this.getBalance(playerId, DEFAULT_CURRENCY_NAME); } /** @@ -276,19 +282,89 @@ public BigDecimal getBalance(UUID playerId, String currencyName) { return this.providers.get(currencyName).getBalance(playerId); } + /** + * Remove some money from a player, but only if the player can actually afford it. + * + *

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

+ * + * @param playerId The UUID of the player to debit. + * @param amount The amount to debit, must be strictly positive. + * @param reason The reason of the withdrawal. + * @return The outcome. Nothing is debited unless the status is + * {@link TransactionResult.Status#SUCCESS}. + */ + @NotNull + public TransactionResult withdrawIfSufficient(@NotNull UUID playerId, @NotNull BigDecimal amount, @Nullable String reason) { + return this.withdrawIfSufficient(playerId, amount, DEFAULT_CURRENCY_NAME, reason); + } + + /** + * Remove some money from a player, but only if the player can actually afford it. + * + * @param playerId The UUID of the player to debit. + * @param amount The amount to debit, must be strictly positive. + * @param currencyName The name of the currency. + * @param reason The reason of the withdrawal. + * @return The outcome. Nothing is debited unless the status is + * {@link TransactionResult.Status#SUCCESS}. + */ + @NotNull + public TransactionResult withdrawIfSufficient(@NotNull UUID playerId, @NotNull BigDecimal amount, @NotNull String currencyName, @Nullable String reason) { + this.canBeUse(currencyName); + return this.providers.get(currencyName).withdrawIfSufficient(playerId, amount, reason); + } + + /** + * Asynchronous variant of {@link #withdrawIfSufficient(UUID, BigDecimal, String, String)}. + * + * @param playerId The UUID of the player to debit. + * @param amount The amount to debit, must be strictly positive. + * @param currencyName The name of the currency. + * @param reason The reason of the withdrawal. + * @return A future completed with the outcome. + */ + @NotNull + public CompletableFuture withdrawIfSufficientAsync(@NotNull UUID playerId, @NotNull BigDecimal amount, @NotNull String currencyName, @Nullable String reason) { + this.canBeUse(currencyName); + return this.providers.get(currencyName).withdrawIfSufficientAsync(playerId, amount, reason); + } + + /** + * Returns the provider backing this currency, creating it if necessary. + * + *

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

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

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

+ */ +public final class CurrencyArgumentChecks { + + private CurrencyArgumentChecks() { + } + + /** + * Looks for a problem with the arguments to a conditional currency operation. + * + * @param playerId The player. + * @param amount The requested amount. + * @return A result describing the problem, or null when the arguments are usable. + */ + @Nullable + public static TransactionResult findProblem(@Nullable UUID playerId, @Nullable BigDecimal amount) { + if (playerId == null) { + return TransactionResult.failed(amount, "The player UUID cannot be null."); + } + if (amount == null) { + return TransactionResult.failed(BigDecimal.ZERO, "The amount cannot be null."); + } + if (amount.signum() <= 0) { + return TransactionResult.failed(amount, "The amount must be strictly positive, was " + amount + "."); + } + return null; + } +} diff --git a/src/main/java/fr/traqueur/currencies/CurrencyExecutor.java b/src/main/java/fr/traqueur/currencies/CurrencyExecutor.java new file mode 100644 index 0000000..2bdca2e --- /dev/null +++ b/src/main/java/fr/traqueur/currencies/CurrencyExecutor.java @@ -0,0 +1,44 @@ +package fr.traqueur.currencies; + +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * The thread pool used by the asynchronous currency operations. + * + *

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

+ * + *

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

+ */ +final class CurrencyExecutor { + private static final int THREADS = Math.max(2, Math.min(8, Runtime.getRuntime().availableProcessors())); + + private static final Executor EXECUTOR = Executors.newFixedThreadPool(THREADS, new ThreadFactory() { + + private final AtomicInteger counter = new AtomicInteger(); + + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "CurrenciesAPI-async-" + this.counter.incrementAndGet()); + thread.setDaemon(true); + return thread; + } + }); + + private CurrencyExecutor() { + } + + /** + * @return The executor for asynchronous currency operations. + */ + static Executor get() { + return EXECUTOR; + } +} diff --git a/src/main/java/fr/traqueur/currencies/CurrencyLocks.java b/src/main/java/fr/traqueur/currencies/CurrencyLocks.java new file mode 100644 index 0000000..0b6d8e0 --- /dev/null +++ b/src/main/java/fr/traqueur/currencies/CurrencyLocks.java @@ -0,0 +1,156 @@ +package fr.traqueur.currencies; + +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +/** + * One lock per provider and player pair, used to serialize emulated conditional operations. + * + *

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

+ * + *

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

+ * + *

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

+ * + *

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

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

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

+ */ + private static final class Key { + + private final int providerIdentity; + private final UUID playerId; + + private Key(int providerIdentity, UUID playerId) { + this.providerIdentity = providerIdentity; + this.playerId = playerId; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof Key)) { + return false; + } + Key key = (Key) other; + return this.providerIdentity == key.providerIdentity + && (Objects.equals(this.playerId, key.playerId)); + } + + @Override + public int hashCode() { + return this.providerIdentity * 31 + (this.playerId == null ? 0 : this.playerId.hashCode()); + } + } +} diff --git a/src/main/java/fr/traqueur/currencies/CurrencyProvider.java b/src/main/java/fr/traqueur/currencies/CurrencyProvider.java index 49873f8..6862164 100644 --- a/src/main/java/fr/traqueur/currencies/CurrencyProvider.java +++ b/src/main/java/fr/traqueur/currencies/CurrencyProvider.java @@ -1,7 +1,12 @@ package fr.traqueur.currencies; +import org.bukkit.Bukkit; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + import java.math.BigDecimal; import java.util.UUID; +import java.util.concurrent.CompletableFuture; /** * Interface used to interact with a currency provider. @@ -36,4 +41,136 @@ public interface CurrencyProvider { */ BigDecimal getBalance(UUID playerId); + /** + * How strong a promise this provider can make about {@link #withdrawIfSufficient}. + * + *

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

+ * + *

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

+ * + * @return The level of guarantee behind a conditional withdrawal. + */ + @NotNull + default Guarantee getWithdrawGuarantee() { + return Guarantee.EMULATED; + } + + /** + * Whether this provider must be used from the main server thread. + * + *

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

+ * + *

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

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

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

+ * + *

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

+ * + * @param playerId The UUID of the player to debit. + * @param amount The amount to debit, must be strictly positive. + * @param reason The reason of the withdrawal. + * @return The outcome. Nothing is debited unless the status is + * {@link TransactionResult.Status#SUCCESS}. + */ + @NotNull + default TransactionResult withdrawIfSufficient(@NotNull UUID playerId, @NotNull BigDecimal amount, @Nullable String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return invalid; + } + + CurrencyLocks.Handle handle = CurrencyLocks.tryAcquire(this, playerId); + if (handle == null) { + return TransactionResult.failed(amount, + "Timed out waiting for another operation on the same balance, nothing was taken."); + } + + try { + BigDecimal balance = this.getBalance(playerId); + if (balance == null) { + balance = BigDecimal.ZERO; + } + + if (balance.compareTo(amount) < 0) { + return TransactionResult.insufficientFunds(amount, balance, Guarantee.EMULATED); + } + + this.withdraw(playerId, amount, reason); + return TransactionResult.success(amount, balance.subtract(amount), Guarantee.EMULATED); + } catch (Exception exception) { + return TransactionResult.failed(amount, "The backend threw while withdrawing: " + exception.getMessage()); + } finally { + handle.release(); + } + } + + /** + * Asynchronous variant of {@link #withdrawIfSufficient}. + * + *

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

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

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

+ */ +public final class CurrencyRegistry { + + private static final Map PROVIDERS = new ConcurrentHashMap(); + + private CurrencyRegistry() { + } + + /** + * Register a custom provider under a name. + * + * @param name The name used to look the provider up. Case-insensitive. + * @param provider The provider instance. + * @throws IllegalArgumentException if the name or the provider is null or the name is blank. + * @throws IllegalStateException if a different provider is already registered under this name. + */ + public static void register(@NotNull String name, @NotNull CurrencyProvider provider) { + if (provider == null) { + throw new IllegalArgumentException("The provider cannot be null."); + } + String key = normalize(name); + + CurrencyProvider existing = PROVIDERS.putIfAbsent(key, provider); + if (existing != null && existing != provider) { + throw new IllegalStateException("A different provider is already registered for the currency " + name + "."); + } + } + + /** + * Register a custom provider, replacing any provider already registered under this name. + * + * @param name The name used to look the provider up. Case-insensitive. + * @param provider The provider instance. + * @return The provider that was previously registered, or null. + */ + @Nullable + public static CurrencyProvider registerOrReplace(@NotNull String name, @NotNull CurrencyProvider provider) { + if (provider == null) { + throw new IllegalArgumentException("The provider cannot be null."); + } + return PROVIDERS.put(normalize(name), provider); + } + + /** + * Remove a registered provider. + * + * @param name The name it was registered under. + * @return The removed provider, or null when nothing was registered. + */ + @Nullable + public static CurrencyProvider unregister(@NotNull String name) { + return PROVIDERS.remove(normalize(name)); + } + + /** + * Look a registered provider up, failing when there is none. + * + * @param name The name it was registered under. + * @return The provider, never null. + * @throws IllegalStateException if nothing is registered under this name. + */ + @NotNull + public static CurrencyProvider require(@NotNull String name) { + CurrencyProvider provider = find(name); + if (provider == null) { + throw new IllegalStateException("No custom currency is registered under the name " + name + + ". Register one with CurrencyRegistry.register(name, provider) first."); + } + return provider; + } + + /** + * Look a registered provider up, returning null when there is none. + * + * @param name The name it was registered under. + * @return The provider, or null. + */ + @Nullable + public static CurrencyProvider find(@NotNull String name) { + return PROVIDERS.get(normalize(name)); + } + + /** + * @param name The name to look up. + * @return True when a provider is registered under this name. + */ + public static boolean isRegistered(@Nullable String name) { + return name != null && PROVIDERS.containsKey(normalize(name)); + } + + /** + * The names of every registered custom currency. + * + *

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

+ * + * @return A snapshot of the registered names, in their normalized form. + */ + @NotNull + public static Set getRegisteredNames() { + return Collections.unmodifiableSet(new java.util.HashSet<>(PROVIDERS.keySet())); + } + + /** + * Debit a registered custom currency, but only if the funds are available. + * + * @param name The name the provider was registered under. + * @param playerId The UUID of the player to debit. + * @param amount The amount to debit, must be strictly positive. + * @param reason The reason of the withdrawal. + * @return The outcome. Nothing is debited unless the status is + * {@link TransactionResult.Status#SUCCESS}. + */ + @NotNull + public static TransactionResult withdrawIfSufficient(@NotNull String name, @NotNull UUID playerId, @NotNull BigDecimal amount, @Nullable String reason) { + return require(name).withdrawIfSufficient(playerId, amount, reason); + } + + /** + * Asynchronous variant of {@link #withdrawIfSufficient(String, UUID, BigDecimal, String)}. + * + * @param name The name the provider was registered under. + * @param playerId The UUID of the player to debit. + * @param amount The amount to debit, must be strictly positive. + * @param reason The reason of the withdrawal. + * @return A future completed with the outcome. + */ + @NotNull + public static CompletableFuture withdrawIfSufficientAsync(@NotNull String name, @NotNull UUID playerId, @NotNull BigDecimal amount, @Nullable String reason) { + return require(name).withdrawIfSufficientAsync(playerId, amount, reason); + } + + /** + * Resolves a currency by name, whether it is one of the built in {@link Currencies} constants + * or a custom provider registered here. + * + *

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

+ * + *

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

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

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

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

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

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

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

+ */ + EMULATED; + + /** + * Whether this level is safe when several servers share one economy. + * + * @return True only for {@link #NATIVE}. + */ + public boolean isCrossServerSafe() { + return this == NATIVE; + } +} diff --git a/src/main/java/fr/traqueur/currencies/TransactionResult.java b/src/main/java/fr/traqueur/currencies/TransactionResult.java new file mode 100644 index 0000000..695cc18 --- /dev/null +++ b/src/main/java/fr/traqueur/currencies/TransactionResult.java @@ -0,0 +1,166 @@ +package fr.traqueur.currencies; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.math.BigDecimal; + +public final class TransactionResult { + + public enum Status { + + /** + * The funds were available and the debit was applied. + */ + SUCCESS, + + /** + * The player did not have enough funds. Nothing was debited. + */ + INSUFFICIENT_FUNDS, + + /** + * The backend does not implement this operation at all. Nothing was debited. + * Callers must decide for themselves whether to fall back to a non-atomic + * balance check followed by a plain withdraw, or to refuse the operation. + */ + UNSUPPORTED, + + /** + * The operation could not be completed for any other reason, for example the economy + * plugin returned an error or the player data could not be loaded. + * + *

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

+ */ + FAILED + } + + private final Status status; + private final BigDecimal amount; + private final BigDecimal balance; + private final String errorMessage; + private final Guarantee guarantee; + + private TransactionResult(Status status, BigDecimal amount, BigDecimal balance, String errorMessage, Guarantee guarantee) { + this.status = status; + this.amount = amount == null ? BigDecimal.ZERO : amount; + this.balance = balance; + this.errorMessage = errorMessage; + this.guarantee = guarantee; + } + + /** + * Build a successful result. + * + * @param amount The amount that was debited. + * @param balance The resulting balance, or null when it is not known. + * @param guarantee How strong the promise behind the operation is. + * @return The result. + */ + @NotNull + public static TransactionResult success(@Nullable BigDecimal amount, @Nullable BigDecimal balance, @NotNull Guarantee guarantee) { + return new TransactionResult(Status.SUCCESS, amount, balance, null, guarantee); + } + + /** + * Build a result for a player who could not afford the amount. Nothing was debited. + * + * @param amount The amount that was requested. + * @param balance The balance that was observed, or null when it is not known. + * @param guarantee How strong the promise behind the check is. + * @return The result. + */ + @NotNull + public static TransactionResult insufficientFunds(@Nullable BigDecimal amount, @Nullable BigDecimal balance, @NotNull Guarantee guarantee) { + return new TransactionResult(Status.INSUFFICIENT_FUNDS, amount, balance, null, guarantee); + } + + /** + * Build a result for a backend that cannot perform this operation. Nothing was debited. + * + * @param amount The amount that was requested. + * @param errorMessage A human readable explanation. + * @return The result. + */ + @NotNull + public static TransactionResult unsupported(@Nullable BigDecimal amount, @Nullable String errorMessage) { + return new TransactionResult(Status.UNSUPPORTED, amount, null, errorMessage, Guarantee.EMULATED); + } + + /** + * Build a failed result. Nothing was debited. + * + * @param amount The amount that was requested. + * @param errorMessage A human readable explanation. + * @return The result. + */ + @NotNull + public static TransactionResult failed(@Nullable BigDecimal amount, @Nullable String errorMessage) { + return new TransactionResult(Status.FAILED, amount, null, errorMessage, Guarantee.EMULATED); + } + + /** + * @return The outcome of the operation. + */ + @NotNull + public Status getStatus() { + return this.status; + } + + /** + * @return True only when the funds were actually debited. + */ + public boolean isSuccess() { + return this.status == Status.SUCCESS; + } + + /** + * @return How strong the promise behind this operation was. Use + * {@link Guarantee#isCrossServerSafe()} to decide whether it holds on a network where several + * servers share one economy. + */ + @NotNull + public Guarantee getGuarantee() { + return this.guarantee; + } + + /** + * @return The amount that was requested. + */ + @NotNull + public BigDecimal getAmount() { + return this.amount; + } + + /** + * @return The resulting balance, or null when the backend does not report one. + */ + @Nullable + public BigDecimal getBalance() { + return this.balance; + } + + /** + * @return A human-readable explanation for a failure, or null. + */ + @Nullable + public String getErrorMessage() { + return this.errorMessage; + } + + @Override + @NotNull + public String toString() { + return "TransactionResult{status=" + this.status + + ", amount=" + this.amount + + ", balance=" + this.balance + + ", guarantee=" + this.guarantee + + (this.errorMessage == null ? "" : ", error='" + this.errorMessage + "'") + + '}'; + } +} diff --git a/src/main/java/fr/traqueur/currencies/providers/ExcellentEconomyProvider.java b/src/main/java/fr/traqueur/currencies/providers/ExcellentEconomyProvider.java index 3b26755..95faece 100644 --- a/src/main/java/fr/traqueur/currencies/providers/ExcellentEconomyProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/ExcellentEconomyProvider.java @@ -1,13 +1,19 @@ package fr.traqueur.currencies.providers; +import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.Guarantee; +import fr.traqueur.currencies.TransactionResult; import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.plugin.RegisteredServiceProvider; import su.nightexpress.excellenteconomy.api.ExcellentEconomyAPI; import su.nightexpress.excellenteconomy.api.currency.operation.OperationContext; +import su.nightexpress.excellenteconomy.api.currency.operation.OperationResult; import java.math.BigDecimal; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiFunction; import java.util.UUID; public class ExcellentEconomyProvider implements CurrencyProvider { @@ -53,4 +59,81 @@ public BigDecimal getBalance(UUID playerId) { : this.api.getBalanceAsync(playerId, this.currencyName).join(); return BigDecimal.valueOf(raw); } + + private BigDecimal getBalanceWithoutBukkit(UUID playerId) { + try { + Double raw = this.api.getBalanceAsync(playerId, this.currencyName).join(); + return raw == null ? null : BigDecimal.valueOf(raw); + } catch (Exception exception) { + return null; + } + } + + @Override + public Guarantee getWithdrawGuarantee() { + return Guarantee.NATIVE; + } + + @Override + public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return invalid; + } + + try { + OperationContext ctx = OperationContext.custom(reason); + + OperationResult result = this.api.withdrawAsync(playerId, this.currencyName, amount.doubleValue(), ctx).join(); + + if (result != null && result.success()) { + return TransactionResult.success(amount, null, Guarantee.NATIVE); + } + + BigDecimal balance = this.getBalanceWithoutBukkit(playerId); + if (balance != null && balance.compareTo(amount) < 0) { + return TransactionResult.insufficientFunds(amount, balance, Guarantee.NATIVE); + } + + return TransactionResult.failed(amount, "ExcellentEconomy refused the withdrawal and the player could afford it, " + + "so the currency " + this.currencyName + " may be unknown or the player data may not be loaded."); + } catch (Exception exception) { + return TransactionResult.failed(amount, "ExcellentEconomy threw while withdrawing: " + exception.getMessage()); + } + } + + @Override + public CompletableFuture withdrawIfSufficientAsync(UUID playerId, BigDecimal amount, String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return CompletableFuture.completedFuture(invalid); + } + + try { + OperationContext ctx = OperationContext.custom(reason); + return this.api.withdrawAsync(playerId, this.currencyName, amount.doubleValue(), ctx) + .handle((result, throwable) -> { + if (throwable != null) { + return TransactionResult.failed(amount, "ExcellentEconomy threw while withdrawing: " + throwable.getMessage()); + } + if (result != null && result.success()) { + return TransactionResult.success(amount, null, Guarantee.NATIVE); + } + BigDecimal balance = this.getBalanceWithoutBukkit(playerId); + if (balance != null && balance.compareTo(amount) < 0) { + return TransactionResult.insufficientFunds(amount, balance, Guarantee.NATIVE); + } + return TransactionResult.failed(amount, "ExcellentEconomy refused the withdrawal and the player could afford it, " + + "so the currency " + this.currencyName + " may be unknown or the player data may not be loaded."); + }); + } catch (Exception exception) { + return CompletableFuture.completedFuture( + TransactionResult.failed(amount, "ExcellentEconomy threw while withdrawing: " + exception.getMessage())); + } + } + + @Override + public boolean requiresMainThread() { + return false; + } } diff --git a/src/main/java/fr/traqueur/currencies/providers/ExperienceProvider.java b/src/main/java/fr/traqueur/currencies/providers/ExperienceProvider.java index bad37b2..e779f73 100644 --- a/src/main/java/fr/traqueur/currencies/providers/ExperienceProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/ExperienceProvider.java @@ -1,6 +1,9 @@ package fr.traqueur.currencies.providers; +import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.Guarantee; +import fr.traqueur.currencies.TransactionResult; import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -13,8 +16,8 @@ public class ExperienceProvider implements CurrencyProvider { public void deposit(UUID playerId, BigDecimal amount, String reason) { Player player = Bukkit.getPlayer(playerId); if (player != null) { - BigDecimal totalExperience = BigDecimal.valueOf(getTotalExperience(player)); - setTotalExperience(player, totalExperience.add(amount).intValue()); + BigDecimal totalExperience = BigDecimal.valueOf(this.getTotalExperience(player)); + this.setTotalExperience(player, totalExperience.add(amount).intValue()); } } @@ -22,16 +25,16 @@ public void deposit(UUID playerId, BigDecimal amount, String reason) { public void withdraw(UUID playerId, BigDecimal amount, String reason) { Player player = Bukkit.getPlayer(playerId); if (player != null) { - BigDecimal totalExperience = BigDecimal.valueOf(getTotalExperience(player)); + BigDecimal totalExperience = BigDecimal.valueOf(this.getTotalExperience(player)); BigDecimal newExperience = totalExperience.subtract(amount); - setTotalExperience(player, newExperience.max(BigDecimal.ZERO).intValue()); + this.setTotalExperience(player, newExperience.max(BigDecimal.ZERO).intValue()); } } @Override public BigDecimal getBalance(UUID playerId) { Player player = Bukkit.getPlayer(playerId); - return player != null ? BigDecimal.valueOf(getTotalExperience(player)) : BigDecimal.ZERO; + return player != null ? BigDecimal.valueOf(this.getTotalExperience(player)) : BigDecimal.ZERO; } private void setTotalExperience(Player player, int experience) { @@ -41,7 +44,7 @@ private void setTotalExperience(Player player, int experience) { player.setTotalExperience(0); int currentExperience = experience; while (currentExperience > 0) { - int j = getExpAtLevel(player); + int j = this.getExpAtLevel(player); currentExperience -= j; if (currentExperience >= 0) { player.giveExp(j); @@ -54,7 +57,7 @@ private void setTotalExperience(Player player, int experience) { } private int getExpAtLevel(Player player) { - return getExpAtLevel(player.getLevel()); + return this.getExpAtLevel(player.getLevel()); } private int getExpAtLevel(int experience) { @@ -64,15 +67,46 @@ private int getExpAtLevel(int experience) { } private int getTotalExperience(Player player) { - int experience = Math.round(getExpAtLevel(player) * player.getExp()); + int experience = Math.round(this.getExpAtLevel(player) * player.getExp()); int playerLevel = player.getLevel(); while (playerLevel > 0) { playerLevel--; - experience += getExpAtLevel(playerLevel); + experience += this.getExpAtLevel(playerLevel); } if (experience < 0) { experience = Integer.MAX_VALUE; } return experience; } + + @Override + public Guarantee getWithdrawGuarantee() { + return Guarantee.NATIVE; + } + + @Override + public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return invalid; + } + + Player player = Bukkit.getPlayer(playerId); + if (player == null) { + return TransactionResult.failed(amount, "Experience can only be taken from an online player."); + } + + if (amount.stripTrailingZeros().scale() > 0) { + return TransactionResult.failed(amount, "Experience only supports whole amounts, got " + amount + "."); + } + + BigDecimal current = BigDecimal.valueOf(this.getTotalExperience(player)); + if (current.compareTo(amount) < 0) { + return TransactionResult.insufficientFunds(amount, current, Guarantee.NATIVE); + } + + BigDecimal remaining = current.subtract(amount); + this.setTotalExperience(player, remaining.intValue()); + return TransactionResult.success(amount, remaining, Guarantee.NATIVE); + } } diff --git a/src/main/java/fr/traqueur/currencies/providers/ItemProvider.java b/src/main/java/fr/traqueur/currencies/providers/ItemProvider.java index f5f0deb..a8603ca 100644 --- a/src/main/java/fr/traqueur/currencies/providers/ItemProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/ItemProvider.java @@ -1,6 +1,9 @@ package fr.traqueur.currencies.providers; +import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.Guarantee; +import fr.traqueur.currencies.TransactionResult; import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; @@ -24,7 +27,7 @@ public ItemProvider(Plugin plugin, ItemStack itemStack) { public void deposit(UUID playerId, BigDecimal amount, String reason) { Player player = Bukkit.getPlayer(playerId); if (player != null) { - giveItem(player, amount.intValue(), this.itemStack); + this.giveItem(player, amount.intValue(), this.itemStack); } else{ this.plugin.getLogger().severe("Deposit items to " + playerId + " but is offline"); } @@ -34,7 +37,7 @@ public void deposit(UUID playerId, BigDecimal amount, String reason) { public void withdraw(UUID playerId, BigDecimal amount, String reason) { Player player = Bukkit.getPlayer(playerId); if (player != null) { - removeItems(player, this.itemStack, amount.intValue()); + this.removeItems(player, this.itemStack, amount.intValue()); } else { this.plugin.getLogger().severe("Withdraw items from " + playerId + " but is offline"); } @@ -44,7 +47,7 @@ public void withdraw(UUID playerId, BigDecimal amount, String reason) { public BigDecimal getBalance(UUID playerId) { Player player = Bukkit.getPlayer(playerId); if (player != null) { - return BigDecimal.valueOf(getAmount(player, this.itemStack)); + return BigDecimal.valueOf(this.getAmount(player, this.itemStack)); } else return BigDecimal.ZERO; } @@ -89,11 +92,11 @@ protected void giveItem(Player player, long value, ItemStack itemStack) { if (value > 64) { value -= 64; itemStack.setAmount(64); - give(player, itemStack); - giveItem(player, value, itemStack); + this.give(player, itemStack); + this.giveItem(player, value, itemStack); } else { itemStack.setAmount((int) value); - give(player, itemStack); + this.give(player, itemStack); } } @@ -102,7 +105,7 @@ public ItemStack getItemStack(Player player) { } private void give(Player player, ItemStack item) { - if (hasInventoryFull(player)) player.getWorld().dropItem(player.getLocation(), item); + if (this.hasInventoryFull(player)) player.getWorld().dropItem(player.getLocation(), item); else player.getInventory().addItem(item); } @@ -115,4 +118,46 @@ private boolean hasInventoryFull(Player player) { } return slot == 0; } + + @Override + public Guarantee getWithdrawGuarantee() { + return Guarantee.NATIVE; + } + + @Override + public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return invalid; + } + + Player player = Bukkit.getPlayer(playerId); + if (player == null) { + return TransactionResult.failed(amount, "Items can only be taken from an online player."); + } + + if (amount.stripTrailingZeros().scale() > 0) { + return TransactionResult.failed(amount, "An item currency only supports whole amounts, got " + amount + "."); + } + + int cost; + try { + cost = amount.intValueExact(); + } catch (ArithmeticException exception) { + return TransactionResult.failed(amount, "The amount does not fit in an integer item count: " + amount + "."); + } + + ItemStack currencyItem = this.getItemStack(player); + if (currencyItem == null) { + return TransactionResult.failed(amount, "The currency item could not be resolved for " + player.getName() + "."); + } + + int held = this.getAmount(player, currencyItem); + if (held < cost) { + return TransactionResult.insufficientFunds(amount, BigDecimal.valueOf(held), Guarantee.NATIVE); + } + + this.removeItems(player, currencyItem, cost); + return TransactionResult.success(amount, BigDecimal.valueOf(held - cost), Guarantee.NATIVE); + } } diff --git a/src/main/java/fr/traqueur/currencies/providers/LevelProvider.java b/src/main/java/fr/traqueur/currencies/providers/LevelProvider.java index 023d324..56fbb52 100644 --- a/src/main/java/fr/traqueur/currencies/providers/LevelProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/LevelProvider.java @@ -1,6 +1,9 @@ package fr.traqueur.currencies.providers; +import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.Guarantee; +import fr.traqueur.currencies.TransactionResult; import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -32,4 +35,41 @@ public BigDecimal getBalance(UUID playerId) { Player player = Bukkit.getPlayer(playerId); return BigDecimal.valueOf(player != null ? player.getLevel() : 0); } + + @Override + public Guarantee getWithdrawGuarantee() { + return Guarantee.NATIVE; + } + + @Override + public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return invalid; + } + + Player player = Bukkit.getPlayer(playerId); + if (player == null) { + return TransactionResult.failed(amount, "Levels can only be taken from an online player."); + } + + if (amount.stripTrailingZeros().scale() > 0) { + return TransactionResult.failed(amount, "Levels only support whole amounts, got " + amount + "."); + } + + int cost; + try { + cost = amount.intValueExact(); + } catch (ArithmeticException exception) { + return TransactionResult.failed(amount, "The amount does not fit in an integer level: " + amount + "."); + } + + int current = player.getLevel(); + if (current < cost) { + return TransactionResult.insufficientFunds(amount, BigDecimal.valueOf(current), Guarantee.NATIVE); + } + + player.setLevel(current - cost); + return TransactionResult.success(amount, BigDecimal.valueOf(current - cost), Guarantee.NATIVE); + } } diff --git a/src/main/java/fr/traqueur/currencies/providers/PlayerPointsProvider.java b/src/main/java/fr/traqueur/currencies/providers/PlayerPointsProvider.java index 3de7f77..80baa58 100644 --- a/src/main/java/fr/traqueur/currencies/providers/PlayerPointsProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/PlayerPointsProvider.java @@ -1,6 +1,9 @@ package fr.traqueur.currencies.providers; +import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.Guarantee; +import fr.traqueur.currencies.TransactionResult; import org.black_ixx.playerpoints.PlayerPoints; import org.black_ixx.playerpoints.PlayerPointsAPI; import org.bukkit.plugin.java.JavaPlugin; @@ -35,4 +38,34 @@ public void withdraw(UUID playerId, BigDecimal amount, String reason) { public BigDecimal getBalance(UUID playerId) { return BigDecimal.valueOf(this.getAPI().look(playerId)); } + + @Override + public Guarantee getWithdrawGuarantee() { + return Guarantee.DELEGATED; + } + + @Override + public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return invalid; + } + + try { + if (amount.stripTrailingZeros().scale() > 0) { + return TransactionResult.failed(amount, "PlayerPoints only supports whole amounts, got " + amount + "."); + } + + int points = amount.intValueExact(); + if (this.getAPI().take(playerId, points)) { + return TransactionResult.success(amount, BigDecimal.valueOf(this.getAPI().look(playerId)), Guarantee.DELEGATED); + } + + return TransactionResult.insufficientFunds(amount, BigDecimal.valueOf(this.getAPI().look(playerId)), Guarantee.DELEGATED); + } catch (ArithmeticException exception) { + return TransactionResult.failed(amount, "The amount does not fit in a PlayerPoints integer: " + amount + "."); + } catch (Exception exception) { + return TransactionResult.failed(amount, "PlayerPoints threw while withdrawing: " + exception.getMessage()); + } + } } diff --git a/src/main/java/fr/traqueur/currencies/providers/RedisEconomyProvider.java b/src/main/java/fr/traqueur/currencies/providers/RedisEconomyProvider.java index 72564fc..e5b14c7 100644 --- a/src/main/java/fr/traqueur/currencies/providers/RedisEconomyProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/RedisEconomyProvider.java @@ -2,7 +2,11 @@ import dev.unnm3d.rediseconomy.api.RedisEconomyAPI; import dev.unnm3d.rediseconomy.currency.Currency; +import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.Guarantee; +import fr.traqueur.currencies.TransactionResult; +import net.milkbowl.vault.economy.EconomyResponse; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; @@ -57,4 +61,53 @@ public BigDecimal getBalance(UUID playerId) { } return BigDecimal.ZERO; } + + @Override + public Guarantee getWithdrawGuarantee() { + return Guarantee.NATIVE; + } + + @Override + public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return invalid; + } + + try { + Currency currency = this.getCurrency(); + if (currency == null) { + return TransactionResult.failed(amount, "The RedisEconomy currency " + this.economyName + " was not found."); + } + + OfflinePlayer offlinePlayer = Bukkit.getOfflinePlayer(playerId); + EconomyResponse response = currency.withdrawPlayer(offlinePlayer, amount.doubleValue()); + if (response == null) { + return TransactionResult.failed(amount, "RedisEconomy returned no response."); + } + + if (response.type == EconomyResponse.ResponseType.SUCCESS) { + return TransactionResult.success(amount, BigDecimal.valueOf(response.balance), Guarantee.NATIVE); + } + + if (response.type == EconomyResponse.ResponseType.NOT_IMPLEMENTED) { + return TransactionResult.unsupported(amount, "RedisEconomy does not implement withdrawPlayer."); + } + + if (!currency.has(playerId, amount.doubleValue())) { + return TransactionResult.insufficientFunds(amount, BigDecimal.valueOf(currency.getBalance(playerId)), Guarantee.NATIVE); + } + + return TransactionResult.failed(amount, response.errorMessage == null + ? "RedisEconomy refused the withdrawal." + : response.errorMessage); + } catch (Exception exception) { + return TransactionResult.failed(amount, "RedisEconomy threw while withdrawing: " + exception.getMessage()); + } + } + + @Override + public boolean requiresMainThread() { + return false; + } } diff --git a/src/main/java/fr/traqueur/currencies/providers/VaultProvider.java b/src/main/java/fr/traqueur/currencies/providers/VaultProvider.java index 371f92f..13f1c04 100644 --- a/src/main/java/fr/traqueur/currencies/providers/VaultProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/VaultProvider.java @@ -1,7 +1,11 @@ package fr.traqueur.currencies.providers; +import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.Guarantee; +import fr.traqueur.currencies.TransactionResult; import net.milkbowl.vault.economy.Economy; +import net.milkbowl.vault.economy.EconomyResponse; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.plugin.RegisteredServiceProvider; @@ -43,4 +47,46 @@ public BigDecimal getBalance(UUID playerId) { OfflinePlayer offlinePlayer = Bukkit.getOfflinePlayer(playerId); return BigDecimal.valueOf(this.getEconomy().getBalance(offlinePlayer)); } + + @Override + public Guarantee getWithdrawGuarantee() { + return Guarantee.DELEGATED; + } + + @Override + public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return invalid; + } + + try { + OfflinePlayer offlinePlayer = Bukkit.getOfflinePlayer(playerId); + Economy vaultEconomy = this.getEconomy(); + + EconomyResponse response = vaultEconomy.withdrawPlayer(offlinePlayer, amount.doubleValue()); + if (response == null) { + return TransactionResult.failed(amount, "The Vault economy returned no response."); + } + + if (response.type == EconomyResponse.ResponseType.SUCCESS) { + return TransactionResult.success(amount, BigDecimal.valueOf(response.balance), Guarantee.DELEGATED); + } + + if (response.type == EconomyResponse.ResponseType.NOT_IMPLEMENTED) { + return TransactionResult.unsupported(amount, "The Vault economy does not implement withdrawPlayer."); + } + + BigDecimal balance = BigDecimal.valueOf(vaultEconomy.getBalance(offlinePlayer)); + if (balance.compareTo(amount) < 0) { + return TransactionResult.insufficientFunds(amount, balance, Guarantee.DELEGATED); + } + + return TransactionResult.failed(amount, response.errorMessage == null + ? "The Vault economy refused the withdrawal." + : response.errorMessage); + } catch (Exception exception) { + return TransactionResult.failed(amount, "The Vault economy threw while withdrawing: " + exception.getMessage()); + } + } } diff --git a/src/main/java/fr/traqueur/currencies/providers/VotingProvider.java b/src/main/java/fr/traqueur/currencies/providers/VotingProvider.java index 2d299b1..c6b029d 100644 --- a/src/main/java/fr/traqueur/currencies/providers/VotingProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/VotingProvider.java @@ -2,7 +2,11 @@ import com.bencodez.votingplugin.VotingPluginHooks; import com.bencodez.votingplugin.user.UserManager; +import com.bencodez.votingplugin.user.VotingPluginUser; +import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.Guarantee; +import fr.traqueur.currencies.TransactionResult; import java.math.BigDecimal; import java.util.UUID; @@ -25,4 +29,33 @@ public void withdraw(UUID playerId, BigDecimal amount, String reason) { public BigDecimal getBalance(UUID playerId) { return BigDecimal.valueOf(this.userManager.getVotingPluginUser(playerId).getPoints()); } + + @Override + public Guarantee getWithdrawGuarantee() { + return Guarantee.DELEGATED; + } + + @Override + public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return invalid; + } + + try { + if (amount.stripTrailingZeros().scale() > 0) { + return TransactionResult.failed(amount, "VotingPlugin only supports whole amounts, got " + amount + "."); + } + + VotingPluginUser user = this.userManager.getVotingPluginUser(playerId); + if (user.removePoints(amount.intValueExact())) { + return TransactionResult.success(amount, BigDecimal.valueOf(user.getPoints()), Guarantee.DELEGATED); + } + return TransactionResult.insufficientFunds(amount, BigDecimal.valueOf(user.getPoints()), Guarantee.DELEGATED); + } catch (ArithmeticException exception) { + return TransactionResult.failed(amount, "The amount does not fit in a VotingPlugin integer: " + amount + "."); + } catch (Exception exception) { + return TransactionResult.failed(amount, "VotingPlugin threw while withdrawing: " + exception.getMessage()); + } + } } diff --git a/src/main/java/fr/traqueur/currencies/providers/ZEssentialsProvider.java b/src/main/java/fr/traqueur/currencies/providers/ZEssentialsProvider.java index 949c502..3ae0dc7 100644 --- a/src/main/java/fr/traqueur/currencies/providers/ZEssentialsProvider.java +++ b/src/main/java/fr/traqueur/currencies/providers/ZEssentialsProvider.java @@ -3,7 +3,10 @@ import fr.maxlego08.essentials.api.EssentialsPlugin; import fr.maxlego08.essentials.api.economy.Economy; import fr.maxlego08.essentials.api.economy.EconomyManager; +import fr.traqueur.currencies.CurrencyArgumentChecks; import fr.traqueur.currencies.CurrencyProvider; +import fr.traqueur.currencies.Guarantee; +import fr.traqueur.currencies.TransactionResult; import org.bukkit.Bukkit; import java.math.BigDecimal; @@ -21,33 +24,59 @@ public ZEssentialsProvider(String economyName) { } private void initialize() { - if (economyManager == null || economy == null) { + if (this.economyManager == null || this.economy == null) { EssentialsPlugin essentialsPlugin = (EssentialsPlugin) Bukkit.getPluginManager().getPlugin("zEssentials"); + if (essentialsPlugin == null) { + throw new IllegalStateException("The plugin zEssentials is not installed."); + } this.economyManager = essentialsPlugin.getEconomyManager(); - Optional optional = economyManager.getEconomy(economyName); + Optional optional = this.economyManager.getEconomy(this.economyName); if (optional.isPresent()) { this.economy = optional.get(); } else { - throw new NullPointerException("ZEssentials economy " + economyName + " not found"); + throw new IllegalStateException("The zEssentials economy " + this.economyName + " was not found."); } } } @Override public void deposit(UUID playerId, BigDecimal amount, String reason) { - initialize(); + this.initialize(); this.economyManager.deposit(playerId, this.economy, amount, reason); } @Override public void withdraw(UUID playerId, BigDecimal amount, String reason) { - initialize(); + this.initialize(); this.economyManager.withdraw(playerId, this.economy, amount, reason); } @Override public BigDecimal getBalance(UUID playerId) { - initialize(); + this.initialize(); return this.economyManager.getBalance(Bukkit.getOfflinePlayer(playerId), this.economy); } + + @Override + public Guarantee getWithdrawGuarantee() { + return Guarantee.DELEGATED; + } + + @Override + public TransactionResult withdrawIfSufficient(UUID playerId, BigDecimal amount, String reason) { + TransactionResult invalid = CurrencyArgumentChecks.findProblem(playerId, amount); + if (invalid != null) { + return invalid; + } + + try { + this.initialize(); + if (this.economyManager.withdraw(playerId, this.economy, amount, reason)) { + return TransactionResult.success(amount, this.getBalance(playerId), Guarantee.DELEGATED); + } + return TransactionResult.insufficientFunds(amount, this.getBalance(playerId), Guarantee.DELEGATED); + } catch (Exception exception) { + return TransactionResult.failed(amount, "zEssentials threw while withdrawing: " + exception.getMessage()); + } + } } diff --git a/src/test/java/fr/traqueur/currencies/CurrencyArgumentChecksTest.java b/src/test/java/fr/traqueur/currencies/CurrencyArgumentChecksTest.java new file mode 100644 index 0000000..55a4b4b --- /dev/null +++ b/src/test/java/fr/traqueur/currencies/CurrencyArgumentChecksTest.java @@ -0,0 +1,47 @@ +package fr.traqueur.currencies; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.math.BigDecimal; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +class CurrencyArgumentChecksTest { + + @Test + @DisplayName("usable arguments report no problem") + void usableArgumentsReportNoProblem() { + assertNull(CurrencyArgumentChecks.findProblem(UUID.randomUUID(), BigDecimal.TEN)); + } + + @Test + @DisplayName("a null player is a problem") + void nullPlayerIsAProblem() { + TransactionResult problem = CurrencyArgumentChecks.findProblem(null, BigDecimal.TEN); + assertNotNull(problem); + assertSame(TransactionResult.Status.FAILED, problem.getStatus()); + } + + @Test + @DisplayName("a null amount is a problem") + void nullAmountIsAProblem() { + TransactionResult problem = CurrencyArgumentChecks.findProblem(UUID.randomUUID(), null); + assertNotNull(problem); + assertSame(TransactionResult.Status.FAILED, problem.getStatus()); + } + + @ParameterizedTest + @ValueSource(strings = {"0", "-0.01", "-1", "-1000000"}) + @DisplayName("a zero or negative amount is a problem") + void nonPositiveAmountIsAProblem(String amount) { + TransactionResult problem = CurrencyArgumentChecks.findProblem(UUID.randomUUID(), new BigDecimal(amount)); + assertNotNull(problem, amount + " should be refused"); + assertSame(TransactionResult.Status.FAILED, problem.getStatus()); + } +} diff --git a/src/test/java/fr/traqueur/currencies/CurrencyLocksTest.java b/src/test/java/fr/traqueur/currencies/CurrencyLocksTest.java new file mode 100644 index 0000000..35fe3cd --- /dev/null +++ b/src/test/java/fr/traqueur/currencies/CurrencyLocksTest.java @@ -0,0 +1,144 @@ +package fr.traqueur.currencies; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The lock is keyed per provider and player, so it has two properties worth pinning down: unrelated + * players must never wait on each other, and the entries must not pile up over time. + */ +class CurrencyLocksTest { + + private static final String REASON = "test"; + + @Test + @DisplayName("two different players do not block each other") + void differentPlayersDoNotBlockEachOther() throws Exception { + FakeProvider provider = new FakeProvider(0); + UUID first = UUID.randomUUID(); + + CurrencyLocks.Handle held = CurrencyLocks.tryAcquire(provider, first); + assertNotNull(held, "the first acquisition should succeed"); + + try { + UUID second = UUID.randomUUID(); + CurrencyLocks.Handle other = CurrencyLocks.tryAcquire(provider, second); + assertNotNull(other, "an unrelated player must not be blocked"); + other.release(); + } finally { + held.release(); + } + } + + @Test + @DisplayName("the same player is serialized, and a timeout means a real conflict") + void samePlayerIsSerialized() throws Exception { + FakeProvider provider = new FakeProvider(0); + UUID player = UUID.randomUUID(); + + CurrencyLocks.Handle held = CurrencyLocks.tryAcquire(provider, player); + assertNotNull(held); + + try { + ExecutorService pool = Executors.newSingleThreadExecutor(); + try { + Future attempt = pool.submit(() -> CurrencyLocks.tryAcquire(provider, player)); + assertNull(attempt.get(30, TimeUnit.SECONDS), "the same balance must be serialized"); + } finally { + pool.shutdownNow(); + } + } finally { + held.release(); + } + } + + @Test + @DisplayName("lock entries are released, so the map does not grow with the number of players") + void lockEntriesDoNotAccumulate() throws Exception { + FakeProvider provider = new FakeProvider(0); + + int before = CurrencyLocks.activeLockCount(); + + for (int i = 0; i < 2000; i++) { + UUID player = UUID.randomUUID(); + provider.deposit(player, BigDecimal.TEN, REASON); + provider.withdrawIfSufficient(player, BigDecimal.ONE, REASON); + } + + assertEquals(before, CurrencyLocks.activeLockCount(), + "entries must be removed once no thread holds or waits for them"); + } + + @Test + @DisplayName("entries are also released when the acquisition times out") + void timedOutAcquisitionReleasesItsEntry() throws Exception { + FakeProvider provider = new FakeProvider(0); + UUID player = UUID.randomUUID(); + + int before = CurrencyLocks.activeLockCount(); + + CurrencyLocks.Handle held = CurrencyLocks.tryAcquire(provider, player); + assertNotNull(held); + + ExecutorService pool = Executors.newSingleThreadExecutor(); + try { + assertNull(pool.submit(() -> CurrencyLocks.tryAcquire(provider, player)).get(30, TimeUnit.SECONDS)); + } finally { + pool.shutdownNow(); + } + + held.release(); + + assertEquals(before, CurrencyLocks.activeLockCount(), "a timed out attempt must not leak an entry"); + } + + @Test + @DisplayName("concurrent traffic across many players leaves no entries behind") + void concurrentTrafficLeavesNoEntries() throws Exception { + FakeProvider provider = new FakeProvider(1); + int before = CurrencyLocks.activeLockCount(); + + int threads = 8; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + try { + for (int i = 0; i < threads; i++) { + futures.add(pool.submit(() -> { + start.await(); + for (int n = 0; n < 100; n++) { + UUID player = UUID.randomUUID(); + provider.deposit(player, BigDecimal.TEN, REASON); + provider.withdrawIfSufficient(player, BigDecimal.ONE, REASON); + } + return null; + })); + } + start.countDown(); + for (Future future : futures) { + future.get(60, TimeUnit.SECONDS); + } + } finally { + pool.shutdownNow(); + } + + assertTrue(CurrencyLocks.activeLockCount() <= before, + "expected no leftover entries, got " + CurrencyLocks.activeLockCount()); + } +} diff --git a/src/test/java/fr/traqueur/currencies/FakeProvider.java b/src/test/java/fr/traqueur/currencies/FakeProvider.java new file mode 100644 index 0000000..8d3947d --- /dev/null +++ b/src/test/java/fr/traqueur/currencies/FakeProvider.java @@ -0,0 +1,65 @@ +package fr.traqueur.currencies; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * An in-memory provider used to exercise the emulated {@link CurrencyProvider#withdrawIfSufficient} + * without a server. + * + *

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

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