Skip to content
14 changes: 13 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Cette exclusion n'est pas cosmétique, c'est un correctif nécessaire : develop ne résout plus ses dépendances sans elle.

Vérifié en local sur develop seul :

> Could not resolve org.mozilla:rhino:1.9.1.
  Required by: root project 'CurrenciesAPI' > com.bencodez:votingplugin:6.17.2 > com.bencodez:advancedcore:3.8.2-SNAPSHOT
  > Dependency resolution is looking for a library compatible with JVM runtime version 8,
    but 'org.mozilla:rhino:1.9.1' is only compatible with JVM runtime version 11 or newer.

(advancedcore est un SNAPSHOT, la casse est donc arrivée toute seule côté amont.)

Ça mériterait d'être extrait dans sa propre PR et mergé tout de suite, indépendamment du reste : la branche est cassée pour tout le monde en attendant.


Generated by Claude Code

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")
Expand All @@ -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
Expand Down
175 changes: 175 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -221,3 +395,4 @@ public class MyPlugin extends JavaPlugin {
}
}

```
96 changes: 86 additions & 10 deletions src/main/java/fr/traqueur/currencies/Currencies.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -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);
}

/**
Expand All @@ -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);
}

/**
Expand All @@ -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);
}

/**
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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.
*
* <p>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.</p>
*
* @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<TransactionResult> 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.
*
* <p>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.</p>
*
* @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.");
}
}
Expand Down
Loading
Loading