Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/modrinth-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,5 @@ jobs:
1.21.11
26.1
26.1.1
26.1.2
files: target/InvSwitcher-${{ github.event.release.tag_name || inputs.tag }}.jar
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
<!-- Do not change unless you want different name for local builds. -->
<build.number>-LOCAL</build.number>
<!-- This allows to change between versions. -->
<build.version>1.18.0</build.version>
<build.version>1.19.0</build.version>
<!-- Sonar Cloud -->
<sonar.projectKey>BentoBoxWorld_addon-invSwitcher</sonar.projectKey>
<sonar.organization>bentobox-world</sonar.organization>
Expand Down
35 changes: 35 additions & 0 deletions release-notes-1.19.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## 🎁 What's new

1.19.0 is a follow-up to the 1.18.0 per-world economy release. It fixes two issues that stopped the new economy from working cleanly in a common setup. InvSwitcher now functions as a true **standalone economy** — you no longer need a separate economy plugin such as EssentialsX for it to take effect — and admin economy commands now report the **correct balance for offline players** instead of a stale value.

## ✨ Highlights

### 🐛 Standalone economy now works on its own
InvSwitcher registers its own per-world Vault economy, but BentoBox hooks Vault during its early startup, *before* addons enable. When InvSwitcher was the only economy on the server, that early hook found nothing and was discarded, so BentoBox's `getVault()` stayed empty — and economy-dependent addons such as **Bank** disabled themselves with "Vault is required" even though a working per-world economy was present. InvSwitcher now registers a fresh Vault hook with BentoBox once its provider is live, so it works as the server's only economy.

> Companion framework PRs harden this further: [BentoBox #2995](https://github.com/BentoBoxWorld/BentoBox/pull/2995) retries the Vault hook after addons enable, and [Bank #67](https://github.com/BentoBoxWorld/Bank/pull/67) retries before disabling.

### 🐛 Correct balance reported for offline economy transactions
Admin `eco give`, `eco set`, and `eco take` on an offline player reported a stale balance — for example "New balance: 0.00" right after giving 2,000. The money was always stored correctly, but the confirmation message re-read the balance from the database before the asynchronous save had flushed, returning the pre-transaction value. The commands now report the authoritative balance returned by the transaction itself.

This release also hardens the underlying offline read-after-write path: offline saves are tracked while in flight so two rapid sequential transactions on the same offline player can no longer load independent stale copies and lose an update — without blocking the main thread or caching offline players indefinitely.

## ⚙️ Compatibility

✔️ BentoBox 3.17.0
✔️ Paper Minecraft 1.21.5 – 26.1.2
✔️ Java 21

## 📥 How to update
1. Take backups of your server, for safety.
2. Stop the server.
3. Drop the new InvSwitcher jar into the addons folder and remove the old one.
4. Restart the server.
5. You should be good to go!

## What's Changed
* 🐛 Register a fresh Vault hook so InvSwitcher works as a standalone economy by @tastybento in https://github.com/BentoBoxWorld/InvSwitcher/commit/d929649
* 🐛 Report offline economy balances correctly and harden offline saves by @tastybento in https://github.com/BentoBoxWorld/InvSwitcher/commit/a15c645
* Add MC 26.1.2 to Modrinth game-versions by @tastybento in https://github.com/BentoBoxWorld/InvSwitcher/pull/52

**Full Changelog**: https://github.com/BentoBoxWorld/InvSwitcher/compare/1.18.0...1.19.0
14 changes: 13 additions & 1 deletion src/main/java/com/wasteofplastic/invswitcher/InvSwitcher.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import java.util.HashSet;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

Expand Down Expand Up @@ -179,9 +180,20 @@ public void onDisable() {
* Re-runs BentoBox's VaultHook so it re-reads the highest-priority economy currently
* registered with the services manager. BentoBox addons such as Bank hold this same hook
* instance, so they pick up the change without needing to re-hook themselves.
* <p>
* If BentoBox has no stored VaultHook, its early Vault hook failed because no economy was
* registered when BentoBox's early hooks ran (the standalone case: we are the only economy).
* A failed hook is discarded by {@code HooksManager}, so {@link world.bentobox.bentobox.BentoBox#getVault()}
* stays empty and there is nothing to refresh. Now that our provider is live, register a fresh
* VaultHook so {@code getVault()} is populated for Bank and the rest of BentoBox.
*/
private void refreshBentoBoxVaultHook() {
getPlugin().getVault().ifPresent(VaultHook::hook);
Optional<VaultHook> vault = getPlugin().getVault();
if (vault.isPresent()) {
vault.get().hook();
} else {
getPlugin().getHooks().registerHook(new VaultHook());
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@


import world.bentobox.bentobox.api.addons.Addon;
import world.bentobox.bentobox.api.addons.GameModeAddon;
import world.bentobox.bentobox.api.addons.Pladdon;

public class InvSwitcherPladdon extends Pladdon {
Expand Down
73 changes: 61 additions & 12 deletions src/main/java/com/wasteofplastic/invswitcher/Store.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;

import org.bukkit.Bukkit;
Expand Down Expand Up @@ -71,6 +72,19 @@ public class Store {
private final Map<UUID, InventoryStorage> cache;
private final Map<UUID, String> currentKey;
private final InvSwitcher addon;
/**
* Offline storage objects whose asynchronous save has not yet flushed to the database, kept so
* that a follow-up read or write reuses the same in-memory copy instead of reloading a stale one.
* Without this, two rapid sequential economy transactions on an offline player would each load
* an independent copy from the database before the first save landed, losing the first update.
* Entries are dropped once all their in-flight saves complete (see {@link #pendingSaveCount}), so
* a later login still reloads fresh data. Economy operations run on the main thread, so this map
* stays small and self-clearing.
*/
private final Map<UUID, InventoryStorage> pendingSaves = new ConcurrentHashMap<>();
/** Number of in-flight saves per offline player, so a pending object is only evicted once the
* last of its saves has flushed. Mutated only on the main thread (see {@link #saveStorage}). */
private final Map<UUID, Integer> pendingSaveCount = new ConcurrentHashMap<>();

public Store(InvSwitcher addon) {
this.addon = addon;
Expand Down Expand Up @@ -247,12 +261,10 @@ public void getInventory(Player player, World world, Island island) {
// Backward compat: if island-specific key has no data, migrate from world-only key.
// This only happens once — the world-only data is cleared after migration so that
// other islands don't also inherit a duplicate copy.
if (islandKey.contains("/") && !store.isInventory(islandKey)) {
if (store.isInventory(worldKey)) {
islandLoadKey = worldKey;
// Clear the world-only data so it can't be claimed by another island
store.clearWorldData(worldKey);
}
if (islandKey.contains("/") && !store.isInventory(islandKey) && store.isInventory(worldKey)) {
islandLoadKey = worldKey;
// Clear the world-only data so it can't be claimed by another island
store.clearWorldData(worldKey);
}

// Each option uses the island key or the world key based on its island sub-setting
Expand Down Expand Up @@ -535,11 +547,8 @@ private void getStat(Statistic s, InventoryStorage store, Player player, String
case BLOCK -> store.getBlockStats(worldName).getOrDefault(s, Collections.emptyMap()).forEach((k,v) -> player.setStatistic(s, k, v));
case ITEM -> store.getItemStats(worldName).getOrDefault(s, Collections.emptyMap()).forEach((k,v) -> player.setStatistic(s, k, v));
case ENTITY -> store.getEntityStats(worldName).getOrDefault(s, Collections.emptyMap()).forEach((k,v) -> player.setStatistic(s, k, v));
case UNTYPED -> {
if (store.getUntypedStats(worldName).containsKey(s)) {
player.setStatistic(s, store.getUntypedStats(worldName).get(s));
}
}
case UNTYPED -> Optional.ofNullable(store.getUntypedStats(worldName).get(s))
.ifPresent(v -> player.setStatistic(s, v));
}
}

Expand Down Expand Up @@ -845,6 +854,13 @@ public InventoryStorage getStorageObject(UUID uuid) {
if (cache.containsKey(uuid)) {
return cache.get(uuid);
}
// An offline write may still be in flight (not yet flushed to the database). Reuse that same
// object so this read - and any follow-up write built on it - sees the pending change rather
// than a stale reload. This is what keeps rapid sequential offline transactions consistent.
InventoryStorage pending = pendingSaves.get(uuid);
if (pending != null) {
return pending;
}
if (database.objectExists(uuid.toString())) {
InventoryStorage store = database.loadObject(uuid.toString());
if (store != null) {
Expand All @@ -858,10 +874,43 @@ public InventoryStorage getStorageObject(UUID uuid) {

/**
* Persist a storage object asynchronously.
* <p>
* For an online (cached) player the cache is the source of truth, so a plain async save cannot
* be read back stale. For an offline player the object is transient and not cached, so the save
* is tracked in {@link #pendingSaves} until it flushes - otherwise a follow-up read would reload
* a stale copy from the database before the async write landed, losing the update.
* @param store - storage to save
*/
public void saveStorage(InventoryStorage store) {
database.saveObjectAsync(store);
UUID uuid = UUID.fromString(store.getUniqueId());
if (cache.containsKey(uuid)) {
database.saveObjectAsync(store);
return;
}
pendingSaves.put(uuid, store);
pendingSaveCount.merge(uuid, 1, Integer::sum);
database.saveObjectAsync(store).whenComplete((r, ex) -> onOfflineSaveComplete(uuid));
}

/**
* Drop a pending offline save once it has flushed, but only when it was the last save in flight
* for that player, so a still-pending later write keeps its object available. Runs the eviction
* on the main thread to stay consistent with {@link #saveStorage}; during shutdown the scheduler
* is unavailable, so it evicts inline.
* @param uuid - the player whose save completed
*/
private void onOfflineSaveComplete(UUID uuid) {
Runnable evict = () -> {
if (pendingSaveCount.merge(uuid, -1, Integer::sum) <= 0) {
pendingSaveCount.remove(uuid);
pendingSaves.remove(uuid);
}
};
if (addon.getPlugin().isEnabled()) {
Bukkit.getScheduler().runTask(addon.getPlugin(), evict);
} else {
evict.run();
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ public boolean execute(User user, String label, List<String> args) {
user.sendMessage("invswitcher.errors.insufficient-funds");
return false;
}
sendBalanceMessage(user, successKey(), target, world);
// Report the balance returned by the transaction itself. Re-reading here would reload an
// offline target fresh from the database before the asynchronous save has flushed, showing
// the stale pre-transaction balance.
sendBalanceMessage(user, successKey(), target, response.balance);
return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,31 @@ protected User resolveTarget(User user, String name) {

/**
* Send a message reporting the target's balance for the given world, with [name] and [number].
* Reads the balance from the economy, so only use this when no write is in flight (a write's
* asynchronous save may not have flushed yet, making a fresh read of an offline player stale).
* After a transaction, prefer {@link #sendBalanceMessage(User, String, User, double)} with the
* balance from the {@code EconomyResponse}.
* @param user - command sender
* @param messageKey - locale key of the message
* @param target - the target player
* @param world - the world to report the balance for
*/
protected void sendBalanceMessage(User user, String messageKey, User target, String world) {
sendBalanceMessage(user, messageKey, target, economy().getBalance(target.getOfflinePlayer(), world));
}

/**
* Send a message reporting a known balance, with [name] and [number]. Use this after a
* transaction with the balance returned in the {@link net.milkbowl.vault.economy.EconomyResponse},
* which is authoritative and avoids re-reading an offline player before the async save has flushed.
* @param user - command sender
* @param messageKey - locale key of the message
* @param target - the target player
* @param balance - the balance to report
*/
protected void sendBalanceMessage(User user, String messageKey, User target, double balance) {
user.sendMessage(messageKey, TextVariables.NAME, target.getName(),
TextVariables.NUMBER, economy().format(economy().getBalance(target.getOfflinePlayer(), world)));
TextVariables.NUMBER, economy().format(balance));
}

/**
Expand Down
Loading
Loading