diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 6efcfbb..ba6a608 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -76,11 +76,10 @@ jobs: path: coverage-summary.md - name: Post coverage summary comment on PR - if: ${{ github.event_name == 'pull_request' }} + if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} uses: peter-evans/create-or-update-comment@v5 with: token: ${{ secrets.GITHUB_TOKEN }} issue-number: ${{ github.event.pull_request.number }} body-file: coverage-summary.md edit-mode: replace - identifier: coverage-summary diff --git a/.gitignore b/.gitignore index debc96c..4864ff8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,34 +1,26 @@ -# Ignore Maven target directory -/**/target/ - -# Ignore compiled classes directories -**/classes/ - -# Ignore module build outputs -ezeconomy-bukkit/target/ -ezeconomy-papi/target/ - -# Ignore IDE files -/.idea/ -*.iml -*.ipr -*.iws - -# Ignore Eclipse files -.classpath -.project -.settings/ - -# Ignore OS files -.DS_Store -Thumbs.db - -# Ignore logs -*.log - -# Ignore build tools -/build/ - -# Ignore other common files -*.swp -*.swo + +/**/target/ +# Ignore compiled classes directories +**/classes/ +# Ignore module build outputs +ezeconomy-bukkit/target/ +ezeconomy-papi/target/ +# Ignore IDE files +/.idea/ +*.iml +*.ipr +*.iws +# Ignore Eclipse files +.classpath +.project +.settings/ +# Ignore OS files +.DS_Store +Thumbs.db +# Ignore logs +*.log +# Ignore build tools +/build/ +# Ignore other common files +*.swp +*.swo \ No newline at end of file diff --git a/README.md b/README.md index 3ecc4dc..31fe958 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,9 @@ **EzEconomy** is a professional-grade Vault economy provider for Minecraft servers. Choose from YML, MySQL, SQLite, MongoDB, or custom storage with multi-currency support, async caching, and thorough permission controls. +> **Original plugin by [@ez-plugins](https://github.com/ez-plugins) — [ez-plugins/EzEconomy](https://github.com/ez-plugins/EzEconomy)** +> This fork adds Velocity proxy support, HikariCP connection pooling, cross-server payments, and other improvements by [@GitEpildev](https://github.com/gitEpildev). + --- ## 📚 Documentation @@ -42,6 +45,7 @@ EzEconomy is designed for performance, reliability, and operational clarity. Hig - **Async caching**: Optimized for large servers - **Comprehensive commands**: `/balance`, `/eco`, `/baltop`, `/bank`, `/pay`, `/currency` - **Granular permissions**: Per-command and per-bank action +- **Velocity proxy support**: Cross-server payments, network-wide player list, and offline notification queuing --- @@ -63,6 +67,8 @@ EzEconomy is designed for performance, reliability, and operational clarity. Hig - `ezeconomy.balance.others`: View other players' balances - `ezeconomy.eco`: Use /eco admin command - `ezeconomy.pay`: Use /pay command +- `ezeconomy.payall`: Use /pay * to pay all players +- `ezeconomy.payall.bypasswithdraw`: Pay all without deducting from sender balance - `ezeconomy.currency`: Use /currency command - **Bank Permissions**: - `ezeconomy.bank.create`: Create a new bank @@ -168,12 +174,72 @@ mongodb: ## ⬇️ Installation -1. Place `EzEconomy.jar` in your plugins folder +1. Place `ezeconomy-bukkit-*.jar` in your Paper/Spigot server's `plugins/` folder 2. Configure `config.yml` and the appropriate `config-*.yml` file for your storage type 3. Restart your server --- +## Velocity / Proxy Support + +This fork includes a dedicated `ezeconomy-velocity` module that enables full cross-server economy on Velocity networks. Both the Velocity proxy plugin and the Bukkit plugin work together over a shared plugin messaging channel (`ezeconomy:notify`). + +### What it does + +| Feature | How it works | +|---------|-------------| +| **Cross-server `/pay`** | A player on Server A can `/pay PlayerB 100` even if PlayerB is on Server B. The Velocity plugin forwards the "You received ..." notification to the correct backend. | +| **Cross-server `/bal`** | `/bal PlayerB` resolves players across all servers using the shared MySQL `players` table and the Velocity network player list. | +| **`/pay *` (pay all)** | Pays every online player on every backend server, not just the local one. Remote recipients get notifications forwarded through Velocity. | +| **Network player list** | The Velocity plugin broadcasts a UUID + name list of all connected players to every backend every 3 seconds. This powers tab-completion and cross-server player resolution. | +| **Offline notification queue** | If a recipient is offline when payment arrives, the notification is queued. MySQL storage: persisted to a `pending_notifications` table. Other storage: held in-memory until the player next logs in. | + +### Prerequisites + +- **Velocity proxy** (not BungeeCord — the velocity module uses the Velocity API) +- **MySQL storage** (`storage: mysql`) configured identically on every backend server, pointing to the **same database**. This is required so all servers share balances and player records. +- **`store-on-join: true`** recommended on every backend so player UUID/name records are persisted to MySQL when they join any server. Without this, cross-server lookups may fail for players who have never been seen by the database. + +### Setup + +1. **Velocity proxy** — place `ezeconomy-velocity-*.jar` in the proxy's `plugins/` folder. +2. **Each Paper backend** — place `ezeconomy-bukkit-*.jar` in the server's `plugins/` folder. +3. **Each backend `config.yml`** — set: + +```yaml +storage: mysql + +store-on-join: + enabled: true + +cross-server: + enabled: true + verbose-logging: false # set true temporarily for debugging +``` + +4. **Each backend `config-mysql.yml`** — point to the **same** MySQL database: + +```yaml +mysql: + host: your-shared-db-host + port: 3306 + database: ezeconomy + username: ezeconomy + password: your-password + table: balances +``` + +5. **Restart** the Velocity proxy first, then all backend servers. + +### Troubleshooting + +- **"Player not found" on cross-server `/pay` or `/bal`**: make sure `cross-server.enabled: true` and `store-on-join.enabled: true` are set on the backend where the command is run. The target player must have joined at least once since `store-on-join` was enabled. +- **Enable verbose logging**: set `cross-server.verbose-logging: true` temporarily to see `PLAYER_LIST` and `NOTIFY` messages in the console. +- **Command conflicts**: if another plugin intercepts `/pay` or `/payall`, use the built-in aliases `/ezpay` and `/ezpayall` instead. +- **Vault required**: each Paper backend needs `Vault` (or `Vault-Updated`) installed so EzEconomy can register as the economy provider. + +--- + ## 🔗 Integration - EzEconomy automatically registers as a Vault provider diff --git a/docs/commands.md b/docs/commands.md index 02f4329..cab83fd 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -70,5 +70,4 @@ Using `/pay * ` sends the specified amount to multiple recipients at onc - By default the command enumerates online players via the server; enabling `pay.pay_all.include_offline` uses the storage provider to enumerate stored balances and may include offline-only accounts. - A summary message (`paid_all_summary`) is sent to the sender after successful execution. Recipients receive the standard payment notification if they are online. - Large recipient sets or mixed-currency conversions may increase execution time; consider enabling the feature only for trusted admins and ensure backup/monitoring is in place. - -If you'd like, I can also add a short example snippet and cross-link to the config defaults in `src/main/resources/config.yml`. + - On Velocity networks with `cross-server.enabled: true`, `/pay *` includes players from all backend servers. Each remote recipient receives a notification forwarded through the proxy. diff --git a/docs/configuration.md b/docs/configuration.md index a1828eb..0b64d6d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -45,6 +45,41 @@ banking: Set `banking.enabled` to `false` if you prefer using a different bank plugin or want to disable shared bank accounts. +### Store on join + +Control whether player metadata (UUID + name) is written to storage when a player joins. + +```yaml +store-on-join: + enabled: false +``` + +When disabled, EzEconomy skips the join-time write. **On Velocity networks this should be `true`** so that every backend server populates the shared MySQL `players` table. Without it, cross-server commands like `/bal ` and `/pay ` cannot resolve players who have only joined a different backend. + +### Cross-server messaging (Velocity) + +These settings control the plugin messaging channel used by the `ezeconomy-velocity` proxy module. + +```yaml +cross-server: + enabled: true + verbose-logging: false +``` + +- `enabled` — set to `true` on every Paper backend that participates in the Velocity network. When `false` (the default for single-server setups), the plugin does not register the messaging channel and all cross-server features are disabled. +- `verbose-logging` — set to `true` temporarily to see `PLAYER_LIST`, `NOTIFY`, and `RECIPIENT_OFFLINE` messages in the console. Useful for verifying that the Velocity plugin is broadcasting the player list and forwarding payment notifications. + +> **Requires**: the `ezeconomy-velocity-*.jar` plugin running on the Velocity proxy, `storage: mysql` with all backends pointing to the same database, and `store-on-join.enabled: true`. + +### Payment sync timeout + +```yaml +payment: + sync-event-timeout-ms: 5000 +``` + +This controls how long async payment execution waits for sync event dispatch before the payment is cancelled for safety. + ### Caching strategy Configure how EzEconomy caches frequently-read values (placeholders, top lists, GUI data). @@ -61,6 +96,23 @@ caching-strategy: LOCAL If `caching-strategy` is not present, the plugin will fallback to the older `locking-strategy` value for backward compatibility. +### Lock timing + +Configure lock acquisition timing independently from the selected lock backend. + +```yaml +locking-strategy: LOCAL +locking: + ttl-ms: 5000 + retry-ms: 50 + max-attempts: 100 +``` + +- `locking.ttl-ms`: lock lease duration in milliseconds. +- `locking.retry-ms`: wait time between lock retries. +- `locking.max-attempts`: maximum retry attempts before failing lock acquisition. +- Legacy `redis.ttl-ms`, `redis.retry-ms`, and `redis.max-attempts` are still accepted as fallback values. + ### Notes - `storage` must match one of the supported providers: `yml`, `mysql`, `sqlite`, `mongodb`, or `custom`. diff --git a/docs/database.md b/docs/database.md index a945ea1..ef68cfa 100644 --- a/docs/database.md +++ b/docs/database.md @@ -13,15 +13,13 @@ EzEconomy supports multiple storage backends to store player balances, bank data ## Configuration -Storage providers are configured in the main `config.yml` file under the `storage` section: +Storage providers are configured in the main `config.yml` file: ```yaml -storage: - type: sqlite # Options: yml, sqlite, mysql, mongodb - # Provider-specific config below +storage: sqlite # Options: yml, sqlite, mysql, mongodb ``` -Each provider has its own configuration file (e.g., `config-sqlite.yml`) that is loaded based on the type. +Each provider has its own configuration file (for example, `config-sqlite.yml`) that is loaded based on this value. ## YML Storage Provider @@ -29,7 +27,7 @@ Each provider has its own configuration file (e.g., `config-sqlite.yml`) that is Stores data in YAML files on the filesystem. Each player has their own file, and bank data is stored in the owner's file. ### Setup -1. Set `storage.type: yml` in `config.yml` +1. Set `storage: yml` in `config.yml`. 2. Configure in `config-yml.yml`: ```yaml yml: @@ -66,7 +64,7 @@ Stores data in YAML files on the filesystem. Each player has their own file, and Uses a local SQLite database file for all data storage. ### Setup -1. Set `storage.type: sqlite` in `config.yml` +1. Set `storage: sqlite` in `config.yml`. 2. Configure in `config-sqlite.yml`: ```yaml sqlite: @@ -124,7 +122,7 @@ CREATE TABLE bank_members ( Uses a remote MySQL database for scalable storage. ### Setup -1. Set `storage.type: mysql` in `config.yml` +1. Set `storage: mysql` in `config.yml`. 2. Configure in `config-mysql.yml`: ```yaml mysql: @@ -190,7 +188,7 @@ CREATE TABLE transactions ( Uses MongoDB for NoSQL document-based storage. ### Setup -1. Set `storage.type: mongodb` in `config.yml` +1. Set `storage: mongodb` in `config.yml`. 2. Configure in `config-mongodb.yml`: ```yaml mongodb: @@ -269,4 +267,4 @@ Currently, there is no automatic migration tool. To switch providers: 1. **Permission denied**: Ensure the plugin has write access to the data folder 2. **Connection failed**: Check database credentials and network connectivity -3. **Table creation failed**: Ensure the database user has CREATE privileges \ No newline at end of file +3. **Table creation failed**: Ensure the database user has CREATE privileges. \ No newline at end of file diff --git a/docs/developer-api.md b/docs/developer-api.md index 643a8bc..879f018 100644 --- a/docs/developer-api.md +++ b/docs/developer-api.md @@ -1,7 +1,19 @@ # Developer API (v2) -This file has moved to the API folder. See the full developer API documentation at: +The full developer API documentation now lives under the `docs/api/` folder. -- [docs/api/README.md](api/README.md) +## Start Here -The new location contains the complete Developer API guide, examples, and links to storage and command docs. +- [API Overview](api/README.md) +- [Storage Provider API](api/storage-provider.md) + +## Event Reference + +- [PreTransactionEvent](api/event/PreTransactionEvent.md) +- [PostTransactionEvent](api/event/PostTransactionEvent.md) +- [PlayerPayPlayerEvent](api/event/PlayerPayPlayerEvent.md) +- [TransactionType](api/event/TransactionType.md) +- [BankPreTransactionEvent](api/event/BankPreTransactionEvent.md) +- [BankPostTransactionEvent](api/event/BankPostTransactionEvent.md) + +These pages include method summaries, usage notes, and examples for integrations. diff --git a/docs/overview.md b/docs/overview.md index 8de8442..7dd9c25 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -9,7 +9,7 @@ EzEconomy is a Vault-compatible economy provider built for reliability, clarity, - **Multi-currency**: Optional per-player currency selection with conversion rates. - **Async caching**: Keeps balance lookups fast on busy servers. - **Banking system**: Shared accounts with member management and permissions. -- **Banking system**: Shared accounts with member management and permissions. You can disable the built-in banking subsystem via `banking.enabled: false` in `config.yml` if you run an external bank plugin or don't need bank features. +- **Optional banking toggle**: Disable built-in banking via `banking.enabled: false` in `config.yml` if you run an external bank plugin. ## Supported Versions @@ -32,6 +32,10 @@ EzEconomy targets modern Paper/Spigot servers that support Vault. For best resul - **Configuration**: See storage-specific settings and multi-currency setup. - **Commands & Permissions**: Confirm staff and player access rules. -- **Storage Details**: Understand backend behavior and data safety. - -- **Events**: EzEconomy now exposes pre/post transaction events for integrations and moderation. See `docs/api/event/PreTransactionEvent.md`, `docs/api/event/PostTransactionEvent.md`, `docs/api/event/PlayerPayPlayerEvent.md`, and `docs/api/event/TransactionType.md` for details and examples. +- **Storage details**: Understand backend behavior and data safety. +- **Events**: EzEconomy exposes transaction events for integrations and moderation. + See: + - `docs/api/event/PreTransactionEvent.md` + - `docs/api/event/PostTransactionEvent.md` + - `docs/api/event/PlayerPayPlayerEvent.md` + - `docs/api/event/TransactionType.md` diff --git a/docs/permissions.md b/docs/permissions.md index 9305de4..b0d7598 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -2,6 +2,11 @@ Assign permissions through your permissions plugin (LuckPerms, PermissionsEx, etc.). +## Notes + +- All bank permissions are disabled if `banking.enabled` is set to `false`. +- Grant `ezeconomy.bank.admin` only to trusted staff. + ## Player Permissions | Permission | Description | @@ -33,5 +38,5 @@ Assign permissions through your permissions plugin (LuckPerms, PermissionsEx, et ## Recommended Roles - **Players**: `ezeconomy.pay`, `ezeconomy.currency` -- **Staff**: `ezeconomy.balance.others` +- **Moderators/Staff**: `ezeconomy.balance.others` - **Administrators**: `ezeconomy.eco`, `ezeconomy.bank.admin` diff --git a/ezeconomy-bukkit/pom.xml b/ezeconomy-bukkit/pom.xml index d0d26bb..a37d36d 100644 --- a/ezeconomy-bukkit/pom.xml +++ b/ezeconomy-bukkit/pom.xml @@ -5,7 +5,7 @@ com.github.ez-plugins ezeconomy-parent - 2.5.1 + 2.6.0 ../pom.xml ezeconomy-bukkit diff --git a/ezeconomy-bukkit/src/integration/java/com/skyblockexp/ezeconomy/storage/MySQLStorageProviderH2IntegrationTest.java b/ezeconomy-bukkit/src/integration/java/com/skyblockexp/ezeconomy/storage/MySQLStorageProviderH2IntegrationTest.java index 0d000ec..4b20b48 100644 --- a/ezeconomy-bukkit/src/integration/java/com/skyblockexp/ezeconomy/storage/MySQLStorageProviderH2IntegrationTest.java +++ b/ezeconomy-bukkit/src/integration/java/com/skyblockexp/ezeconomy/storage/MySQLStorageProviderH2IntegrationTest.java @@ -1,37 +1,37 @@ package com.skyblockexp.ezeconomy.storage; import com.skyblockexp.ezeconomy.core.EzEconomyPlugin; -import com.skyblockexp.ezeconomy.test.DbTestHelper; import org.bukkit.configuration.file.YamlConfiguration; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockbukkit.mockbukkit.MockBukkit; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; import java.lang.reflect.Field; -import java.sql.Connection; -import java.sql.Statement; import java.util.UUID; import static org.junit.jupiter.api.Assertions.*; public class MySQLStorageProviderH2IntegrationTest { - private Connection conn; + private HikariDataSource testDataSource; @BeforeEach void setup() throws Exception { try { MockBukkit.mock(); } catch (IllegalStateException e) { MockBukkit.unmock(); MockBukkit.mock(); } - conn = DbTestHelper.createH2MemoryMysql(); - try (Statement s = conn.createStatement()) { - s.executeUpdate("CREATE TABLE IF NOT EXISTS balances (uuid VARCHAR(36), currency VARCHAR(32), balance DOUBLE, PRIMARY KEY (uuid, currency))"); - s.executeUpdate("CREATE TABLE IF NOT EXISTS players (uuid VARCHAR(36) PRIMARY KEY, name VARCHAR(64), displayName VARCHAR(128))"); - } + HikariConfig hc = new HikariConfig(); + hc.setJdbcUrl("jdbc:h2:mem:test;MODE=MySQL;DB_CLOSE_DELAY=-1"); + hc.setUsername("sa"); + hc.setPassword(""); + hc.setMaximumPoolSize(2); + testDataSource = new HikariDataSource(hc); } @AfterEach void teardown() throws Exception { - try { if (conn != null && !conn.isClosed()) conn.close(); } catch (Exception ignored) {} + try { if (testDataSource != null && !testDataSource.isClosed()) testDataSource.close(); } catch (Exception ignored) {} try { MockBukkit.unmock(); } catch (Exception ignored) {} } @@ -48,10 +48,11 @@ void setGetDepositWithdrawFlow() throws Exception { MySQLStorageProvider provider = new MySQLStorageProvider(plugin, cfg); - // Inject the H2 connection into the provider using reflection - Field connField = MySQLStorageProvider.class.getDeclaredField("connection"); - connField.setAccessible(true); - connField.set(provider, conn); + // Inject H2-backed datasource (provider uses HikariCP, not a raw Connection field). + Field dsField = MySQLStorageProvider.class.getDeclaredField("dataSource"); + dsField.setAccessible(true); + dsField.set(provider, testDataSource); + provider.init(); UUID u = UUID.randomUUID(); // Initially zero diff --git a/ezeconomy-bukkit/src/test/java/com/skyblockexp/ezeconomy/service/format/CurrencyFormatterFormatTest.java b/ezeconomy-bukkit/src/test/java/com/skyblockexp/ezeconomy/service/format/CurrencyFormatterFormatTest.java index 1ba1c54..8367a52 100644 --- a/ezeconomy-bukkit/src/test/java/com/skyblockexp/ezeconomy/service/format/CurrencyFormatterFormatTest.java +++ b/ezeconomy-bukkit/src/test/java/com/skyblockexp/ezeconomy/service/format/CurrencyFormatterFormatTest.java @@ -56,7 +56,7 @@ void formatPriceForMessage_usesMessageProviderTemplate() throws Exception { String out = plugin.getCurrencyFormatter().formatPriceForMessage(1500.0, "test"); // formatShort for 1500 -> 1.5k - assertEquals("$1.5k", out); + assertEquals("$1.5K", out); } finally { try { plugin.getServer().getPluginManager().disablePlugin(plugin); } catch (Exception ignored) {} } diff --git a/ezeconomy-bungeecord-proxy/pom.xml b/ezeconomy-bungeecord-proxy/pom.xml index 3369555..a281965 100644 --- a/ezeconomy-bungeecord-proxy/pom.xml +++ b/ezeconomy-bungeecord-proxy/pom.xml @@ -5,7 +5,7 @@ com.github.ez-plugins ezeconomy-parent - 2.5.1 + 2.6.0 ../pom.xml ezeconomy-bungeecord-proxy diff --git a/ezeconomy-bungeecord/pom.xml b/ezeconomy-bungeecord/pom.xml index 7bebac0..f20d640 100644 --- a/ezeconomy-bungeecord/pom.xml +++ b/ezeconomy-bungeecord/pom.xml @@ -5,7 +5,7 @@ com.github.ez-plugins ezeconomy-parent - 2.5.1 + 2.6.0 ../pom.xml ezeconomy-bungeecord @@ -17,7 +17,7 @@ com.github.ez-plugins ezeconomy-bukkit - 2.5.1 + 2.6.0 provided diff --git a/ezeconomy-papi/pom.xml b/ezeconomy-papi/pom.xml index 7e0d412..b007e44 100644 --- a/ezeconomy-papi/pom.xml +++ b/ezeconomy-papi/pom.xml @@ -5,7 +5,7 @@ com.github.ez-plugins ezeconomy-parent - 2.5.1 + 2.6.0 ../pom.xml ezeconomy-papi @@ -40,7 +40,7 @@ com.github.ez-plugins ezeconomy-bukkit - 2.5.1 + 2.6.0 provided diff --git a/ezeconomy-redis/pom.xml b/ezeconomy-redis/pom.xml index eb1601b..c9427be 100644 --- a/ezeconomy-redis/pom.xml +++ b/ezeconomy-redis/pom.xml @@ -5,7 +5,7 @@ com.github.ez-plugins ezeconomy-parent - 2.5.1 + 2.6.0 ../pom.xml ezeconomy-redis @@ -16,7 +16,7 @@ com.github.ez-plugins ezeconomy-bukkit - 2.5.1 + 2.6.0 provided diff --git a/ezeconomy-velocity/pom.xml b/ezeconomy-velocity/pom.xml new file mode 100644 index 0000000..38387ea --- /dev/null +++ b/ezeconomy-velocity/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + + com.github.ez-plugins + ezeconomy-parent + 2.6.0 + + ezeconomy-velocity + jar + + + papermc + https://repo.papermc.io/repository/maven-public/ + + + + + com.velocitypowered + velocity-api + 3.4.0-SNAPSHOT + provided + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + + + com.velocitypowered + velocity-api + 3.4.0-SNAPSHOT + + + + + + + diff --git a/ezeconomy-velocity/src/main/java/com/skyblockexp/ezeconomy/velocity/EzEconomyVelocity.java b/ezeconomy-velocity/src/main/java/com/skyblockexp/ezeconomy/velocity/EzEconomyVelocity.java new file mode 100644 index 0000000..90a1814 --- /dev/null +++ b/ezeconomy-velocity/src/main/java/com/skyblockexp/ezeconomy/velocity/EzEconomyVelocity.java @@ -0,0 +1,126 @@ +package com.skyblockexp.ezeconomy.velocity; + +import com.google.inject.Inject; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.connection.PluginMessageEvent; +import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; +import com.velocitypowered.api.plugin.Plugin; +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.ServerConnection; +import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier; +import com.velocitypowered.api.proxy.server.RegisteredServer; +import org.slf4j.Logger; + +import java.io.*; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Plugin(id = "ezeconomy", name = "EzEconomy", version = "2.6.0", + description = "Cross-server payment forwarding & global player list for EzEconomy", + authors = {"Shadow48402"}) +public class EzEconomyVelocity { + private static final MinecraftChannelIdentifier CHANNEL = + MinecraftChannelIdentifier.create("ezeconomy", "notify"); + + private final ProxyServer server; + private final Logger logger; + + @Inject + public EzEconomyVelocity(ProxyServer server, Logger logger) { + this.server = server; + this.logger = logger; + } + + @Subscribe + public void onProxyInit(ProxyInitializeEvent event) { + server.getChannelRegistrar().register(CHANNEL); + logger.info("EzEconomy Velocity plugin enabled - registered channel ezeconomy:notify"); + + server.getScheduler().buildTask(this, this::broadcastPlayerList) + .repeat(3, TimeUnit.SECONDS) + .schedule(); + } + + @Subscribe + public void onPluginMessage(PluginMessageEvent event) { + if (!CHANNEL.equals(event.getIdentifier())) return; + if (!(event.getSource() instanceof ServerConnection)) return; + event.setResult(PluginMessageEvent.ForwardResult.handled()); + + ServerConnection source = (ServerConnection) event.getSource(); + byte[] data = event.getData(); + + try { + DataInputStream in = new DataInputStream(new ByteArrayInputStream(data)); + String type = in.readUTF(); + + if ("NOTIFY".equals(type)) { + String recipientUuidStr = in.readUTF(); + String recipientName = in.readUTF(); + String senderName = in.readUTF(); + String amount = in.readUTF(); + String currency = in.readUTF(); + + java.util.UUID recipientUuid = java.util.UUID.fromString(recipientUuidStr); + Optional recipient = server.getPlayer(recipientUuid); + + if (recipient.isPresent()) { + Optional conn = recipient.get().getCurrentServer(); + if (conn.isPresent()) { + conn.get().sendPluginMessage(CHANNEL, data); + logger.info("Forwarded payment notification from {} to {} (on {})", + senderName, recipientName, + conn.get().getServerInfo().getName()); + } + } else { + sendOfflineResponse(source, recipientUuidStr, senderName, amount, currency); + logger.info("Recipient {} not online, sent RECIPIENT_OFFLINE to {}", + recipientName, source.getServerInfo().getName()); + } + } + } catch (IOException e) { + logger.warn("Failed to process plugin message: {}", e.getMessage()); + } + } + + private void sendOfflineResponse(ServerConnection source, String recipientUuid, + String senderName, String amount, String currency) { + try { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bos); + out.writeUTF("RECIPIENT_OFFLINE"); + out.writeUTF(recipientUuid); + out.writeUTF(senderName); + out.writeUTF(amount); + out.writeUTF(currency); + source.sendPluginMessage(CHANNEL, bos.toByteArray()); + } catch (IOException e) { + logger.warn("Failed to send offline response: {}", e.getMessage()); + } + } + + private void broadcastPlayerList() { + try { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bos); + out.writeUTF("PLAYER_LIST"); + + var allPlayers = server.getAllPlayers(); + out.writeInt(allPlayers.size()); + for (Player p : allPlayers) { + out.writeUTF(p.getUniqueId().toString()); + out.writeUTF(p.getUsername()); + } + byte[] data = bos.toByteArray(); + + for (RegisteredServer rs : server.getAllServers()) { + if (!rs.getPlayersConnected().isEmpty()) { + rs.sendPluginMessage(CHANNEL, data); + } + } + } catch (IOException e) { + logger.warn("Failed to broadcast player list: {}", e.getMessage()); + } + } +} diff --git a/pom.xml b/pom.xml index 8b37c90..c332316 100644 --- a/pom.xml +++ b/pom.xml @@ -1,415 +1,425 @@ - - 4.0.0 - com.github.ez-plugins - ezeconomy-parent - 2.5.1 - pom - EzEconomy - Vault-compatible economy provider with YML and MySQL support - - ezeconomy-bukkit - ezeconomy-redis - ezeconomy-papi - ezeconomy-bungeecord - ezeconomy-bungeecord-proxy - - - UTF-8 - 21 - 21 - 21 - - - 1.21.11-R0.1-SNAPSHOT - - org.mockbukkit.mockbukkit - mockbukkit-v1.21 - 4.108.0 - - ez-plugins - ezeconomy - - - - - src/main/resources - true - - plugin.yml - - - - src/main/resources - false - - plugin.yml - - - - - - org.apache.maven.plugins - maven-shade-plugin - 3.6.2 - - - package - - shade - - - false - - - org.bstats:bstats-bukkit - org.bstats:bstats-base - net.kyori:adventure-api - net.kyori:adventure-key - net.kyori:adventure-text-minimessage - net.kyori:adventure-text-serializer-legacy - net.kyori:examination-api - - - - - org.bstats - com.skyblockexp.ezeconomy.shaded.bstats - - - net.kyori - com.skyblockexp.ezeconomy.shaded.net.kyori - - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.15.0 - - ${maven.compiler.source} - ${maven.compiler.target} - ${maven.compiler.release} - - - - org.apache.maven.plugins - maven-surefire-plugin - 3.5.5 - - false - - - - org.jacoco - jacoco-maven-plugin - 0.8.14 - - - prepare-agent - - prepare-agent - - - - report - verify - - report - - - ${project.build.directory}/site/jacoco - - - - - - - - - org.bstats - bstats-bukkit - 3.2.1 - compile - - - net.kyori - adventure-api - 4.26.1 - - - net.kyori - adventure-key - 4.26.1 - - - net.kyori - adventure-text-minimessage - 4.26.1 - - - net.kyori - adventure-text-serializer-legacy - 4.26.1 - - - net.kyori - examination-api - 1.3.0 - - - io.papermc.paper - paper-api - ${paper.version} - provided - - - com.github.MilkBowl - VaultAPI - 1.7 - provided - - - me.clip - placeholderapi - 2.12.2 - provided - - - org.yaml - snakeyaml - 2.6 - - - mysql - mysql-connector-java - 8.0.33 - - - com.h2database - h2 - 2.4.240 - test - - - org.slf4j - slf4j-simple - 2.0.17 - test - - - org.mongodb - mongodb-driver-sync - 5.6.5 - - - ${mockbukkit.groupId} - ${mockbukkit.artifactId} - ${mockbukkit.version} - test - - - org.junit.jupiter - junit-jupiter-api - 6.0.3 - test - - - org.junit.jupiter - junit-jupiter-engine - 6.0.3 - test - - - org.testcontainers - testcontainers - 2.0.4 - test - - - org.testcontainers - junit-jupiter - 1.21.4 - test - - - - - papermc-repo - https://repo.papermc.io/repository/maven-public/ - - true - - - true - - - - central - Maven Central - https://repo.maven.apache.org/maven2 - - - spigot-repo - https://hub.spigotmc.org/nexus/content/repositories/snapshots/ - - - maxhenkel-public - https://maven.maxhenkel.de/repository/public - - - enginehub-repo - https://maven.enginehub.org/repo/ - - true - - - false - - - - jitpack.io - https://jitpack.io - - - placeholderapi-repo - https://repo.extendedclip.com/content/repositories/placeholderapi/ - - true - - - false - - - - maven-central - https://repo1.maven.org/maven2/ - - true - - - false - - - - sonatype-oss-snapshots - Sonatype OSS Snapshots - https://oss.sonatype.org/content/repositories/snapshots/ - - false - - - true - - - - md5-public - md-5 Public Repo - https://repo.md-5.net/content/repositories/public/ - - true - - - true - - - - - - - github - ${github.owner} - https://maven.pkg.github.com/${github.owner}/${github.repository} - - - github - ${github.owner} - https://maven.pkg.github.com/${github.owner}/${github.repository} - - - - - - jdk17 - - [17,20] - - - 17 - 17 - 17 - - - - - - jdk21 - - [21,) - - - 21 - 21 - 21 - - - - integration - - - central - https://repo.maven.apache.org/maven2 - - - - - - - - - - org.codehaus.mojo - build-helper-maven-plugin - 3.6.1 - - - add-integration-test-source - generate-test-sources - - add-test-source - - - - src/integration/java - - - - - - - - - - feature-tests - - - - org.apache.maven.plugins - maven-surefire-plugin - 3.5.5 - - false - - **/*FeatureTest.java - - - - - - - - + + 4.0.0 + com.github.ez-plugins + ezeconomy-parent + 2.5.1 + pom + EzEconomy + Vault-compatible economy provider with YML and MySQL support + + ezeconomy-bukkit + ezeconomy-redis + ezeconomy-papi + ezeconomy-bungeecord + ezeconomy-bungeecord-proxy + ezeconomy-velocity + + + UTF-8 + 21 + 21 + 21 + + + 1.21.11-R0.1-SNAPSHOT + + org.mockbukkit.mockbukkit + mockbukkit-v1.21 + 4.108.0 + + ez-plugins + ezeconomy + + + + + src/main/resources + true + + plugin.yml + + + + src/main/resources + false + + plugin.yml + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.2 + + + package + + shade + + + false + + + org.bstats:bstats-bukkit + org.bstats:bstats-base + net.kyori:adventure-api + net.kyori:adventure-key + net.kyori:adventure-text-minimessage + net.kyori:adventure-text-serializer-legacy + net.kyori:examination-api + + com.zaxxer:HikariCP + org.slf4j:slf4j-api + + + + + org.bstats + com.skyblockexp.ezeconomy.shaded.bstats + + + net.kyori + com.skyblockexp.ezeconomy.shaded.net.kyori + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.15.0 + + ${maven.compiler.source} + ${maven.compiler.target} + ${maven.compiler.release} + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.5 + + false + + + + org.jacoco + jacoco-maven-plugin + 0.8.14 + + + prepare-agent + + prepare-agent + + + + report + verify + + report + + + ${project.build.directory}/site/jacoco + + + + + + + + + org.bstats + bstats-bukkit + 3.2.1 + compile + + + net.kyori + adventure-api + 4.26.1 + + + net.kyori + adventure-key + 4.26.1 + + + net.kyori + adventure-text-minimessage + 4.26.1 + + + net.kyori + adventure-text-serializer-legacy + 4.26.1 + + + net.kyori + examination-api + 1.3.0 + + + io.papermc.paper + paper-api + ${paper.version} + provided + + + com.github.MilkBowl + VaultAPI + 1.7 + provided + + + me.clip + placeholderapi + 2.12.2 + provided + + + org.yaml + snakeyaml + 2.6 + + + mysql + mysql-connector-java + 8.0.33 + + + + com.zaxxer + HikariCP + 5.1.0 + + + com.h2database + h2 + 2.4.240 + test + + + org.slf4j + slf4j-simple + 2.0.17 + test + + + org.mongodb + mongodb-driver-sync + 5.6.5 + + + ${mockbukkit.groupId} + ${mockbukkit.artifactId} + ${mockbukkit.version} + test + + + org.junit.jupiter + junit-jupiter-api + 6.0.3 + test + + + org.junit.jupiter + junit-jupiter-engine + 6.0.3 + test + + + org.testcontainers + testcontainers + 2.0.4 + test + + + org.testcontainers + junit-jupiter + 1.21.4 + test + + + + + papermc-repo + https://repo.papermc.io/repository/maven-public/ + + true + + + true + + + + central + Maven Central + https://repo.maven.apache.org/maven2 + + + spigot-repo + https://hub.spigotmc.org/nexus/content/repositories/snapshots/ + + + maxhenkel-public + https://maven.maxhenkel.de/repository/public + + + enginehub-repo + https://maven.enginehub.org/repo/ + + true + + + false + + + + jitpack.io + https://jitpack.io + + + placeholderapi-repo + https://repo.extendedclip.com/content/repositories/placeholderapi/ + + true + + + false + + + + maven-central + https://repo1.maven.org/maven2/ + + true + + + false + + + + sonatype-oss-snapshots + Sonatype OSS Snapshots + https://oss.sonatype.org/content/repositories/snapshots/ + + false + + + true + + + + md5-public + md-5 Public Repo + https://repo.md-5.net/content/repositories/public/ + + true + + + true + + + + + + + github + ${github.owner} + https://maven.pkg.github.com/${github.owner}/${github.repository} + + + github + ${github.owner} + https://maven.pkg.github.com/${github.owner}/${github.repository} + + + + + + jdk17 + + [17,20] + + + 17 + 17 + 17 + + + + + + jdk21 + + [21,) + + + 21 + 21 + 21 + + + + integration + + + central + https://repo.maven.apache.org/maven2 + + + + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.1 + + + add-integration-test-source + generate-test-sources + + add-test-source + + + + src/integration/java + + + + + + + + + + feature-tests + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.5 + + false + + **/*FeatureTest.java + + + + + + + + diff --git a/src/main/java/com/skyblockexp/ezeconomy/api/storage/StorageProvider.java b/src/main/java/com/skyblockexp/ezeconomy/api/storage/StorageProvider.java index 7703cfd..f874884 100644 --- a/src/main/java/com/skyblockexp/ezeconomy/api/storage/StorageProvider.java +++ b/src/main/java/com/skyblockexp/ezeconomy/api/storage/StorageProvider.java @@ -173,7 +173,7 @@ default TransferResult transfer(UUID fromUuid, UUID toUuid, String currency, dou if (fromUuid.compareTo(toUuid) > 0) ordered = new UUID[]{toUuid, fromUuid}; String[] tokens = null; try { - tokens = lm.acquireOrdered(ordered, inst.getConfig().getLong("redis.ttl-ms", 5000), inst.getConfig().getLong("redis.retry-ms", 50), inst.getConfig().getInt("redis.max-attempts", 100)); + tokens = lm.acquireOrdered(ordered, inst.getLockTtlMs(), inst.getLockRetryMs(), inst.getLockMaxAttempts()); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); } @@ -426,4 +426,11 @@ default boolean tryWithdrawBank(String name, double amount) { default void depositBank(String name, double amount) { depositBank(name, "dollar", amount); } + + default UUID resolvePlayerByName(String name) { + return null; + } + + default void persistPlayerInfo(UUID uuid, String name, String displayName) { + } } diff --git a/src/main/java/com/skyblockexp/ezeconomy/bootstrap/Bootstrap.java b/src/main/java/com/skyblockexp/ezeconomy/bootstrap/Bootstrap.java index c8a1632..df52014 100644 --- a/src/main/java/com/skyblockexp/ezeconomy/bootstrap/Bootstrap.java +++ b/src/main/java/com/skyblockexp/ezeconomy/bootstrap/Bootstrap.java @@ -27,6 +27,7 @@ public Bootstrap(EzEconomyPlugin plugin) { components.add(new com.skyblockexp.ezeconomy.bootstrap.component.CacheComponent(plugin)); components.add(new com.skyblockexp.ezeconomy.bootstrap.component.LockingComponent(plugin)); components.add(new StorageComponent(plugin)); + components.add(new com.skyblockexp.ezeconomy.bootstrap.component.MessagingComponent(plugin)); components.add(new ManagersComponent(plugin)); // Metrics component should be initialized after managers components.add(new MetricsComponent(plugin)); diff --git a/src/main/java/com/skyblockexp/ezeconomy/bootstrap/component/LockingComponent.java b/src/main/java/com/skyblockexp/ezeconomy/bootstrap/component/LockingComponent.java index 7fa9c64..ddc3389 100644 --- a/src/main/java/com/skyblockexp/ezeconomy/bootstrap/component/LockingComponent.java +++ b/src/main/java/com/skyblockexp/ezeconomy/bootstrap/component/LockingComponent.java @@ -26,6 +26,8 @@ public LockingComponent(EzEconomyPlugin plugin) { @Override public void start() { FileConfiguration cfg = plugin.getConfig(); + plugin.refreshEffectiveLockSettings(); + warnLegacyLockTimingKeys(cfg); // Configure cache strategy early from config (backwards-compatible with 'caching-strategy') String caching = cfg.getString("caching-strategy", cfg.getString("locking-strategy", "LOCAL")).toUpperCase(); try { @@ -197,6 +199,18 @@ public void start() { plugin.setLockManager(this.manager); } + private void warnLegacyLockTimingKeys(FileConfiguration cfg) { + if (!cfg.contains("locking.ttl-ms") && cfg.contains("redis.ttl-ms")) { + plugin.getLogger().warning("Config key redis.ttl-ms is deprecated for lock timing. Please migrate to locking.ttl-ms."); + } + if (!cfg.contains("locking.retry-ms") && cfg.contains("redis.retry-ms")) { + plugin.getLogger().warning("Config key redis.retry-ms is deprecated for lock timing. Please migrate to locking.retry-ms."); + } + if (!cfg.contains("locking.max-attempts") && cfg.contains("redis.max-attempts")) { + plugin.getLogger().warning("Config key redis.max-attempts is deprecated for lock timing. Please migrate to locking.max-attempts."); + } + } + @Override public void stop() { plugin.setLockManager(null); diff --git a/src/main/java/com/skyblockexp/ezeconomy/bootstrap/component/MessagingComponent.java b/src/main/java/com/skyblockexp/ezeconomy/bootstrap/component/MessagingComponent.java new file mode 100644 index 0000000..ca8890b --- /dev/null +++ b/src/main/java/com/skyblockexp/ezeconomy/bootstrap/component/MessagingComponent.java @@ -0,0 +1,73 @@ +package com.skyblockexp.ezeconomy.bootstrap.component; + +import com.skyblockexp.ezeconomy.bootstrap.BootstrapComponent; +import com.skyblockexp.ezeconomy.core.EzEconomyPlugin; +import com.skyblockexp.ezeconomy.messaging.CrossServerMessenger; +import com.skyblockexp.ezeconomy.storage.MySQLStorageProvider; +import com.skyblockexp.ezeconomy.api.storage.StorageProvider; +import org.bukkit.Bukkit; +import org.bukkit.event.HandlerList; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; + +public class MessagingComponent implements BootstrapComponent, Listener { + private final EzEconomyPlugin plugin; + private CrossServerMessenger messenger; + private int cleanupTaskId = -1; + + public MessagingComponent(EzEconomyPlugin plugin) { + this.plugin = plugin; + } + + @Override + public void start() { + if (!plugin.getConfig().getBoolean("cross-server.enabled", false)) { + plugin.setCrossServerMessenger(null); + plugin.getLogger().info("Cross-server messaging is disabled in config."); + return; + } + + messenger = new CrossServerMessenger(plugin); + messenger.register(); + plugin.setCrossServerMessenger(messenger); + Bukkit.getPluginManager().registerEvents(this, plugin); + + StorageProvider s = plugin.getStorage(); + if (s instanceof MySQLStorageProvider) { + cleanupTaskId = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, () -> + ((MySQLStorageProvider) s).cleanupOldNotifications(86400000L), + 6000L, 6000L + ).getTaskId(); + } + + plugin.getLogger().info("Cross-server messaging component started."); + } + + @Override + public void stop() { + if (cleanupTaskId != -1) { + Bukkit.getScheduler().cancelTask(cleanupTaskId); + cleanupTaskId = -1; + } + if (messenger != null) { + messenger.unregister(); + messenger = null; + } + plugin.setCrossServerMessenger(null); + HandlerList.unregisterAll(this); + } + + @Override + public void reload() { + stop(); + start(); + } + + @EventHandler + public void onPlayerJoin(PlayerJoinEvent event) { + if (messenger != null) { + messenger.deliverPendingNotifications(event.getPlayer()); + } + } +} diff --git a/src/main/java/com/skyblockexp/ezeconomy/command/BalanceCommand.java b/src/main/java/com/skyblockexp/ezeconomy/command/BalanceCommand.java index 50763ad..aab8b10 100644 --- a/src/main/java/com/skyblockexp/ezeconomy/command/BalanceCommand.java +++ b/src/main/java/com/skyblockexp/ezeconomy/command/BalanceCommand.java @@ -1,6 +1,5 @@ package com.skyblockexp.ezeconomy.command; -import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; @@ -11,6 +10,7 @@ import com.skyblockexp.ezeconomy.util.MessageUtils; import com.skyblockexp.ezeconomy.manager.CurrencyPreferenceManager; import com.skyblockexp.ezeconomy.api.storage.StorageProvider; +import java.util.UUID; public class BalanceCommand implements CommandExecutor { private final EzEconomyPlugin plugin; @@ -55,11 +55,9 @@ public boolean onCommand(CommandSender sender, Command command, String label, St return true; } - // Otherwise treat as a player name. Check online players first to avoid Mojang lookups. - var maybe = com.skyblockexp.ezeconomy.util.PlayerLookup.findByName(args[0]); - OfflinePlayer target = maybe.orElse(null); - - if (target == null || (!target.hasPlayedBefore() && !target.isOnline())) { + // Otherwise treat as a player name. + OfflinePlayer target = resolveOfflinePlayer(args[0], storage); + if (target == null) { MessageUtils.send(sender, plugin, "player_not_found"); return true; } @@ -71,7 +69,8 @@ public boolean onCommand(CommandSender sender, Command command, String label, St } String currency = preferenceManager.getPreferredCurrency(target.getUniqueId()); double balance = storage != null ? storage.getBalance(target.getUniqueId(), currency) : plugin.getEconomy().getBalance(target); - MessageUtils.send(sender, plugin, "others_balance", java.util.Map.of("player", target.getName(), "balance", plugin.getCurrencyFormatter().formatPriceForMessage(balance, currency), "currency", currency)); + String displayName = target.getName() != null ? target.getName() : args[0]; + MessageUtils.send(sender, plugin, "others_balance", java.util.Map.of("player", displayName, "balance", plugin.getCurrencyFormatter().formatPriceForMessage(balance, currency), "currency", currency)); return true; } else if (args.length == 2) { // /balance @@ -79,9 +78,7 @@ public boolean onCommand(CommandSender sender, Command command, String label, St MessageUtils.send(sender, plugin, "no_permission_others_balance"); return true; } - // Use PlayerLookup to avoid expensive or blocking lookups. - var maybe2 = com.skyblockexp.ezeconomy.util.PlayerLookup.findByName(args[0]); - OfflinePlayer target = maybe2.orElse(null); + OfflinePlayer target = resolveOfflinePlayer(args[0], storage); if (target == null) { MessageUtils.send(sender, plugin, "player_not_found"); return true; @@ -92,10 +89,44 @@ public boolean onCommand(CommandSender sender, Command command, String label, St return true; } double balance = storage != null ? storage.getBalance(target.getUniqueId(), currency) : plugin.getEconomy().getBalance(target); - MessageUtils.send(sender, plugin, "others_balance", java.util.Map.of("player", target.getName(), "balance", plugin.getCurrencyFormatter().formatPriceForMessage(balance, currency), "currency", currency)); + String displayName2 = target.getName() != null ? target.getName() : args[0]; + MessageUtils.send(sender, plugin, "others_balance", java.util.Map.of("player", displayName2, "balance", plugin.getCurrencyFormatter().formatPriceForMessage(balance, currency), "currency", currency)); return true; } MessageUtils.send(sender, plugin, "usage_balance"); return true; } + + private OfflinePlayer resolveOfflinePlayer(String name, StorageProvider storage) { + Player online = plugin.getServer().getPlayerExact(name); + if (online != null) return online; + + UUID resolvedUuid = null; + if (storage != null) { + resolvedUuid = storage.resolvePlayerByName(name); + } + if (resolvedUuid == null) { + var messenger = plugin.getCrossServerMessenger(); + if (messenger != null) { + resolvedUuid = messenger.getNetworkPlayerUuid(name); + } + } + if (resolvedUuid != null) { + return plugin.getServer().getOfflinePlayer(resolvedUuid); + } + + var maybe = com.skyblockexp.ezeconomy.util.PlayerLookup.findByName(name); + if (maybe.isPresent()) return maybe.get(); + + @SuppressWarnings("deprecation") + OfflinePlayer stub = plugin.getServer().getOfflinePlayer(name); + if (stub != null && stub.hasPlayedBefore()) { + return stub; + } + if (stub != null && storage != null) { + double bal = storage.getBalance(stub.getUniqueId(), plugin.getDefaultCurrency()); + if (bal > 0) return stub; + } + return null; + } } diff --git a/src/main/java/com/skyblockexp/ezeconomy/command/BaltopCommand.java b/src/main/java/com/skyblockexp/ezeconomy/command/BaltopCommand.java index 6a64304..ce0101d 100644 --- a/src/main/java/com/skyblockexp/ezeconomy/command/BaltopCommand.java +++ b/src/main/java/com/skyblockexp/ezeconomy/command/BaltopCommand.java @@ -100,6 +100,7 @@ public boolean onCommand(CommandSender sender, Command command, String label, St )); rank++; } + MessageUtils.send(sender, plugin, "baltop_footer"); return true; } if (top > 0 && sorted.size() > top) { @@ -121,6 +122,7 @@ public boolean onCommand(CommandSender sender, Command command, String label, St )); rank++; } + MessageUtils.send(sender, plugin, "baltop_footer"); return true; } } diff --git a/src/main/java/com/skyblockexp/ezeconomy/command/PayCommand.java b/src/main/java/com/skyblockexp/ezeconomy/command/PayCommand.java index f69b643..f3176e6 100644 --- a/src/main/java/com/skyblockexp/ezeconomy/command/PayCommand.java +++ b/src/main/java/com/skyblockexp/ezeconomy/command/PayCommand.java @@ -149,6 +149,11 @@ public boolean onCommand(CommandSender sender, Command command, String label, St boolean includeOffline = plugin.getConfig().getBoolean("pay.pay_all.include_offline", false); UUID fromUuid = ((Player) sender).getUniqueId(); List recipients = new ArrayList<>(); + java.util.Set localOnlineUuids = new java.util.HashSet<>(); + plugin.getLogger().info("[PayAll] sender=" + sender.getName() + " uuid=" + fromUuid + + " onlinePlayers=" + Bukkit.getOnlinePlayers().size() + + " includeOffline=" + includeOffline + + " crossServer=" + (plugin.getCrossServerMessenger() != null)); if (includeOffline) { Map all = storage.getAllBalances(currency); if (all == null || all.isEmpty()) { @@ -159,16 +164,42 @@ public boolean onCommand(CommandSender sender, Command command, String label, St if (!u.equals(fromUuid)) recipients.add(u); } } else { - // default: only online players for (Player p : Bukkit.getOnlinePlayers()) { - if (!p.getUniqueId().equals(fromUuid)) recipients.add(p.getUniqueId()); + if (!p.getUniqueId().equals(fromUuid)) { + recipients.add(p.getUniqueId()); + localOnlineUuids.add(p.getUniqueId()); + } + } + // Include players from other servers via Velocity network list + var messenger = plugin.getCrossServerMessenger(); + if (messenger != null) { + String senderName = ((Player) sender).getName(); + for (String netPlayer : messenger.getNetworkPlayers()) { + if (netPlayer.equalsIgnoreCase(senderName)) continue; + if (Bukkit.getPlayerExact(netPlayer) != null) continue; + UUID netUuid = messenger.getNetworkPlayerUuid(netPlayer); + if (netUuid == null) { + netUuid = storage.resolvePlayerByName(netPlayer); + } + if (netUuid == null) { + var lookup = com.skyblockexp.ezeconomy.util.PlayerLookup.findByName(netPlayer); + if (lookup.isPresent()) { + netUuid = lookup.get().getUniqueId(); + } + } + if (netUuid != null && !netUuid.equals(fromUuid) && !localOnlineUuids.contains(netUuid)) { + recipients.add(netUuid); + } + } } + plugin.getLogger().info("[PayAll] after enumeration: recipients=" + recipients.size() + " localOnline=" + localOnlineUuids.size()); if (recipients.isEmpty()) { MessageUtils.send(sender, plugin, "player_not_found"); return true; } } if (recipients.isEmpty()) { + plugin.getLogger().info("[PayAll] final recipients empty"); MessageUtils.send(sender, plugin, "player_not_found"); return true; } @@ -203,11 +234,17 @@ public boolean onCommand(CommandSender sender, Command command, String label, St } storage.deposit(recip, recipPref, credit); } - // Notify online recipients + // Notify recipients: local or cross-server + String amountStr = plugin.getCurrencyFormatter().formatPriceForMessage(amountDecimal.doubleValue(), currency); OfflinePlayer op = Bukkit.getOfflinePlayer(recip); if (op != null && op.isOnline() && op.getPlayer() != null) { - String amountStr = plugin.getCurrencyFormatter().formatPriceForMessage(amountDecimal.doubleValue(), currency); MessageUtils.send(op.getPlayer(), plugin, "received", java.util.Map.of("player", ((Player) sender).getName(), "amount", amountStr)); + } else { + var messenger = plugin.getCrossServerMessenger(); + if (messenger != null) { + String recipName = (op != null && op.getName() != null) ? op.getName() : recip.toString(); + messenger.sendPaymentNotification(recip, recipName, ((Player) sender).getName(), amountStr, currency); + } } } @@ -231,23 +268,34 @@ public boolean onCommand(CommandSender sender, Command command, String label, St if (online != null) { knownOffline = true; } else { - // Use PlayerLookup to avoid expensive or blocking lookups. - var maybe = com.skyblockexp.ezeconomy.util.PlayerLookup.findByName(operands[0]); - if (maybe.isPresent()) { - OfflinePlayer sample = maybe.get(); - if (sample.hasPlayedBefore()) { + var storage = plugin.getStorageOrWarn(); + if (storage != null && storage.resolvePlayerByName(operands[0]) != null) { + knownOffline = true; + } + if (!knownOffline) { + var messenger = plugin.getCrossServerMessenger(); + if (messenger != null && messenger.getNetworkPlayerUuid(operands[0]) != null) { knownOffline = true; - } else { - try { - var storage = plugin.getStorageOrWarn(); - if (storage != null) { - java.util.Map all = storage.getAllBalances(currency); - if (all.containsKey(sample.getUniqueId())) { - knownOffline = true; + } + } + // Use PlayerLookup to avoid expensive or blocking lookups. + if (!knownOffline) { + var maybe = com.skyblockexp.ezeconomy.util.PlayerLookup.findByName(operands[0]); + if (maybe.isPresent()) { + OfflinePlayer sample = maybe.get(); + if (sample.hasPlayedBefore()) { + knownOffline = true; + } else { + try { + if (storage != null) { + java.util.Map all = storage.getAllBalances(currency); + if (all.containsKey(sample.getUniqueId())) { + knownOffline = true; + } } + } catch (Exception ignored) { + // swallow and treat as unknown } - } catch (Exception ignored) { - // swallow and treat as unknown } } } diff --git a/src/main/java/com/skyblockexp/ezeconomy/command/eco/GiveSubcommand.java b/src/main/java/com/skyblockexp/ezeconomy/command/eco/GiveSubcommand.java index e407206..b954f11 100644 --- a/src/main/java/com/skyblockexp/ezeconomy/command/eco/GiveSubcommand.java +++ b/src/main/java/com/skyblockexp/ezeconomy/command/eco/GiveSubcommand.java @@ -10,6 +10,7 @@ import org.bukkit.command.CommandSender; import java.util.Map; +import java.util.UUID; /** * Subcommand for /eco give @@ -21,13 +22,26 @@ public GiveSubcommand(EzEconomyPlugin plugin) { this.plugin = plugin; } + private OfflinePlayer resolveTarget(String name) { + org.bukkit.entity.Player online = Bukkit.getPlayerExact(name); + if (online != null) return online; + com.skyblockexp.ezeconomy.api.storage.StorageProvider storage = plugin.getStorageOrWarn(); + if (storage != null) { + UUID dbUuid = storage.resolvePlayerByName(name); + if (dbUuid != null) return Bukkit.getOfflinePlayer(dbUuid); + } + var maybe = com.skyblockexp.ezeconomy.util.PlayerLookup.findByName(name); + if (maybe.isPresent()) return maybe.get(); + return Bukkit.getOfflinePlayer(name); + } + @Override public boolean execute(CommandSender sender, String[] args) { if (args.length < 2 || args.length > 3) { MessageUtils.send(sender, plugin, "usage_eco"); return true; } - OfflinePlayer target = Bukkit.getOfflinePlayer(args[0]); + OfflinePlayer target = resolveTarget(args[0]); Money money = NumberUtil.parseMoney(args[1], null); if (money == null || money.getAmount().compareTo(java.math.BigDecimal.ZERO) <= 0) { MessageUtils.send(sender, plugin, "invalid_amount", java.util.Map.of("input", args[1])); @@ -37,7 +51,7 @@ public boolean execute(CommandSender sender, String[] args) { if (args.length == 2) { plugin.getEconomy().depositPlayer(target, amount); - MessageUtils.send(sender, plugin, "paid", Map.of("player", target.getName(), "amount", plugin.getCurrencyFormatter().formatPriceForMessage(amount, plugin.getDefaultCurrency()))); + MessageUtils.send(sender, plugin, "eco_give", Map.of("player", target.getName(), "amount", plugin.getCurrencyFormatter().formatPriceForMessage(amount, plugin.getDefaultCurrency()))); return true; } @@ -59,7 +73,7 @@ public boolean execute(CommandSender sender, String[] args) { storage.deposit(target.getUniqueId(), currency, amount); String amountWithSymbol = plugin.getCurrencyFormatter().formatPriceForMessage(amount, currency); - MessageUtils.send(sender, plugin, "paid", Map.of("player", target.getName(), "amount", amountWithSymbol)); + MessageUtils.send(sender, plugin, "eco_give", Map.of("player", target.getName(), "amount", amountWithSymbol)); return true; } } \ No newline at end of file diff --git a/src/main/java/com/skyblockexp/ezeconomy/command/eco/SetSubcommand.java b/src/main/java/com/skyblockexp/ezeconomy/command/eco/SetSubcommand.java index 95803a5..de50425 100644 --- a/src/main/java/com/skyblockexp/ezeconomy/command/eco/SetSubcommand.java +++ b/src/main/java/com/skyblockexp/ezeconomy/command/eco/SetSubcommand.java @@ -11,6 +11,7 @@ import org.bukkit.command.CommandSender; import java.util.Map; +import java.util.UUID; /** * Subcommand for /eco set @@ -22,13 +23,26 @@ public SetSubcommand(EzEconomyPlugin plugin) { this.plugin = plugin; } + private OfflinePlayer resolveTarget(String name) { + org.bukkit.entity.Player online = Bukkit.getPlayerExact(name); + if (online != null) return online; + com.skyblockexp.ezeconomy.api.storage.StorageProvider storage = plugin.getStorageOrWarn(); + if (storage != null) { + UUID dbUuid = storage.resolvePlayerByName(name); + if (dbUuid != null) return Bukkit.getOfflinePlayer(dbUuid); + } + var maybe = com.skyblockexp.ezeconomy.util.PlayerLookup.findByName(name); + if (maybe.isPresent()) return maybe.get(); + return Bukkit.getOfflinePlayer(name); + } + @Override public boolean execute(CommandSender sender, String[] args) { if (args.length < 2 || args.length > 3) { MessageUtils.send(sender, plugin, "usage_eco"); return true; } - OfflinePlayer target = Bukkit.getOfflinePlayer(args[0]); + OfflinePlayer target = resolveTarget(args[0]); Money money = NumberUtil.parseMoney(args[1], null); if (money == null || money.getAmount().compareTo(java.math.BigDecimal.ZERO) < 0) { MessageUtils.send(sender, plugin, "invalid_amount", java.util.Map.of("input", args[1])); diff --git a/src/main/java/com/skyblockexp/ezeconomy/command/eco/TakeSubcommand.java b/src/main/java/com/skyblockexp/ezeconomy/command/eco/TakeSubcommand.java index 8c585c8..91af79d 100644 --- a/src/main/java/com/skyblockexp/ezeconomy/command/eco/TakeSubcommand.java +++ b/src/main/java/com/skyblockexp/ezeconomy/command/eco/TakeSubcommand.java @@ -1,69 +1,83 @@ -package com.skyblockexp.ezeconomy.command.eco; - -import com.skyblockexp.ezeconomy.command.Subcommand; -import com.skyblockexp.ezeconomy.core.EzEconomyPlugin; -import com.skyblockexp.ezeconomy.util.MessageUtils; -import com.skyblockexp.ezeconomy.util.NumberUtil; -import com.skyblockexp.ezeconomy.core.Money; -import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; -import org.bukkit.command.CommandSender; - -import java.util.Map; - -/** - * Subcommand for /eco take - */ -public class TakeSubcommand implements Subcommand { - private final EzEconomyPlugin plugin; - - public TakeSubcommand(EzEconomyPlugin plugin) { - this.plugin = plugin; - } - - @Override - public boolean execute(CommandSender sender, String[] args) { - if (args.length < 2 || args.length > 3) { - MessageUtils.send(sender, plugin, "usage_eco"); - return true; - } - OfflinePlayer target = Bukkit.getOfflinePlayer(args[0]); - Money money = NumberUtil.parseMoney(args[1], null); - if (money == null || money.getAmount().compareTo(java.math.BigDecimal.ZERO) <= 0) { - MessageUtils.send(sender, plugin, "invalid_amount", java.util.Map.of("input", args[1])); - return true; - } - double amount = money.getAmount().doubleValue(); - - if (args.length == 2) { - plugin.getEconomy().withdrawPlayer(target, amount); - MessageUtils.send(sender, plugin, "withdrew", Map.of("name", target.getName(), "amount", plugin.getCurrencyFormatter().formatPriceForMessage(amount, plugin.getDefaultCurrency()))); - return true; - } - - // args.length == 3 -> currency specified - String currency = args[2].toLowerCase(); - java.util.Map currencies = plugin.getConfig().getConfigurationSection("multi-currency.currencies") != null - ? plugin.getConfig().getConfigurationSection("multi-currency.currencies").getValues(false) - : java.util.Collections.emptyMap(); - if (!currencies.containsKey(currency)) { - MessageUtils.send(sender, plugin, "unknown_currency", java.util.Map.of("currency", currency)); - return true; - } - - com.skyblockexp.ezeconomy.api.storage.StorageProvider storage = plugin.getStorageOrWarn(); - if (storage == null) { - MessageUtils.send(sender, plugin, "storage_unavailable"); - return true; - } - - boolean ok = storage.tryWithdraw(target.getUniqueId(), currency, amount); - if (!ok) { - MessageUtils.send(sender, plugin, "not_enough_money"); - return true; - } - String amountWithSymbol = plugin.getCurrencyFormatter().formatPriceForMessage(amount, currency); - MessageUtils.send(sender, plugin, "withdrew", Map.of("name", target.getName(), "amount", amountWithSymbol)); - return true; - } +package com.skyblockexp.ezeconomy.command.eco; + +import com.skyblockexp.ezeconomy.command.Subcommand; +import com.skyblockexp.ezeconomy.core.EzEconomyPlugin; +import com.skyblockexp.ezeconomy.util.MessageUtils; +import com.skyblockexp.ezeconomy.util.NumberUtil; +import com.skyblockexp.ezeconomy.core.Money; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.command.CommandSender; + +import java.util.Map; +import java.util.UUID; + +/** + * Subcommand for /eco take + */ +public class TakeSubcommand implements Subcommand { + private final EzEconomyPlugin plugin; + + public TakeSubcommand(EzEconomyPlugin plugin) { + this.plugin = plugin; + } + + private OfflinePlayer resolveTarget(String name) { + org.bukkit.entity.Player online = Bukkit.getPlayerExact(name); + if (online != null) return online; + com.skyblockexp.ezeconomy.api.storage.StorageProvider storage = plugin.getStorageOrWarn(); + if (storage != null) { + UUID dbUuid = storage.resolvePlayerByName(name); + if (dbUuid != null) return Bukkit.getOfflinePlayer(dbUuid); + } + var maybe = com.skyblockexp.ezeconomy.util.PlayerLookup.findByName(name); + if (maybe.isPresent()) return maybe.get(); + return Bukkit.getOfflinePlayer(name); + } + + @Override + public boolean execute(CommandSender sender, String[] args) { + if (args.length < 2 || args.length > 3) { + MessageUtils.send(sender, plugin, "usage_eco"); + return true; + } + OfflinePlayer target = resolveTarget(args[0]); + Money money = NumberUtil.parseMoney(args[1], null); + if (money == null || money.getAmount().compareTo(java.math.BigDecimal.ZERO) <= 0) { + MessageUtils.send(sender, plugin, "invalid_amount", java.util.Map.of("input", args[1])); + return true; + } + double amount = money.getAmount().doubleValue(); + + if (args.length == 2) { + plugin.getEconomy().withdrawPlayer(target, amount); + MessageUtils.send(sender, plugin, "eco_take", Map.of("player", target.getName(), "amount", plugin.getCurrencyFormatter().formatPriceForMessage(amount, plugin.getDefaultCurrency()))); + return true; + } + + // args.length == 3 -> currency specified + String currency = args[2].toLowerCase(); + java.util.Map currencies = plugin.getConfig().getConfigurationSection("multi-currency.currencies") != null + ? plugin.getConfig().getConfigurationSection("multi-currency.currencies").getValues(false) + : java.util.Collections.emptyMap(); + if (!currencies.containsKey(currency)) { + MessageUtils.send(sender, plugin, "unknown_currency", java.util.Map.of("currency", currency)); + return true; + } + + com.skyblockexp.ezeconomy.api.storage.StorageProvider storage = plugin.getStorageOrWarn(); + if (storage == null) { + MessageUtils.send(sender, plugin, "storage_unavailable"); + return true; + } + + net.milkbowl.vault.economy.EconomyResponse response = plugin.getEconomy().withdrawPlayer(target, amount, currency); + if (response.type != net.milkbowl.vault.economy.EconomyResponse.ResponseType.SUCCESS) { + MessageUtils.send(sender, plugin, "not_enough_money"); + return true; + } + String amountWithSymbol = plugin.getCurrencyFormatter().formatPriceForMessage(amount, currency); + MessageUtils.send(sender, plugin, "eco_take", Map.of("player", target.getName(), "amount", amountWithSymbol)); + return true; + } } \ No newline at end of file diff --git a/src/main/java/com/skyblockexp/ezeconomy/core/EzEconomyPlugin.java b/src/main/java/com/skyblockexp/ezeconomy/core/EzEconomyPlugin.java index eedbeda..ca14e02 100644 --- a/src/main/java/com/skyblockexp/ezeconomy/core/EzEconomyPlugin.java +++ b/src/main/java/com/skyblockexp/ezeconomy/core/EzEconomyPlugin.java @@ -13,6 +13,7 @@ import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.plugin.java.JavaPlugin; import net.milkbowl.vault.economy.Economy; +import java.util.Locale; public class EzEconomyPlugin extends JavaPlugin { private static final int SPIGOT_RESOURCE_ID = 130975; @@ -49,6 +50,15 @@ public class EzEconomyPlugin extends JavaPlugin { private com.skyblockexp.ezeconomy.service.metrics.TransactionMetricsService transactionMetricsService; private com.skyblockexp.ezeconomy.service.format.CurrencyFormatter currencyFormatter; private com.skyblockexp.ezeconomy.service.storage.StorageConfigLoader storageConfigLoader; + private com.skyblockexp.ezeconomy.messaging.CrossServerMessenger crossServerMessenger; + + public com.skyblockexp.ezeconomy.messaging.CrossServerMessenger getCrossServerMessenger() { + return crossServerMessenger; + } + + public void setCrossServerMessenger(com.skyblockexp.ezeconomy.messaging.CrossServerMessenger messenger) { + this.crossServerMessenger = messenger; + } @@ -229,6 +239,83 @@ public void setLockManager(com.skyblockexp.ezeconomy.lock.LockManager m) { this.lockManager = m; } + /** + * Resolve lock timing/attempt settings according to configured locking strategy. + * Strategy-specific files (redis.yml / bungeecord.yml) override generic config values. + */ + public long getLockTtlMs() { + return resolveLockSettings().ttlMs; + } + + public long getLockRetryMs() { + return resolveLockSettings().retryMs; + } + + public int getLockMaxAttempts() { + return resolveLockSettings().maxAttempts; + } + + /** + * Compute effective lock settings and mirror them into legacy redis.* config keys. + * This keeps existing call sites strategy-aware without breaking compatibility. + */ + public void refreshEffectiveLockSettings() { + LockSettings settings = resolveLockSettings(); + FileConfiguration cfg = getConfig(); + cfg.set("redis.ttl-ms", settings.ttlMs); + cfg.set("redis.retry-ms", settings.retryMs); + cfg.set("redis.max-attempts", settings.maxAttempts); + } + + private LockSettings resolveLockSettings() { + FileConfiguration cfg = getConfig(); + String strategy = cfg.getString("locking-strategy", "LOCAL").toUpperCase(Locale.ROOT); + + long ttlMs = cfg.getLong("locking.ttl-ms", 5000L); + long retryMs = cfg.getLong("locking.retry-ms", 50L); + int maxAttempts = cfg.getInt("locking.max-attempts", 100); + + // Backward compatibility with previous config keys. + ttlMs = cfg.getLong("redis.ttl-ms", ttlMs); + retryMs = cfg.getLong("redis.retry-ms", retryMs); + maxAttempts = cfg.getInt("redis.max-attempts", maxAttempts); + + if ("REDIS".equals(strategy)) { + FileConfiguration redis = loadOptionalConfig("redis.yml"); + if (redis != null) { + ttlMs = redis.getLong("ttl-ms", ttlMs); + retryMs = redis.getLong("retry-ms", retryMs); + maxAttempts = redis.getInt("max-attempts", maxAttempts); + } + } else if ("BUNGEECORD".equals(strategy)) { + FileConfiguration bungee = loadOptionalConfig("bungeecord.yml"); + if (bungee != null) { + ttlMs = bungee.getLong("ttl-ms", ttlMs); + retryMs = bungee.getLong("retry-ms", retryMs); + maxAttempts = bungee.getInt("max-attempts", maxAttempts); + } + } + + return new LockSettings(ttlMs, retryMs, maxAttempts); + } + + private FileConfiguration loadOptionalConfig(String fileName) { + File file = new File(getDataFolder(), fileName); + return file.exists() ? YamlConfiguration.loadConfiguration(file) : null; + } + + private static final class LockSettings { + private final long ttlMs; + private final long retryMs; + private final int maxAttempts; + + private LockSettings(long ttlMs, long retryMs, int maxAttempts) { + this.ttlMs = ttlMs; + this.retryMs = retryMs; + this.maxAttempts = maxAttempts; + } + } + /** * Returns the active plugin instance, or null if not set. */ diff --git a/src/main/java/com/skyblockexp/ezeconomy/core/VaultEconomyImpl.java b/src/main/java/com/skyblockexp/ezeconomy/core/VaultEconomyImpl.java index 1b7e582..579b8d0 100644 --- a/src/main/java/com/skyblockexp/ezeconomy/core/VaultEconomyImpl.java +++ b/src/main/java/com/skyblockexp/ezeconomy/core/VaultEconomyImpl.java @@ -6,6 +6,9 @@ import java.util.Collections; import java.util.List; import java.util.UUID; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.locks.ReentrantLock; +import com.skyblockexp.ezeconomy.storage.TransferLockManager; import net.milkbowl.vault.economy.Economy; import net.milkbowl.vault.economy.EconomyResponse; import org.bukkit.OfflinePlayer; @@ -145,11 +148,21 @@ public EconomyResponse withdrawPlayer(OfflinePlayer player, double amount) { } public EconomyResponse withdrawPlayer(OfflinePlayer player, double amount, String currency) { - boolean success = api.withdraw(player.getUniqueId(), currency, amount); - double balance = api.getBalance(player.getUniqueId(), currency).getBalance(); - return success + StorageProvider storage = getStorageProvider(); + if (storage == null) { + return new EconomyResponse(0, 0, EconomyResponse.ResponseType.FAILURE, "Storage unavailable"); + } + UUID uuid = player.getUniqueId(); + LockedOperation lock = lockFor(uuid); + try { + boolean success = storage.tryWithdraw(uuid, currency, amount); + double balance = storage.getBalance(uuid, currency); + return success ? new EconomyResponse(amount, balance, EconomyResponse.ResponseType.SUCCESS, null) : new EconomyResponse(0, balance, EconomyResponse.ResponseType.FAILURE, INSUFFICIENT_FUNDS); + } finally { + lock.close(); + } } @Override @@ -260,12 +273,18 @@ public EconomyResponse bankWithdraw(String name, double amount) { return new EconomyResponse(0, 0, EconomyResponse.ResponseType.FAILURE, BANK_DOES_NOT_EXIST); } String currency = plugin.getDefaultCurrency(); - boolean success = storage.tryWithdrawBank(name, currency, amount); - double balance = storage.getBankBalance(name, currency); - if (!success) { - return new EconomyResponse(0, balance, EconomyResponse.ResponseType.FAILURE, INSUFFICIENT_FUNDS); + UUID bankLockKey = UUID.nameUUIDFromBytes(("bank:" + name + ":" + currency).getBytes(StandardCharsets.UTF_8)); + LockedOperation lock = lockFor(bankLockKey); + try { + boolean success = storage.tryWithdrawBank(name, currency, amount); + double balance = storage.getBankBalance(name, currency); + if (!success) { + return new EconomyResponse(0, balance, EconomyResponse.ResponseType.FAILURE, INSUFFICIENT_FUNDS); + } + return new EconomyResponse(amount, balance, EconomyResponse.ResponseType.SUCCESS, null); + } finally { + lock.close(); } - return new EconomyResponse(amount, balance, EconomyResponse.ResponseType.SUCCESS, null); } @Override @@ -405,10 +424,63 @@ public EconomyResponse bankWithdraw(String name, String currency, double amount) if (!storage.bankExists(name)) { return new EconomyResponse(0, 0, EconomyResponse.ResponseType.FAILURE, BANK_DOES_NOT_EXIST); } - boolean success = storage.tryWithdrawBank(name, currency, amount); - double balance = storage.getBankBalance(name, currency); - return success + UUID bankLockKey = UUID.nameUUIDFromBytes(("bank:" + name + ":" + currency).getBytes(StandardCharsets.UTF_8)); + LockedOperation lock = lockFor(bankLockKey); + try { + boolean success = storage.tryWithdrawBank(name, currency, amount); + double balance = storage.getBankBalance(name, currency); + return success ? new EconomyResponse(amount, balance, EconomyResponse.ResponseType.SUCCESS, null) : new EconomyResponse(0, balance, EconomyResponse.ResponseType.FAILURE, INSUFFICIENT_FUNDS); + } finally { + lock.close(); + } + } + + private LockedOperation lockFor(UUID key) { + com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); + if (lm != null) { + try { + String token = lm.acquire( + key, + plugin.getLockTtlMs(), + plugin.getLockRetryMs(), + plugin.getLockMaxAttempts() + ); + if (token != null) { + return new LockedOperation(lm, key, token, null); + } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + ReentrantLock local = TransferLockManager.getLock(key); + local.lock(); + return new LockedOperation(null, key, null, local); + } + + private static final class LockedOperation implements AutoCloseable { + private final com.skyblockexp.ezeconomy.lock.LockManager manager; + private final UUID key; + private final String token; + private final ReentrantLock localLock; + + private LockedOperation(com.skyblockexp.ezeconomy.lock.LockManager manager, UUID key, String token, ReentrantLock localLock) { + this.manager = manager; + this.key = key; + this.token = token; + this.localLock = localLock; + } + + @Override + public void close() { + if (manager != null && token != null) { + manager.release(key, token); + return; + } + if (localLock != null) { + localLock.unlock(); + } + } } } diff --git a/src/main/java/com/skyblockexp/ezeconomy/listener/PlayerJoinListener.java b/src/main/java/com/skyblockexp/ezeconomy/listener/PlayerJoinListener.java index d57afa1..311cbed 100644 --- a/src/main/java/com/skyblockexp/ezeconomy/listener/PlayerJoinListener.java +++ b/src/main/java/com/skyblockexp/ezeconomy/listener/PlayerJoinListener.java @@ -31,15 +31,26 @@ public void onPlayerJoin(PlayerJoinEvent event) { StorageProvider storage = plugin.getStorageOrWarn(); if (storage == null) return; + // Only persist mapping when store-on-join is enabled to avoid write spam. + try { + org.bukkit.entity.Player p = event.getPlayer(); + storage.persistPlayerInfo(p.getUniqueId(), p.getName(), p.getDisplayName()); + } catch (Exception e) { + plugin.getLogger().warning("Failed to persist player info on join: " + e.getMessage()); + } + String currency = plugin.getDefaultCurrency(); try { UUID uuid = event.getPlayer().getUniqueId(); if (!storage.playerExists(uuid)) { com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); + long ttlMs = plugin.getLockTtlMs(); + long retryMs = plugin.getLockRetryMs(); + int maxAttempts = plugin.getLockMaxAttempts(); if (lm != null) { String token = null; try { - token = lm.acquire(uuid, 5000L, 50L, 100); + token = lm.acquire(uuid, ttlMs, retryMs, maxAttempts); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); token = null; diff --git a/src/main/java/com/skyblockexp/ezeconomy/manager/BankInterestManager.java b/src/main/java/com/skyblockexp/ezeconomy/manager/BankInterestManager.java index 117660e..1b8ebb0 100644 --- a/src/main/java/com/skyblockexp/ezeconomy/manager/BankInterestManager.java +++ b/src/main/java/com/skyblockexp/ezeconomy/manager/BankInterestManager.java @@ -1,115 +1,118 @@ -package com.skyblockexp.ezeconomy.manager; - -import com.skyblockexp.ezeconomy.core.EzEconomyPlugin; -import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; -import org.bukkit.scheduler.BukkitRunnable; -import java.util.Map; -import java.util.Set; -import java.util.UUID; - -public class BankInterestManager { - private final EzEconomyPlugin plugin; - private int taskId = -1; - - // THREAD SAFETY NOTE: - // payInterestToAll() is called from a BukkitRunnable (main server thread), - // so storage operations are not concurrent by default. However, if storage - // providers are accessed from async tasks elsewhere, or if future changes - // introduce async interest payout, all storage operations here must be thread-safe. - // - // If you plan to run payInterestToAll() asynchronously, ensure: - // 1. All storageProvider methods (get/setBalance, getBankBalance, getBankMembers, etc.) are thread-safe. - // 2. Use synchronization or locks if the underlying storage is not thread-safe. - // 3. Consider using Bukkit's scheduler to run only thread-safe code async, and all Bukkit API calls sync. - - public BankInterestManager(EzEconomyPlugin plugin) { - this.plugin = plugin; - } - - public void start(long intervalTicks) { - if (taskId != -1) { - Bukkit.getScheduler().cancelTask(taskId); - } - taskId = new BukkitRunnable() { - @Override - public void run() { - payInterestToAll(); - } - }.runTaskTimer(plugin, intervalTicks, intervalTicks).getTaskId(); - } - - public void stop() { - if (taskId != -1) { - Bukkit.getScheduler().cancelTask(taskId); - taskId = -1; - } - } - - private void payInterestToAll() { - com.skyblockexp.ezeconomy.api.storage.StorageProvider storage = plugin.getStorageOrWarn(); - if (storage == null) { - return; - } - org.bukkit.configuration.file.FileConfiguration config = plugin.getConfig(); - boolean multiEnabled = config.getBoolean("multi-currency.enabled", false); - Set currencies; - if (multiEnabled) { - var section = config.getConfigurationSection("multi-currency.currencies"); - currencies = section != null ? section.getKeys(false) : java.util.Collections.singleton("dollar"); - } else { - currencies = java.util.Collections.singleton("dollar"); - } - for (String currency : currencies) { - for (String bank : storage.getBanks()) { - double bankBalance = storage.getBankBalance(bank, currency); - Set members = storage.getBankMembers(bank); - if (members == null || members.isEmpty()) continue; - double grossInterest = calculateInterest(bankBalance); - double perMemberInterest = grossInterest / members.size(); - for (UUID uuid : members) { - OfflinePlayer player = Bukkit.getOfflinePlayer(uuid); - if (perMemberInterest > 0) { - com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); - if (lm != null) { - String token = null; - try { - token = lm.acquire(uuid, 5000L, 50L, 100); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - token = null; - } - if (token != null) { - try { - storage.setBalance(uuid, currency, storage.getBalance(uuid, currency) + perMemberInterest); - } finally { - lm.release(uuid, token); - } - } else { - // fallback to local lock - java.util.concurrent.locks.ReentrantLock l = com.skyblockexp.ezeconomy.storage.TransferLockManager.getLock(uuid); - l.lock(); - try { - storage.setBalance(uuid, currency, storage.getBalance(uuid, currency) + perMemberInterest); - } finally { - l.unlock(); - } - } - } else { - storage.setBalance(uuid, currency, storage.getBalance(uuid, currency) + perMemberInterest); - } - if (player.isOnline()) { - String compact = plugin.getCurrencyFormatter().formatShort(perMemberInterest, null); - player.getPlayer().sendMessage("You received " + compact + " " + currency + " interest from bank '" + bank + "'"); - } - } - } - } - } - } - - // Example interest calculation (1% per payout) - private double calculateInterest(double balance) { - return Math.round(balance * 0.01 * 100.0) / 100.0; - } -} +package com.skyblockexp.ezeconomy.manager; + +import com.skyblockexp.ezeconomy.core.EzEconomyPlugin; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.scheduler.BukkitRunnable; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +public class BankInterestManager { + private final EzEconomyPlugin plugin; + private int taskId = -1; + + // THREAD SAFETY NOTE: + // payInterestToAll() is called from a BukkitRunnable (main server thread), + // so storage operations are not concurrent by default. However, if storage + // providers are accessed from async tasks elsewhere, or if future changes + // introduce async interest payout, all storage operations here must be thread-safe. + // + // If you plan to run payInterestToAll() asynchronously, ensure: + // 1. All storageProvider methods (get/setBalance, getBankBalance, getBankMembers, etc.) are thread-safe. + // 2. Use synchronization or locks if the underlying storage is not thread-safe. + // 3. Consider using Bukkit's scheduler to run only thread-safe code async, and all Bukkit API calls sync. + + public BankInterestManager(EzEconomyPlugin plugin) { + this.plugin = plugin; + } + + public void start(long intervalTicks) { + if (taskId != -1) { + Bukkit.getScheduler().cancelTask(taskId); + } + taskId = new BukkitRunnable() { + @Override + public void run() { + payInterestToAll(); + } + }.runTaskTimer(plugin, intervalTicks, intervalTicks).getTaskId(); + } + + public void stop() { + if (taskId != -1) { + Bukkit.getScheduler().cancelTask(taskId); + taskId = -1; + } + } + + private void payInterestToAll() { + com.skyblockexp.ezeconomy.api.storage.StorageProvider storage = plugin.getStorageOrWarn(); + if (storage == null) { + return; + } + org.bukkit.configuration.file.FileConfiguration config = plugin.getConfig(); + boolean multiEnabled = config.getBoolean("multi-currency.enabled", false); + Set currencies; + if (multiEnabled) { + var section = config.getConfigurationSection("multi-currency.currencies"); + currencies = section != null ? section.getKeys(false) : java.util.Collections.singleton("dollar"); + } else { + currencies = java.util.Collections.singleton("dollar"); + } + for (String currency : currencies) { + for (String bank : storage.getBanks()) { + double bankBalance = storage.getBankBalance(bank, currency); + Set members = storage.getBankMembers(bank); + if (members == null || members.isEmpty()) continue; + double grossInterest = calculateInterest(bankBalance); + double perMemberInterest = grossInterest / members.size(); + for (UUID uuid : members) { + OfflinePlayer player = Bukkit.getOfflinePlayer(uuid); + if (perMemberInterest > 0) { + com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); + if (lm != null) { + String token = null; + long ttlMs = plugin.getLockTtlMs(); + long retryMs = plugin.getLockRetryMs(); + int maxAttempts = plugin.getLockMaxAttempts(); + try { + token = lm.acquire(uuid, ttlMs, retryMs, maxAttempts); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + token = null; + } + if (token != null) { + try { + storage.setBalance(uuid, currency, storage.getBalance(uuid, currency) + perMemberInterest); + } finally { + lm.release(uuid, token); + } + } else { + // fallback to local lock + java.util.concurrent.locks.ReentrantLock l = com.skyblockexp.ezeconomy.storage.TransferLockManager.getLock(uuid); + l.lock(); + try { + storage.setBalance(uuid, currency, storage.getBalance(uuid, currency) + perMemberInterest); + } finally { + l.unlock(); + } + } + } else { + storage.setBalance(uuid, currency, storage.getBalance(uuid, currency) + perMemberInterest); + } + if (player.isOnline()) { + String compact = plugin.getCurrencyFormatter().formatShort(perMemberInterest, null); + player.getPlayer().sendMessage("You received " + compact + " " + currency + " interest from bank '" + bank + "'"); + } + } + } + } + } + } + + // Example interest calculation (1% per payout) + private double calculateInterest(double balance) { + return Math.round(balance * 0.01 * 100.0) / 100.0; + } +} diff --git a/src/main/java/com/skyblockexp/ezeconomy/messaging/CrossServerMessenger.java b/src/main/java/com/skyblockexp/ezeconomy/messaging/CrossServerMessenger.java new file mode 100644 index 0000000..125ef3f --- /dev/null +++ b/src/main/java/com/skyblockexp/ezeconomy/messaging/CrossServerMessenger.java @@ -0,0 +1,191 @@ +package com.skyblockexp.ezeconomy.messaging; + +import com.skyblockexp.ezeconomy.core.EzEconomyPlugin; +import com.skyblockexp.ezeconomy.api.storage.StorageProvider; +import com.skyblockexp.ezeconomy.storage.MySQLStorageProvider; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.messaging.PluginMessageListener; + +import java.io.*; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +public class CrossServerMessenger implements PluginMessageListener { + public static final String CHANNEL = "ezeconomy:notify"; + private final EzEconomyPlugin plugin; + private final Set networkPlayers = ConcurrentHashMap.newKeySet(); + private final Map networkPlayerUuids = new ConcurrentHashMap<>(); + private final Map> localPendingNotifications = new ConcurrentHashMap<>(); + + public CrossServerMessenger(EzEconomyPlugin plugin) { + this.plugin = plugin; + } + + public void register() { + Bukkit.getMessenger().registerOutgoingPluginChannel(plugin, CHANNEL); + Bukkit.getMessenger().registerIncomingPluginChannel(plugin, CHANNEL, this); + logVerbose("Registered cross-server messaging channel: " + CHANNEL); + } + + public void unregister() { + Bukkit.getMessenger().unregisterOutgoingPluginChannel(plugin, CHANNEL); + Bukkit.getMessenger().unregisterIncomingPluginChannel(plugin, CHANNEL, this); + } + + public Set getNetworkPlayers() { + return Collections.unmodifiableSet(networkPlayers); + } + + public boolean isNetworkPlayer(String name) { + return name != null && networkPlayerUuids.containsKey(name.toLowerCase(Locale.ROOT)); + } + + public UUID getNetworkPlayerUuid(String name) { + if (name == null) return null; + return networkPlayerUuids.get(name.toLowerCase(Locale.ROOT)); + } + + public void sendPaymentNotification(UUID recipientUuid, String recipientName, + String senderName, String amount, String currency) { + Player relay = findRelayPlayer(); + if (relay == null) { + storePendingNotification(recipientUuid, recipientName, senderName, amount, currency); + return; + } + + try { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bos); + out.writeUTF("NOTIFY"); + out.writeUTF(recipientUuid.toString()); + out.writeUTF(recipientName); + out.writeUTF(senderName); + out.writeUTF(amount); + out.writeUTF(currency); + relay.sendPluginMessage(plugin, CHANNEL, bos.toByteArray()); + logVerbose("Relayed cross-server notification for recipient=" + recipientName + " via " + relay.getName()); + } catch (IOException e) { + plugin.getLogger().warning("Failed to send cross-server notification: " + e.getMessage()); + storePendingNotification(recipientUuid, recipientName, senderName, amount, currency); + } + } + + @Override + public void onPluginMessageReceived(String channel, Player player, byte[] data) { + if (!CHANNEL.equals(channel)) return; + + try { + DataInputStream in = new DataInputStream(new ByteArrayInputStream(data)); + String type = in.readUTF(); + + if ("NOTIFY".equals(type)) { + String recipientUuidStr = in.readUTF(); + String recipientName = in.readUTF(); + String senderName = in.readUTF(); + String amount = in.readUTF(); + String currency = in.readUTF(); + logVerbose("Received cross-server NOTIFY: recipient=" + recipientName + + " uuid=" + recipientUuidStr + " from=" + senderName + " amount=" + amount); + + Player recipient = Bukkit.getPlayer(UUID.fromString(recipientUuidStr)); + if (recipient != null && recipient.isOnline()) { + String msg = plugin.getMessageProvider().get("received", + Map.of("player", senderName, "amount", amount)); + logVerbose("Delivering cross-server message to " + recipientName + ": " + msg); + recipient.sendMessage(msg); + } else { + logVerbose("Cross-server NOTIFY: recipient " + recipientName + " not found locally (uuid=" + recipientUuidStr + ")"); + } + } else if ("RECIPIENT_OFFLINE".equals(type)) { + String recipientUuidStr = in.readUTF(); + String senderName = in.readUTF(); + String amount = in.readUTF(); + String currency = in.readUTF(); + storePendingNotification(UUID.fromString(recipientUuidStr), recipientUuidStr, senderName, amount, currency); + } else if ("PLAYER_LIST".equals(type)) { + int count = in.readInt(); + Set newList = ConcurrentHashMap.newKeySet(); + Map newUuidMap = new ConcurrentHashMap<>(); + for (int i = 0; i < count; i++) { + // Backward-compatible decode: + // New format: [uuid, name] + // Legacy format: [name] + String first = in.readUTF(); + UUID uuid = null; + String name; + try { + uuid = UUID.fromString(first); + name = in.readUTF(); + } catch (IllegalArgumentException ignored) { + name = first; + } + newList.add(name); + if (uuid == null) { + Player local = Bukkit.getPlayerExact(name); + if (local != null) { + uuid = local.getUniqueId(); + } else { + StorageProvider storage = plugin.getStorageOrWarn(); + if (storage != null) { + uuid = storage.resolvePlayerByName(name); + } + } + } + if (uuid != null) { + newUuidMap.put(name.toLowerCase(Locale.ROOT), uuid); + } + } + networkPlayers.clear(); + networkPlayers.addAll(newList); + networkPlayerUuids.clear(); + networkPlayerUuids.putAll(newUuidMap); + } + } catch (Exception e) { + plugin.getLogger().warning("Failed to read cross-server message: " + e.getMessage()); + } + } + + public void deliverPendingNotifications(Player player) { + StorageProvider storage = plugin.getStorageOrWarn(); + List messages = new ArrayList<>(); + if (storage instanceof MySQLStorageProvider) { + MySQLStorageProvider mysql = (MySQLStorageProvider) storage; + messages.addAll(mysql.pollPendingNotifications(player.getUniqueId())); + } else { + List drained = localPendingNotifications.remove(player.getUniqueId()); + if (drained != null && !drained.isEmpty()) { + messages.addAll(drained); + logVerbose("Delivered " + drained.size() + " in-memory pending notifications to " + player.getName()); + } + } + for (String msg : messages) { + player.sendMessage(msg); + } + } + + private void storePendingNotification(UUID recipientUuid, String recipientName, String senderName, String amount, String currency) { + StorageProvider storage = plugin.getStorageOrWarn(); + String msg = plugin.getMessageProvider().get("received", + Map.of("player", senderName, "amount", amount)); + if (storage instanceof MySQLStorageProvider) { + MySQLStorageProvider mysql = (MySQLStorageProvider) storage; + mysql.insertPendingNotification(recipientUuid, msg); + return; + } + + localPendingNotifications.computeIfAbsent(recipientUuid, ignored -> Collections.synchronizedList(new ArrayList<>())).add(msg); + logVerbose("Queued in-memory pending notification for recipient=" + recipientName + " (storage is not MySQL)"); + } + + private Player findRelayPlayer() { + Collection online = Bukkit.getOnlinePlayers(); + return online.isEmpty() ? null : online.iterator().next(); + } + + private void logVerbose(String message) { + if (plugin.getConfig().getBoolean("cross-server.verbose-logging", false)) { + plugin.getLogger().info(message); + } + } +} diff --git a/src/main/java/com/skyblockexp/ezeconomy/service/PaymentExecutor.java b/src/main/java/com/skyblockexp/ezeconomy/service/PaymentExecutor.java index 37d86b7..1d812d6 100644 --- a/src/main/java/com/skyblockexp/ezeconomy/service/PaymentExecutor.java +++ b/src/main/java/com/skyblockexp/ezeconomy/service/PaymentExecutor.java @@ -14,8 +14,14 @@ import java.math.BigDecimal; import java.util.UUID; +import java.util.concurrent.TimeUnit; public class PaymentExecutor { + private static long syncEventTimeoutMs(EzEconomyPlugin plugin) { + long configured = plugin.getConfig().getLong("payment.sync-event-timeout-ms", 5000L); + return configured > 0L ? configured : 5000L; + } + /** * Execute a payment between players. Returns true if the operation completed (success or handled failure). */ @@ -30,9 +36,32 @@ public static boolean execute(EzEconomyPlugin plugin, Player from, String toName if (storage == null) return true; plugin.getLogger().info("PaymentExecutor: start execute from=" + from.getName() + " toName=" + toName + " amount=" + netAmount + " currency=" + currency + " knownOffline=" + knownOffline); - // Try online fast path + // Try online fast path, then DB-backed resolution, then PlayerLookup Player online = Bukkit.getPlayerExact(toName); - OfflinePlayer toOffline = online != null ? online : Bukkit.getOfflinePlayer(toName); + OfflinePlayer toOffline = null; + if (online != null) { + toOffline = online; + } else { + UUID resolvedUuid = null; + var messenger = plugin.getCrossServerMessenger(); + if (messenger != null) { + resolvedUuid = messenger.getNetworkPlayerUuid(toName); + } + if (resolvedUuid == null) { + resolvedUuid = storage.resolvePlayerByName(toName); + } + if (resolvedUuid != null) { + toOffline = Bukkit.getOfflinePlayer(resolvedUuid); + knownOffline = true; + } else { + var maybe = com.skyblockexp.ezeconomy.util.PlayerLookup.findByName(toName); + if (maybe.isPresent()) { + toOffline = maybe.get(); + } else { + toOffline = Bukkit.getOfflinePlayer(toName); + } + } + } UUID fromUuid = from.getUniqueId(); @@ -60,10 +89,11 @@ public static boolean execute(EzEconomyPlugin plugin, Player from, String toName Bukkit.getPluginManager().callEvent(payEvent); } else { try { + long timeoutMs = syncEventTimeoutMs(plugin); Bukkit.getScheduler().callSyncMethod(plugin, () -> { Bukkit.getPluginManager().callEvent(payEvent); return null; - }).get(); + }).get(timeoutMs, TimeUnit.MILLISECONDS); } catch (Exception e) { plugin.getLogger().warning("PaymentExecutor: failed to call PlayerPayPlayerEvent on main thread: " + e.getMessage()); // If we cannot safely call the event, cancel the payment to be safe @@ -93,8 +123,11 @@ public static boolean execute(EzEconomyPlugin plugin, Player from, String toName if (lm != null) { UUID[] uuids = new UUID[]{first, second}; String[] tokens = null; + long ttlMs = plugin.getLockTtlMs(); + long retryMs = plugin.getLockRetryMs(); + int maxAttempts = plugin.getLockMaxAttempts(); try { - tokens = lm.acquireOrdered(uuids, 5000L, 50L, 100); + tokens = lm.acquireOrdered(uuids, ttlMs, retryMs, maxAttempts); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); tokens = null; @@ -150,6 +183,10 @@ public static boolean execute(EzEconomyPlugin plugin, Player from, String toName } else { MessageUtils.send(toOffline.getPlayer(), plugin, "received", java.util.Map.of("player", from.getName(), "amount", receiverDisplay)); } + } else if (plugin.getCrossServerMessenger() != null) { + plugin.getCrossServerMessenger().sendPaymentNotification( + toOffline.getUniqueId(), toOffline.getName() != null ? toOffline.getName() : toName, + from.getName(), receiverDisplay, recipientCurrency); } return true; } finally { @@ -216,6 +253,10 @@ public static boolean execute(EzEconomyPlugin plugin, Player from, String toName } else { MessageUtils.send(toOffline.getPlayer(), plugin, "received", java.util.Map.of("player", from.getName(), "amount", receiverDisplay)); } + } else if (plugin.getCrossServerMessenger() != null) { + plugin.getCrossServerMessenger().sendPaymentNotification( + toOffline.getUniqueId(), toOffline.getName() != null ? toOffline.getName() : toName, + from.getName(), receiverDisplay, recipientCurrency); } return true; } finally { @@ -235,20 +276,26 @@ public static boolean execute(EzEconomyPlugin plugin, Player from, String toName } String amountWithSymbol = plugin.getCurrencyFormatter().formatPriceForMessage(netAmount, currency); + String senderBalDisplay = plugin.getCurrencyFormatter().formatPriceForMessage(transfer.getFromBalance(), currency); + String receiverBalDisplay = plugin.getCurrencyFormatter().formatPriceForMessage(transfer.getToBalance(), currency); String defaultCur = plugin.getDefaultCurrency(); if (!currency.equalsIgnoreCase(defaultCur)) { double equiv = CurrencyUtil.convert(plugin, netAmount, currency, defaultCur); if (!Double.isNaN(equiv)) { String equivDisplay = plugin.getCurrencyFormatter().formatPriceForMessage(equiv, defaultCur); - MessageUtils.send(from, plugin, "paid_other_currency", java.util.Map.of("player", toOffline.getName(), "amount", amountWithSymbol, "amount_default", equivDisplay)); + MessageUtils.send(from, plugin, "paid_other_currency", java.util.Map.of("player", toOffline.getName(), "amount", amountWithSymbol, "amount_default", equivDisplay, "balance", senderBalDisplay)); } else { - MessageUtils.send(from, plugin, "paid", java.util.Map.of("player", toOffline.getName(), "amount", amountWithSymbol)); + MessageUtils.send(from, plugin, "paid", java.util.Map.of("player", toOffline.getName(), "amount", amountWithSymbol, "balance", senderBalDisplay)); } } else { - MessageUtils.send(from, plugin, "paid", java.util.Map.of("player", toOffline.getName(), "amount", amountWithSymbol)); + MessageUtils.send(from, plugin, "paid", java.util.Map.of("player", toOffline.getName(), "amount", amountWithSymbol, "balance", senderBalDisplay)); } if (toOffline.isOnline() && toOffline.getPlayer() != null) { - MessageUtils.send(toOffline.getPlayer(), plugin, "received", java.util.Map.of("player", from.getName(), "amount", amountWithSymbol)); + MessageUtils.send(toOffline.getPlayer(), plugin, "received", java.util.Map.of("player", from.getName(), "amount", amountWithSymbol, "balance", receiverBalDisplay)); + } else if (plugin.getCrossServerMessenger() != null) { + plugin.getCrossServerMessenger().sendPaymentNotification( + toOffline.getUniqueId(), toOffline.getName() != null ? toOffline.getName() : toName, + from.getName(), amountWithSymbol, currency); } return true; } diff --git a/src/main/java/com/skyblockexp/ezeconomy/storage/MySQLStorageProvider.java b/src/main/java/com/skyblockexp/ezeconomy/storage/MySQLStorageProvider.java index a7bd074..49ea2ac 100644 --- a/src/main/java/com/skyblockexp/ezeconomy/storage/MySQLStorageProvider.java +++ b/src/main/java/com/skyblockexp/ezeconomy/storage/MySQLStorageProvider.java @@ -13,28 +13,24 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import java.math.BigDecimal; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; import com.skyblockexp.ezeconomy.api.events.BankPreTransactionEvent; import com.skyblockexp.ezeconomy.api.events.BankPostTransactionEvent; import com.skyblockexp.ezeconomy.api.events.TransactionType; /** * MySQL implementation of the StorageProvider interface for EzEconomy. - * Handles player and bank balances using a MySQL database. - * Thread-safe and ready for open-source use. + * Uses HikariCP connection pooling for high-performance concurrent access. */ public class MySQLStorageProvider implements StorageProvider { private final EzEconomyPlugin plugin; - private Connection connection; + private HikariDataSource dataSource; private String table; - private final Object lock = new Object(); private final YamlConfiguration dbConfig; - /** - * Constructs a MySQLStorageProvider with the given plugin and configuration. - * @param plugin EzEconomy plugin instance - * @param dbConfig YAML configuration for MySQL - */ public MySQLStorageProvider(EzEconomyPlugin plugin, YamlConfiguration dbConfig) { this.plugin = plugin; this.dbConfig = dbConfig; @@ -42,53 +38,64 @@ public MySQLStorageProvider(EzEconomyPlugin plugin, YamlConfiguration dbConfig) this.table = dbConfig.getString("mysql.table", "balances"); } + private String buildJdbcUrl() { + String host = dbConfig.getString("mysql.host"); + int port = dbConfig.getInt("mysql.port"); + String database = dbConfig.getString("mysql.database"); + return "jdbc:mysql://" + host + ":" + port + "/" + database + + "?autoReconnect=true&useSSL=false&allowPublicKeyRetrieval=true"; + } + @Override public void init() throws StorageInitException { - // Create tables/schema if needed - if (connection == null) { - // Establish a temporary connection for schema creation - String host = dbConfig.getString("mysql.host"); - int port = dbConfig.getInt("mysql.port"); - String database = dbConfig.getString("mysql.database"); - String username = dbConfig.getString("mysql.username"); - String password = dbConfig.getString("mysql.password"); - try (Connection tempConn = DriverManager.getConnection( - "jdbc:mysql://" + host + ":" + port + "/" + database, - username, password)) { - Statement stmt = tempConn.createStatement(); - stmt.executeUpdate("CREATE TABLE IF NOT EXISTS `" + table + "` (uuid VARCHAR(36), currency VARCHAR(32), balance DOUBLE, PRIMARY KEY (uuid, currency))"); - stmt.executeUpdate("CREATE TABLE IF NOT EXISTS banks (name VARCHAR(64), currency VARCHAR(32), balance DOUBLE, PRIMARY KEY (name, currency))"); - stmt.executeUpdate("CREATE TABLE IF NOT EXISTS bank_members (bank VARCHAR(64), uuid VARCHAR(36), owner BOOLEAN, PRIMARY KEY (bank, uuid))"); - // Optional player info table to persist last-known name and displayName - stmt.executeUpdate("CREATE TABLE IF NOT EXISTS players (uuid VARCHAR(36) PRIMARY KEY, name VARCHAR(64), displayName VARCHAR(128))"); - } catch (SQLException e) { - plugin.getLogger().warning("MySQL schema init failed: " + e.getMessage()); - throw new StorageInitException("Failed to initialize MySQL schema", e); - } + try (Connection conn = dataSource != null ? dataSource.getConnection() + : DriverManager.getConnection(buildJdbcUrl(), + dbConfig.getString("mysql.username"), dbConfig.getString("mysql.password"))) { + Statement stmt = conn.createStatement(); + stmt.executeUpdate("CREATE TABLE IF NOT EXISTS `" + table + "` (uuid VARCHAR(36), currency VARCHAR(32), balance DOUBLE, PRIMARY KEY (uuid, currency))"); + stmt.executeUpdate("CREATE TABLE IF NOT EXISTS banks (name VARCHAR(64), currency VARCHAR(32), balance DOUBLE, PRIMARY KEY (name, currency))"); + stmt.executeUpdate("CREATE TABLE IF NOT EXISTS bank_members (bank VARCHAR(64), uuid VARCHAR(36), owner BOOLEAN, PRIMARY KEY (bank, uuid))"); + stmt.executeUpdate("CREATE TABLE IF NOT EXISTS players (uuid VARCHAR(36) PRIMARY KEY, name VARCHAR(64), displayName VARCHAR(128))"); + stmt.executeUpdate("CREATE TABLE IF NOT EXISTS transactions (id BIGINT AUTO_INCREMENT PRIMARY KEY, uuid VARCHAR(36), currency VARCHAR(32), amount DOUBLE, timestamp BIGINT, from_uuid VARCHAR(36), to_uuid VARCHAR(36), from_balance_after DOUBLE, to_balance_after DOUBLE, INDEX idx_tx_uuid(uuid))"); + stmt.executeUpdate("CREATE TABLE IF NOT EXISTS pending_notifications (id BIGINT AUTO_INCREMENT PRIMARY KEY, uuid VARCHAR(36), message TEXT, created_at BIGINT, INDEX idx_pn_uuid(uuid))"); + } catch (SQLException e) { + plugin.getLogger().warning("MySQL schema init failed: " + e.getMessage()); + throw new StorageInitException("Failed to initialize MySQL schema", e); } } @Override public void load() throws StorageLoadException { - // Establish connection only - String host = dbConfig.getString("mysql.host"); - int port = dbConfig.getInt("mysql.port"); - String database = dbConfig.getString("mysql.database"); - String username = dbConfig.getString("mysql.username"); - String password = dbConfig.getString("mysql.password"); + if (dataSource != null && !dataSource.isClosed()) { + dataSource.close(); + } + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(buildJdbcUrl()); + config.setUsername(dbConfig.getString("mysql.username")); + config.setPassword(dbConfig.getString("mysql.password")); + config.setMaximumPoolSize(10); + config.setMinimumIdle(2); + config.setIdleTimeout(300000); + config.setMaxLifetime(600000); + config.setConnectionTimeout(5000); + config.setPoolName("EzEconomy-HikariPool"); + config.addDataSourceProperty("cachePrepStmts", "true"); + config.addDataSourceProperty("prepStmtCacheSize", "250"); + config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); + config.addDataSourceProperty("useServerPrepStmts", "true"); try { - if (connection != null && !connection.isClosed()) { - connection.close(); - } - connection = DriverManager.getConnection( - "jdbc:mysql://" + host + ":" + port + "/" + database, - username, password); - } catch (SQLException e) { - plugin.getLogger().warning("MySQL connection failed: " + e.getMessage()); - throw new StorageLoadException("Failed to connect to MySQL", e); + dataSource = new HikariDataSource(config); + plugin.getLogger().info("HikariCP connection pool initialized (max=10)."); + } catch (Exception e) { + plugin.getLogger().warning("HikariCP pool init failed: " + e.getMessage()); + throw new StorageLoadException("Failed to create connection pool", e); } } + private Connection getConn() throws SQLException { + return dataSource.getConnection(); + } + @Override public void save() throws StorageSaveException { // No in-memory cache, so nothing to save @@ -96,1258 +103,522 @@ public void save() throws StorageSaveException { @Override public boolean isConnected() { - try { - return connection != null && !connection.isClosed(); - } catch (SQLException e) { - return false; - } + return dataSource != null && !dataSource.isClosed(); } @Override public java.util.List getTransactions(java.util.UUID uuid, String currency) { java.util.List transactions = new java.util.ArrayList<>(); - synchronized (lock) { - try { - // Assumes a table: transactions(uuid VARCHAR(36), currency VARCHAR(32), amount DOUBLE, timestamp BIGINT) - String sql = "SELECT amount, timestamp FROM transactions WHERE uuid=? AND currency=? ORDER BY timestamp DESC"; - PreparedStatement ps = connection.prepareStatement(sql); - ps.setString(1, uuid.toString()); - ps.setString(2, currency); - ResultSet rs = ps.executeQuery(); - while (rs.next()) { - double amount = rs.getDouble("amount"); - long timestamp = rs.getLong("timestamp"); - Transaction t = new Transaction(uuid, currency, amount, timestamp); - transactions.add(t); - } - } catch (SQLException e) { - plugin.getLogger().severe("[EzEconomy] MySQL getTransactions failed for " + uuid + " (" + currency + "): " + e.getMessage()); + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement( + "SELECT amount, timestamp FROM transactions WHERE uuid=? AND currency=? ORDER BY timestamp DESC"); + ps.setString(1, uuid.toString()); + ps.setString(2, currency); + ResultSet rs = ps.executeQuery(); + while (rs.next()) { + transactions.add(new Transaction(uuid, currency, rs.getDouble("amount"), rs.getLong("timestamp"))); } + } catch (SQLException e) { + plugin.getLogger().severe("[EzEconomy] MySQL getTransactions failed for " + uuid + " (" + currency + "): " + e.getMessage()); } return transactions; } - /** - * Gets the balance for a player and currency. - */ @Override public double getBalance(UUID uuid, String currency) { - com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); - if (lm != null) { - String token = null; - try { - token = lm.acquire(uuid, plugin.getConfig().getLong("redis.ttl-ms", 5000), plugin.getConfig().getLong("redis.retry-ms", 50), plugin.getConfig().getInt("redis.max-attempts", 100)); - if (token != null) { - try { - PreparedStatement ps = connection.prepareStatement("SELECT balance FROM `" + table + "` WHERE uuid=? AND currency=?"); - ps.setString(1, uuid.toString()); - ps.setString(2, currency); - ResultSet rs = ps.executeQuery(); - if (rs.next()) return rs.getDouble(1); - } catch (SQLException e) { - plugin.getLogger().severe("[EzEconomy] MySQL getBalance failed for " + uuid + " (" + currency + "): " + e.getMessage()); - } catch (Exception e) { - plugin.getLogger().severe("[EzEconomy] Unexpected error in getBalance for " + uuid + " (" + currency + "): " + e.getMessage()); - } - return 0.0; - } - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } finally { - if (token != null) lm.release(uuid, token); - } - } - synchronized (lock) { - try { - PreparedStatement ps = connection.prepareStatement("SELECT balance FROM `" + table + "` WHERE uuid=? AND currency=?"); - ps.setString(1, uuid.toString()); - ps.setString(2, currency); - ResultSet rs = ps.executeQuery(); - if (rs.next()) return rs.getDouble(1); - } catch (SQLException e) { - plugin.getLogger().severe("[EzEconomy] MySQL getBalance failed for " + uuid + " (" + currency + "): " + e.getMessage()); - } catch (Exception e) { - plugin.getLogger().severe("[EzEconomy] Unexpected error in getBalance for " + uuid + " (" + currency + "): " + e.getMessage()); - } - return 0.0; + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement("SELECT balance FROM `" + table + "` WHERE uuid=? AND currency=?"); + ps.setString(1, uuid.toString()); + ps.setString(2, currency); + ResultSet rs = ps.executeQuery(); + if (rs.next()) return rs.getDouble(1); + } catch (SQLException e) { + plugin.getLogger().severe("[EzEconomy] MySQL getBalance failed for " + uuid + " (" + currency + "): " + e.getMessage()); } + return 0.0; } @Override public com.skyblockexp.ezeconomy.dto.EconomyPlayer getPlayer(UUID uuid) { - com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); - if (lm != null) { - String token = null; - try { - token = lm.acquire(uuid, plugin.getConfig().getLong("redis.ttl-ms", 5000), plugin.getConfig().getLong("redis.retry-ms", 50), plugin.getConfig().getInt("redis.max-attempts", 100)); - if (token != null) { - try { - PreparedStatement ps = connection.prepareStatement("SELECT name, displayName FROM players WHERE uuid=?"); - ps.setString(1, uuid.toString()); - ResultSet rs = ps.executeQuery(); - if (rs.next()) { - String name = rs.getString(1); - String display = rs.getString(2); - if (name == null) name = uuid.toString(); - if (display == null) display = name; - return new com.skyblockexp.ezeconomy.dto.EconomyPlayer(uuid, name, display); - } - } catch (Exception ignored) {} - org.bukkit.OfflinePlayer of = org.bukkit.Bukkit.getOfflinePlayer(uuid); - String name = of != null && of.getName() != null ? of.getName() : uuid.toString(); - String display = (of instanceof org.bukkit.entity.Player) ? ((org.bukkit.entity.Player) of).getDisplayName() : name; - return new com.skyblockexp.ezeconomy.dto.EconomyPlayer(uuid, name, display); - } - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } finally { - if (token != null) lm.release(uuid, token); - } - } - synchronized (lock) { - try { - PreparedStatement ps = connection.prepareStatement("SELECT name, displayName FROM players WHERE uuid=?"); - ps.setString(1, uuid.toString()); - ResultSet rs = ps.executeQuery(); - if (rs.next()) { - String name = rs.getString(1); - String display = rs.getString(2); - if (name == null) name = uuid.toString(); - if (display == null) display = name; - return new com.skyblockexp.ezeconomy.dto.EconomyPlayer(uuid, name, display); - } - } catch (Exception ignored) {} - org.bukkit.OfflinePlayer of = org.bukkit.Bukkit.getOfflinePlayer(uuid); - String name = of != null && of.getName() != null ? of.getName() : uuid.toString(); - String display = (of instanceof org.bukkit.entity.Player) ? ((org.bukkit.entity.Player) of).getDisplayName() : name; - return new com.skyblockexp.ezeconomy.dto.EconomyPlayer(uuid, name, display); - } + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement("SELECT name, displayName FROM players WHERE uuid=?"); + ps.setString(1, uuid.toString()); + ResultSet rs = ps.executeQuery(); + if (rs.next()) { + String name = rs.getString(1); + String display = rs.getString(2); + if (name == null) name = uuid.toString(); + if (display == null) display = name; + return new com.skyblockexp.ezeconomy.dto.EconomyPlayer(uuid, name, display); + } + } catch (Exception ignored) {} + org.bukkit.OfflinePlayer of = org.bukkit.Bukkit.getOfflinePlayer(uuid); + String name = of != null && of.getName() != null ? of.getName() : uuid.toString(); + String display = (of instanceof org.bukkit.entity.Player) ? ((org.bukkit.entity.Player) of).getDisplayName() : name; + return new com.skyblockexp.ezeconomy.dto.EconomyPlayer(uuid, name, display); } @Override public boolean playerExists(UUID uuid) { - com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); - if (lm != null) { - String token = null; - try { - token = lm.acquire(uuid, plugin.getConfig().getLong("redis.ttl-ms", 5000), plugin.getConfig().getLong("redis.retry-ms", 50), plugin.getConfig().getInt("redis.max-attempts", 100)); - if (token != null) { - try { - PreparedStatement ps = connection.prepareStatement("SELECT 1 FROM `" + table + "` WHERE uuid=? LIMIT 1"); - ps.setString(1, uuid.toString()); - ResultSet rs = ps.executeQuery(); - return rs.next(); - } catch (SQLException e) { - plugin.getLogger().severe("[EzEconomy] MySQL playerExists failed for " + uuid + ": " + e.getMessage()); - return false; - } - } - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } finally { - if (token != null) lm.release(uuid, token); - } - } - synchronized (lock) { - try { - PreparedStatement ps = connection.prepareStatement("SELECT 1 FROM `" + table + "` WHERE uuid=? LIMIT 1"); - ps.setString(1, uuid.toString()); - ResultSet rs = ps.executeQuery(); - return rs.next(); - } catch (SQLException e) { - plugin.getLogger().severe("[EzEconomy] MySQL playerExists failed for " + uuid + ": " + e.getMessage()); - return false; - } + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement("SELECT 1 FROM `" + table + "` WHERE uuid=? LIMIT 1"); + ps.setString(1, uuid.toString()); + return ps.executeQuery().next(); + } catch (SQLException e) { + plugin.getLogger().severe("[EzEconomy] MySQL playerExists failed for " + uuid + ": " + e.getMessage()); + return false; } } @Override public void setBalance(UUID uuid, String currency, double amount) { - com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); - if (lm != null) { - String token = null; - try { - token = lm.acquire(uuid, plugin.getConfig().getLong("redis.ttl-ms", 5000), plugin.getConfig().getLong("redis.retry-ms", 50), plugin.getConfig().getInt("redis.max-attempts", 100)); - if (token != null) { - try { - PreparedStatement ps = connection.prepareStatement("REPLACE INTO `" + table + "` (uuid, currency, balance) VALUES (?, ?, ?)"); - ps.setString(1, uuid.toString()); - ps.setString(2, currency); - ps.setDouble(3, amount); - ps.executeUpdate(); - org.bukkit.OfflinePlayer of = org.bukkit.Bukkit.getOfflinePlayer(uuid); - String name = of != null && of.getName() != null ? of.getName() : uuid.toString(); - String display = (of instanceof org.bukkit.entity.Player) ? ((org.bukkit.entity.Player) of).getDisplayName() : name; - PreparedStatement ps2 = connection.prepareStatement("REPLACE INTO players (uuid, name, displayName) VALUES (?, ?, ?)"); - ps2.setString(1, uuid.toString()); - ps2.setString(2, name); - ps2.setString(3, display); - ps2.executeUpdate(); - return; - } catch (SQLException e) { - plugin.getLogger().severe("[EzEconomy] MySQL setBalance failed for " + uuid + " (" + currency + "): " + e.getMessage()); - return; - } catch (Exception e) { - plugin.getLogger().severe("[EzEconomy] Unexpected error in setBalance for " + uuid + " (" + currency + "): " + e.getMessage()); - return; - } - } - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } finally { - if (token != null) lm.release(uuid, token); - } - } - synchronized (lock) { - try { - PreparedStatement ps = connection.prepareStatement("REPLACE INTO `" + table + "` (uuid, currency, balance) VALUES (?, ?, ?)"); - ps.setString(1, uuid.toString()); - ps.setString(2, currency); - ps.setDouble(3, amount); - ps.executeUpdate(); - // Persist last known name/displayName - org.bukkit.OfflinePlayer of = org.bukkit.Bukkit.getOfflinePlayer(uuid); - String name = of != null && of.getName() != null ? of.getName() : uuid.toString(); - String display = (of instanceof org.bukkit.entity.Player) ? ((org.bukkit.entity.Player) of).getDisplayName() : name; - PreparedStatement ps2 = connection.prepareStatement("REPLACE INTO players (uuid, name, displayName) VALUES (?, ?, ?)"); - ps2.setString(1, uuid.toString()); - ps2.setString(2, name); - ps2.setString(3, display); - ps2.executeUpdate(); - } catch (SQLException e) { - plugin.getLogger().severe("[EzEconomy] MySQL setBalance failed for " + uuid + " (" + currency + "): " + e.getMessage()); - } catch (Exception e) { - plugin.getLogger().severe("[EzEconomy] Unexpected error in setBalance for " + uuid + " (" + currency + "): " + e.getMessage()); - } + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement("REPLACE INTO `" + table + "` (uuid, currency, balance) VALUES (?, ?, ?)"); + ps.setString(1, uuid.toString()); + ps.setString(2, currency); + ps.setDouble(3, amount); + ps.executeUpdate(); + safeUpdatePlayerInfo(conn, uuid); + } catch (SQLException e) { + plugin.getLogger().severe("[EzEconomy] MySQL setBalance failed for " + uuid + " (" + currency + "): " + e.getMessage()); } } @Override public boolean tryWithdraw(UUID uuid, String currency, double amount) { - com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); - if (lm != null) { - String token = null; - try { - token = lm.acquire(uuid, plugin.getConfig().getLong("redis.ttl-ms", 5000), plugin.getConfig().getLong("redis.retry-ms", 50), plugin.getConfig().getInt("redis.max-attempts", 100)); - if (token != null) { - try { - PreparedStatement ps = connection.prepareStatement( - "UPDATE `" + table + "` SET balance = balance - ? WHERE uuid=? AND currency=? AND balance >= ?" - ); - ps.setDouble(1, amount); - ps.setString(2, uuid.toString()); - ps.setString(3, currency); - ps.setDouble(4, amount); - return ps.executeUpdate() > 0; - } catch (SQLException e) { - plugin.getLogger().severe("[EzEconomy] MySQL tryWithdraw failed for " + uuid + " (" + currency + "): " + e.getMessage()); - return false; - } catch (Exception e) { - plugin.getLogger().severe("[EzEconomy] Unexpected error in tryWithdraw for " + uuid + " (" + currency + "): " + e.getMessage()); - return false; - } - } - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } finally { - if (token != null) lm.release(uuid, token); - } - } - synchronized (lock) { - try { - PreparedStatement ps = connection.prepareStatement( - "UPDATE `" + table + "` SET balance = balance - ? WHERE uuid=? AND currency=? AND balance >= ?" - ); - ps.setDouble(1, amount); - ps.setString(2, uuid.toString()); - ps.setString(3, currency); - ps.setDouble(4, amount); - return ps.executeUpdate() > 0; - } catch (SQLException e) { - plugin.getLogger().severe("[EzEconomy] MySQL tryWithdraw failed for " + uuid + " (" + currency + "): " + e.getMessage()); - } catch (Exception e) { - plugin.getLogger().severe("[EzEconomy] Unexpected error in tryWithdraw for " + uuid + " (" + currency + "): " + e.getMessage()); - } + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement( + "UPDATE `" + table + "` SET balance = balance - ? WHERE uuid=? AND currency=? AND balance >= ?"); + ps.setDouble(1, amount); + ps.setString(2, uuid.toString()); + ps.setString(3, currency); + ps.setDouble(4, amount); + return ps.executeUpdate() > 0; + } catch (SQLException e) { + plugin.getLogger().severe("[EzEconomy] MySQL tryWithdraw failed for " + uuid + " (" + currency + "): " + e.getMessage()); return false; } } @Override public void deposit(UUID uuid, String currency, double amount) { - com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); - if (lm != null) { - String token = null; - try { - token = lm.acquire(uuid, plugin.getConfig().getLong("redis.ttl-ms", 5000), plugin.getConfig().getLong("redis.retry-ms", 50), plugin.getConfig().getInt("redis.max-attempts", 100)); - if (token != null) { - try { - PreparedStatement ps = connection.prepareStatement( - "INSERT INTO `" + table + "` (uuid, currency, balance) VALUES (?, ?, ?) " + - "ON DUPLICATE KEY UPDATE balance = balance + VALUES(balance)" - ); - ps.setString(1, uuid.toString()); - ps.setString(2, currency); - ps.setDouble(3, amount); - ps.executeUpdate(); - org.bukkit.OfflinePlayer of = org.bukkit.Bukkit.getOfflinePlayer(uuid); - String name = of != null && of.getName() != null ? of.getName() : uuid.toString(); - String display = (of instanceof org.bukkit.entity.Player) ? ((org.bukkit.entity.Player) of).getDisplayName() : name; - PreparedStatement ps2 = connection.prepareStatement("REPLACE INTO players (uuid, name, displayName) VALUES (?, ?, ?)"); - ps2.setString(1, uuid.toString()); - ps2.setString(2, name); - ps2.setString(3, display); - ps2.executeUpdate(); - return; - } catch (SQLException e) { - plugin.getLogger().severe("[EzEconomy] MySQL deposit failed for " + uuid + " (" + currency + "): " + e.getMessage()); - return; - } catch (Exception e) { - plugin.getLogger().severe("[EzEconomy] Unexpected error in deposit for " + uuid + " (" + currency + "): " + e.getMessage()); - return; - } - } - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } finally { - if (token != null) lm.release(uuid, token); - } + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement( + "INSERT INTO `" + table + "` (uuid, currency, balance) VALUES (?, ?, ?) " + + "ON DUPLICATE KEY UPDATE balance = balance + VALUES(balance)"); + ps.setString(1, uuid.toString()); + ps.setString(2, currency); + ps.setDouble(3, amount); + ps.executeUpdate(); + safeUpdatePlayerInfo(conn, uuid); + } catch (SQLException e) { + plugin.getLogger().severe("[EzEconomy] MySQL deposit failed for " + uuid + " (" + currency + "): " + e.getMessage()); } - synchronized (lock) { - try { - PreparedStatement ps = connection.prepareStatement( - "INSERT INTO `" + table + "` (uuid, currency, balance) VALUES (?, ?, ?) " + - "ON DUPLICATE KEY UPDATE balance = balance + VALUES(balance)" - ); + } + + private void safeUpdatePlayerInfo(Connection conn, UUID uuid) { + try { + org.bukkit.entity.Player online = org.bukkit.Bukkit.getPlayer(uuid); + if (online != null) { + PreparedStatement ps = conn.prepareStatement( + "REPLACE INTO players (uuid, name, displayName) VALUES (?, ?, ?)"); ps.setString(1, uuid.toString()); - ps.setString(2, currency); - ps.setDouble(3, amount); + ps.setString(2, online.getName()); + ps.setString(3, online.getDisplayName()); ps.executeUpdate(); - // Persist last known name/displayName - org.bukkit.OfflinePlayer of = org.bukkit.Bukkit.getOfflinePlayer(uuid); - String name = of != null && of.getName() != null ? of.getName() : uuid.toString(); - String display = (of instanceof org.bukkit.entity.Player) ? ((org.bukkit.entity.Player) of).getDisplayName() : name; - PreparedStatement ps2 = connection.prepareStatement("REPLACE INTO players (uuid, name, displayName) VALUES (?, ?, ?)"); - ps2.setString(1, uuid.toString()); - ps2.setString(2, name); - ps2.setString(3, display); - ps2.executeUpdate(); - } catch (SQLException e) { - plugin.getLogger().severe("[EzEconomy] MySQL deposit failed for " + uuid + " (" + currency + "): " + e.getMessage()); - } catch (Exception e) { - plugin.getLogger().severe("[EzEconomy] Unexpected error in deposit for " + uuid + " (" + currency + "): " + e.getMessage()); } + } catch (SQLException e) { + plugin.getLogger().warning("[EzEconomy] safeUpdatePlayerInfo failed: " + e.getMessage()); } } public void shutdown() { - synchronized (lock) { - try { - if (connection != null && !connection.isClosed()) connection.close(); - } catch (SQLException e) { - plugin.getLogger().severe("[EzEconomy] MySQL shutdown failed: " + e.getMessage()); - } catch (Exception e) { - plugin.getLogger().severe("[EzEconomy] Unexpected error on shutdown: " + e.getMessage()); - } + if (dataSource != null && !dataSource.isClosed()) { + dataSource.close(); + plugin.getLogger().info("HikariCP connection pool shut down."); } } + public Map getAllBalances(String currency) { - synchronized (lock) { - Map map = new ConcurrentHashMap<>(); - try { - PreparedStatement ps = connection.prepareStatement("SELECT uuid, balance FROM `" + table + "` WHERE currency=?"); - ps.setString(1, currency); - ResultSet rs = ps.executeQuery(); - while (rs.next()) { - try { - UUID uuid = UUID.fromString(rs.getString(1)); - double bal = rs.getDouble(2); - map.put(uuid, bal); - } catch (IllegalArgumentException ignored) {} - } - } catch (SQLException e) { - plugin.getLogger().severe("[EzEconomy] MySQL getAllBalances failed (" + currency + "): " + e.getMessage()); + Map map = new ConcurrentHashMap<>(); + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement("SELECT uuid, balance FROM `" + table + "` WHERE currency=?"); + ps.setString(1, currency); + ResultSet rs = ps.executeQuery(); + while (rs.next()) { + try { + map.put(UUID.fromString(rs.getString(1)), rs.getDouble(2)); + } catch (IllegalArgumentException ignored) {} } - return map; + } catch (SQLException e) { + plugin.getLogger().severe("[EzEconomy] MySQL getAllBalances failed (" + currency + "): " + e.getMessage()); } + return map; } @Override public com.skyblockexp.ezeconomy.storage.TransferResult transfer(UUID fromUuid, UUID toUuid, String currency, double debitAmount, double creditAmount) { - com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); - if (lm == null) { - // No distributed lock available, fall back to default behavior - double fromBefore = getBalance(fromUuid, currency); - double toBefore = getBalance(toUuid, currency); - - com.skyblockexp.ezeconomy.api.events.PreTransactionEvent pre = new com.skyblockexp.ezeconomy.api.events.PreTransactionEvent(fromUuid, toUuid, java.math.BigDecimal.valueOf(debitAmount), com.skyblockexp.ezeconomy.api.events.TransactionType.TRANSFER); - try { - if (plugin.getServer().isPrimaryThread()) { - plugin.getServer().getPluginManager().callEvent(pre); - } else { - plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { - plugin.getServer().getPluginManager().callEvent(pre); - return null; - }).get(); - } - } catch (Exception e) { - plugin.getLogger().warning("[EzEconomy] Failed to fire PreTransactionEvent: " + e.getMessage()); - } - if (pre.isCancelled()) { - return com.skyblockexp.ezeconomy.storage.TransferResult.failure(fromBefore, toBefore); - } + double fromBefore = getBalance(fromUuid, currency); + double toBefore = getBalance(toUuid, currency); - com.skyblockexp.ezeconomy.storage.TransferResult result = StorageProvider.super.transfer(fromUuid, toUuid, currency, debitAmount, creditAmount); + firePreTransaction(fromUuid, toUuid, debitAmount); - com.skyblockexp.ezeconomy.api.events.PostTransactionEvent post = new com.skyblockexp.ezeconomy.api.events.PostTransactionEvent( - fromUuid, toUuid, java.math.BigDecimal.valueOf(debitAmount), com.skyblockexp.ezeconomy.api.events.TransactionType.TRANSFER, - result.isSuccess(), java.math.BigDecimal.valueOf(fromBefore), java.math.BigDecimal.valueOf(result.getFromBalance()), - java.math.BigDecimal.valueOf(toBefore), java.math.BigDecimal.valueOf(result.getToBalance()) - ); + try (Connection conn = getConn()) { + conn.setAutoCommit(false); try { - if (plugin.getServer().isPrimaryThread()) { - plugin.getServer().getPluginManager().callEvent(post); - } else { - plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { - plugin.getServer().getPluginManager().callEvent(post); - return null; - }).get(); - } - } catch (Exception e) { - plugin.getLogger().warning("[EzEconomy] Failed to fire PostTransactionEvent: " + e.getMessage()); - } - - return result; - } - - // Acquire distributed locks for both UUIDs in canonical order - UUID[] ordered = new UUID[]{fromUuid, toUuid}; - if (fromUuid.compareTo(toUuid) > 0) { - ordered = new UUID[]{toUuid, fromUuid}; - } - String[] tokens = null; - try { - tokens = lm.acquireOrdered(ordered, plugin.getConfig().getLong("redis.ttl-ms", 5000), plugin.getConfig().getLong("redis.retry-ms", 50), plugin.getConfig().getInt("redis.max-attempts", 100)); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } - if (tokens == null) { - // Couldn't acquire distributed locks; fall back to default transfer - return StorageProvider.super.transfer(fromUuid, toUuid, currency, debitAmount, creditAmount); - } - - try { - // Re-read balances while holding distributed locks - double fromBefore; - double toBefore; - try { - PreparedStatement ps = connection.prepareStatement("SELECT balance FROM `" + table + "` WHERE uuid=? AND currency=?"); - ps.setString(1, fromUuid.toString()); - ps.setString(2, currency); - ResultSet rs = ps.executeQuery(); - fromBefore = rs.next() ? rs.getDouble(1) : 0.0; - PreparedStatement ps2 = connection.prepareStatement("SELECT balance FROM `" + table + "` WHERE uuid=? AND currency=?"); - ps2.setString(1, toUuid.toString()); - ps2.setString(2, currency); - ResultSet rs2 = ps2.executeQuery(); - toBefore = rs2.next() ? rs2.getDouble(1) : 0.0; - } catch (SQLException e) { - plugin.getLogger().severe("[EzEconomy] MySQL transfer balance read failed: " + e.getMessage()); - return com.skyblockexp.ezeconomy.storage.TransferResult.failure(0.0, 0.0); - } - - com.skyblockexp.ezeconomy.api.events.PreTransactionEvent pre = new com.skyblockexp.ezeconomy.api.events.PreTransactionEvent(fromUuid, toUuid, java.math.BigDecimal.valueOf(debitAmount), com.skyblockexp.ezeconomy.api.events.TransactionType.TRANSFER); - try { - if (plugin.getServer().isPrimaryThread()) { - plugin.getServer().getPluginManager().callEvent(pre); - } else { - plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { - plugin.getServer().getPluginManager().callEvent(pre); - return null; - }).get(); - } - } catch (Exception e) { - plugin.getLogger().warning("[EzEconomy] Failed to fire PreTransactionEvent: " + e.getMessage()); - } - if (pre.isCancelled()) { - return com.skyblockexp.ezeconomy.storage.TransferResult.failure(fromBefore, toBefore); - } - - // Perform atomic withdraw - try { - PreparedStatement psw = connection.prepareStatement( - "UPDATE `" + table + "` SET balance = balance - ? WHERE uuid=? AND currency=? AND balance >= ?" - ); + PreparedStatement psw = conn.prepareStatement( + "UPDATE `" + table + "` SET balance = balance - ? WHERE uuid=? AND currency=? AND balance >= ?"); psw.setDouble(1, debitAmount); psw.setString(2, fromUuid.toString()); psw.setString(3, currency); psw.setDouble(4, debitAmount); - int updated = psw.executeUpdate(); - if (updated <= 0) { - double refreshedFrom = getBalance(fromUuid, currency); - double refreshedTo = getBalance(toUuid, currency); - return com.skyblockexp.ezeconomy.storage.TransferResult.failure(refreshedFrom, refreshedTo); - } - if (creditAmount > 0) { - PreparedStatement psd = connection.prepareStatement("INSERT INTO `" + table + "` (uuid, currency, balance) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE balance = balance + VALUES(balance)"); - psd.setString(1, toUuid.toString()); - psd.setString(2, currency); - psd.setDouble(3, creditAmount); - psd.executeUpdate(); - } - double updatedFrom = getBalance(fromUuid, currency); - double updatedTo = getBalance(toUuid, currency); - com.skyblockexp.ezeconomy.storage.TransferResult tr = com.skyblockexp.ezeconomy.storage.TransferResult.success(updatedFrom, updatedTo); - - com.skyblockexp.ezeconomy.api.events.PostTransactionEvent post = new com.skyblockexp.ezeconomy.api.events.PostTransactionEvent( - fromUuid, toUuid, java.math.BigDecimal.valueOf(debitAmount), com.skyblockexp.ezeconomy.api.events.TransactionType.TRANSFER, - tr.isSuccess(), java.math.BigDecimal.valueOf(fromBefore), java.math.BigDecimal.valueOf(tr.getFromBalance()), - java.math.BigDecimal.valueOf(toBefore), java.math.BigDecimal.valueOf(tr.getToBalance()) - ); - try { - if (plugin.getServer().isPrimaryThread()) { - plugin.getServer().getPluginManager().callEvent(post); - } else { - plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { - plugin.getServer().getPluginManager().callEvent(post); - return null; - }).get(); - } - } catch (Exception e) { - plugin.getLogger().warning("[EzEconomy] Failed to fire PostTransactionEvent: " + e.getMessage()); - } - - return tr; + if (psw.executeUpdate() <= 0) { + conn.rollback(); + return com.skyblockexp.ezeconomy.storage.TransferResult.failure(fromBefore, toBefore); + } + double credit = creditAmount > 0 ? creditAmount : debitAmount; + PreparedStatement psd = conn.prepareStatement( + "INSERT INTO `" + table + "` (uuid, currency, balance) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE balance = balance + VALUES(balance)"); + psd.setString(1, toUuid.toString()); + psd.setString(2, currency); + psd.setDouble(3, credit); + psd.executeUpdate(); + conn.commit(); } catch (SQLException e) { + conn.rollback(); plugin.getLogger().severe("[EzEconomy] MySQL transfer failed: " + e.getMessage()); return com.skyblockexp.ezeconomy.storage.TransferResult.failure(fromBefore, toBefore); + } finally { + conn.setAutoCommit(true); } - } finally { - lm.releaseOrdered(ordered, tokens); + } catch (SQLException e) { + plugin.getLogger().severe("[EzEconomy] MySQL transfer connection error: " + e.getMessage()); + return com.skyblockexp.ezeconomy.storage.TransferResult.failure(fromBefore, toBefore); } + + double updatedFrom = getBalance(fromUuid, currency); + double updatedTo = getBalance(toUuid, currency); + com.skyblockexp.ezeconomy.storage.TransferResult tr = com.skyblockexp.ezeconomy.storage.TransferResult.success(updatedFrom, updatedTo); + firePostTransaction(fromUuid, toUuid, debitAmount, tr, fromBefore, toBefore); + return tr; } - // --- Bank support --- - private void ensureBankTables() { + private void firePreTransaction(UUID from, UUID to, double amount) { + com.skyblockexp.ezeconomy.api.events.PreTransactionEvent pre = new com.skyblockexp.ezeconomy.api.events.PreTransactionEvent( + from, to, java.math.BigDecimal.valueOf(amount), com.skyblockexp.ezeconomy.api.events.TransactionType.TRANSFER); try { - Statement stmt = connection.createStatement(); - stmt.executeUpdate("CREATE TABLE IF NOT EXISTS banks (name VARCHAR(64), currency VARCHAR(32), balance DOUBLE, PRIMARY KEY (name, currency))"); - stmt.executeUpdate("CREATE TABLE IF NOT EXISTS bank_members (bank VARCHAR(64), uuid VARCHAR(36), owner BOOLEAN, PRIMARY KEY (bank, uuid))"); - } catch (SQLException e) { - plugin.getLogger().severe("[EzEconomy] MySQL ensureBankTables failed: " + e.getMessage()); + if (plugin.getServer().isPrimaryThread()) { + plugin.getServer().getPluginManager().callEvent(pre); + } else { + plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { + plugin.getServer().getPluginManager().callEvent(pre); + return null; + }).get(5, TimeUnit.SECONDS); + } + } catch (Exception e) { + plugin.getLogger().warning("[EzEconomy] Failed to fire PreTransactionEvent: " + e.getMessage()); } } - public boolean createBank(String name, UUID owner) { - com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); - UUID bankId = UUID.nameUUIDFromBytes(name.getBytes()); - if (lm != null) { - String token = null; - try { - token = lm.acquire(bankId, plugin.getConfig().getLong("redis.ttl-ms", 5000), plugin.getConfig().getLong("redis.retry-ms", 50), plugin.getConfig().getInt("redis.max-attempts", 100)); - if (token != null) { - ensureBankTables(); - try { - PreparedStatement ps = connection.prepareStatement("INSERT INTO banks (name, currency, balance) VALUES (?, ?, 0.0)"); - ps.setString(1, name); - ps.setString(2, "dollar"); // default currency - ps.executeUpdate(); - ps = connection.prepareStatement("INSERT INTO bank_members (bank, uuid, owner) VALUES (?, ?, ?)"); - ps.setString(1, name); - ps.setString(2, owner.toString()); - ps.setBoolean(3, true); - ps.executeUpdate(); - return true; - } catch (SQLException e) { - return false; - } - } - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } finally { - if (token != null) lm.release(bankId, token); - } - } - synchronized (lock) { - ensureBankTables(); - try { - PreparedStatement ps = connection.prepareStatement("INSERT INTO banks (name, currency, balance) VALUES (?, ?, 0.0)"); - ps.setString(1, name); - ps.setString(2, "dollar"); // default currency - ps.executeUpdate(); - ps = connection.prepareStatement("INSERT INTO bank_members (bank, uuid, owner) VALUES (?, ?, ?)"); - ps.setString(1, name); - ps.setString(2, owner.toString()); - ps.setBoolean(3, true); - ps.executeUpdate(); - return true; - } catch (SQLException e) { - return false; + private void firePostTransaction(UUID from, UUID to, double amount, com.skyblockexp.ezeconomy.storage.TransferResult tr, double fromBefore, double toBefore) { + com.skyblockexp.ezeconomy.api.events.PostTransactionEvent post = new com.skyblockexp.ezeconomy.api.events.PostTransactionEvent( + from, to, java.math.BigDecimal.valueOf(amount), com.skyblockexp.ezeconomy.api.events.TransactionType.TRANSFER, + tr.isSuccess(), java.math.BigDecimal.valueOf(fromBefore), java.math.BigDecimal.valueOf(tr.getFromBalance()), + java.math.BigDecimal.valueOf(toBefore), java.math.BigDecimal.valueOf(tr.getToBalance())); + try { + if (plugin.getServer().isPrimaryThread()) { + plugin.getServer().getPluginManager().callEvent(post); + } else { + plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { + plugin.getServer().getPluginManager().callEvent(post); + return null; + }).get(5, TimeUnit.SECONDS); } + } catch (Exception e) { + plugin.getLogger().warning("[EzEconomy] Failed to fire PostTransactionEvent: " + e.getMessage()); } } - public boolean deleteBank(String name) { - com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); - UUID bankId = UUID.nameUUIDFromBytes(name.getBytes()); - if (lm != null) { - String token = null; - try { - token = lm.acquire(bankId, plugin.getConfig().getLong("redis.ttl-ms", 5000), plugin.getConfig().getLong("redis.retry-ms", 50), plugin.getConfig().getInt("redis.max-attempts", 100)); - if (token != null) { - ensureBankTables(); - try { - PreparedStatement ps = connection.prepareStatement("DELETE FROM banks WHERE name=?"); - ps.setString(1, name); - int affected = ps.executeUpdate(); - ps = connection.prepareStatement("DELETE FROM bank_members WHERE bank=?"); - ps.setString(1, name); - ps.executeUpdate(); - return affected > 0; - } catch (SQLException e) { - return false; - } - } - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } finally { - if (token != null) lm.release(bankId, token); - } - } - synchronized (lock) { - ensureBankTables(); - try { - PreparedStatement ps = connection.prepareStatement("DELETE FROM banks WHERE name=?"); - ps.setString(1, name); - int affected = ps.executeUpdate(); - ps = connection.prepareStatement("DELETE FROM bank_members WHERE bank=?"); - ps.setString(1, name); - ps.executeUpdate(); - return affected > 0; - } catch (SQLException e) { - return false; - } + public void insertPendingNotification(UUID uuid, String message) { + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement( + "INSERT INTO pending_notifications (uuid, message, created_at) VALUES (?, ?, ?)"); + ps.setString(1, uuid.toString()); + ps.setString(2, message); + ps.setLong(3, System.currentTimeMillis()); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getLogger().warning("[EzEconomy] insertPendingNotification failed: " + e.getMessage()); } } - public boolean bankExists(String name) { - com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); - UUID bankId = UUID.nameUUIDFromBytes(name.getBytes()); - if (lm != null) { - String token = null; - try { - token = lm.acquire(bankId, plugin.getConfig().getLong("redis.ttl-ms", 5000), plugin.getConfig().getLong("redis.retry-ms", 50), plugin.getConfig().getInt("redis.max-attempts", 100)); - if (token != null) { - ensureBankTables(); - try { - PreparedStatement ps = connection.prepareStatement("SELECT name FROM banks WHERE name=?"); - ps.setString(1, name); - ResultSet rs = ps.executeQuery(); - return rs.next(); - } catch (SQLException e) { - return false; - } - } - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } finally { - if (token != null) lm.release(bankId, token); + public java.util.List pollPendingNotifications(UUID uuid) { + java.util.List messages = new java.util.ArrayList<>(); + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement( + "SELECT id, message FROM pending_notifications WHERE uuid=? ORDER BY created_at ASC"); + ps.setString(1, uuid.toString()); + ResultSet rs = ps.executeQuery(); + java.util.List ids = new java.util.ArrayList<>(); + while (rs.next()) { + ids.add(rs.getLong("id")); + messages.add(rs.getString("message")); } - } - synchronized (lock) { - ensureBankTables(); - try { - PreparedStatement ps = connection.prepareStatement("SELECT name FROM banks WHERE name=?"); - ps.setString(1, name); - ResultSet rs = ps.executeQuery(); - return rs.next(); - } catch (SQLException e) { - return false; + if (!ids.isEmpty()) { + StringBuilder sb = new StringBuilder("DELETE FROM pending_notifications WHERE id IN ("); + for (int i = 0; i < ids.size(); i++) { + if (i > 0) sb.append(","); + sb.append(ids.get(i)); + } + sb.append(")"); + conn.createStatement().executeUpdate(sb.toString()); } + } catch (SQLException e) { + plugin.getLogger().warning("[EzEconomy] pollPendingNotifications failed: " + e.getMessage()); } + return messages; } - public double getBankBalance(String name, String currency) { - com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); - UUID bankId = UUID.nameUUIDFromBytes(name.getBytes()); - if (lm != null) { - String token = null; - try { - token = lm.acquire(bankId, plugin.getConfig().getLong("redis.ttl-ms", 5000), plugin.getConfig().getLong("redis.retry-ms", 50), plugin.getConfig().getInt("redis.max-attempts", 100)); - if (token != null) { - ensureBankTables(); - try { - PreparedStatement ps = connection.prepareStatement("SELECT balance FROM banks WHERE name=? AND currency=?"); - ps.setString(1, name); - ps.setString(2, currency); - ResultSet rs = ps.executeQuery(); - if (rs.next()) return rs.getDouble(1); - } catch (SQLException e) {} - return 0.0; - } - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } finally { - if (token != null) lm.release(bankId, token); - } - } - synchronized (lock) { - ensureBankTables(); - try { - PreparedStatement ps = connection.prepareStatement("SELECT balance FROM banks WHERE name=? AND currency=?"); - ps.setString(1, name); - ps.setString(2, currency); - ResultSet rs = ps.executeQuery(); - if (rs.next()) return rs.getDouble(1); - } catch (SQLException e) {} - return 0.0; + public void cleanupOldNotifications(long maxAgeMs) { + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement("DELETE FROM pending_notifications WHERE created_at < ?"); + ps.setLong(1, System.currentTimeMillis() - maxAgeMs); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getLogger().warning("[EzEconomy] cleanupOldNotifications failed: " + e.getMessage()); } } - public void setBankBalance(String name, String currency, double amount) { - com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); - UUID bankId = UUID.nameUUIDFromBytes(name.getBytes()); - if (lm != null) { - String token = null; - try { - token = lm.acquire(bankId, plugin.getConfig().getLong("redis.ttl-ms", 5000), plugin.getConfig().getLong("redis.retry-ms", 50), plugin.getConfig().getInt("redis.max-attempts", 100)); - if (token != null) { - ensureBankTables(); - try { - PreparedStatement ps = connection.prepareStatement("REPLACE INTO banks (name, currency, balance) VALUES (?, ?, ?)"); - ps.setString(1, name); - ps.setString(2, currency); - ps.setDouble(3, amount); - ps.executeUpdate(); - return; - } catch (SQLException e) {} - } - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } finally { - if (token != null) lm.release(bankId, token); - } - } - synchronized (lock) { - ensureBankTables(); - try { - PreparedStatement ps = connection.prepareStatement("REPLACE INTO banks (name, currency, balance) VALUES (?, ?, ?)"); - ps.setString(1, name); - ps.setString(2, currency); - ps.setDouble(3, amount); - ps.executeUpdate(); - } catch (SQLException e) {} + @Override + public UUID resolvePlayerByName(String name) { + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement("SELECT uuid FROM players WHERE name=? LIMIT 1"); + ps.setString(1, name); + ResultSet rs = ps.executeQuery(); + if (rs.next()) return UUID.fromString(rs.getString(1)); + } catch (Exception e) { + plugin.getLogger().warning("[EzEconomy] resolvePlayerByName failed for " + name + ": " + e.getMessage()); } + return null; } @Override - public boolean tryWithdrawBank(String name, String currency, double amount) { - com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); - UUID bankId = UUID.nameUUIDFromBytes(name.getBytes()); - if (lm != null) { - String token = null; - try { - token = lm.acquire(bankId, plugin.getConfig().getLong("redis.ttl-ms", 5000), plugin.getConfig().getLong("redis.retry-ms", 50), plugin.getConfig().getInt("redis.max-attempts", 100)); - if (token != null) { - ensureBankTables(); - try { - PreparedStatement sel = connection.prepareStatement("SELECT balance FROM banks WHERE name=? AND currency=?"); - sel.setString(1, name); - sel.setString(2, currency); - ResultSet rs = sel.executeQuery(); - if (!rs.next()) return false; - double current = rs.getDouble(1); - - BankPreTransactionEvent pre = new BankPreTransactionEvent(name, null, BigDecimal.valueOf(amount), TransactionType.BANK_WITHDRAW); - if (plugin.getServer().isPrimaryThread()) { - plugin.getServer().getPluginManager().callEvent(pre); - } else { - try { - plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { - plugin.getServer().getPluginManager().callEvent(pre); - return null; - }).get(); - } catch (Exception e) { - plugin.getLogger().warning("[EzEconomy] Failed to fire BankPreTransactionEvent: " + e.getMessage()); - } - } - if (pre.isCancelled()) return false; - - PreparedStatement ps = connection.prepareStatement( - "UPDATE banks SET balance = balance - ? WHERE name=? AND currency=? AND balance >= ?" - ); - ps.setDouble(1, amount); - ps.setString(2, name); - ps.setString(3, currency); - ps.setDouble(4, amount); - boolean ok = ps.executeUpdate() > 0; - if (ok) { - BankPostTransactionEvent post = new BankPostTransactionEvent(name, null, BigDecimal.valueOf(amount), TransactionType.BANK_WITHDRAW, true, BigDecimal.valueOf(current), BigDecimal.valueOf(current - amount)); - try { - plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { - plugin.getServer().getPluginManager().callEvent(post); - return null; - }).get(); - } catch (Exception e) { - plugin.getLogger().warning("[EzEconomy] Failed to fire BankPostTransactionEvent: " + e.getMessage()); - } - } - return ok; - } catch (SQLException e) { - plugin.getLogger().severe("[EzEconomy] MySQL tryWithdrawBank failed: " + e.getMessage()); - return false; - } - } - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } finally { - if (token != null) lm.release(bankId, token); - } + public void persistPlayerInfo(UUID uuid, String name, String displayName) { + if (uuid == null || name == null) return; + if (displayName == null) displayName = name; + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement( + "REPLACE INTO players (uuid, name, displayName) VALUES (?, ?, ?)"); + ps.setString(1, uuid.toString()); + ps.setString(2, name); + ps.setString(3, displayName); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getLogger().warning("[EzEconomy] persistPlayerInfo failed: " + e.getMessage()); } - synchronized (lock) { - ensureBankTables(); - try { - PreparedStatement sel = connection.prepareStatement("SELECT balance FROM banks WHERE name=? AND currency=?"); - sel.setString(1, name); - sel.setString(2, currency); - ResultSet rs = sel.executeQuery(); - if (!rs.next()) return false; - double current = rs.getDouble(1); + } - boolean bankingEnabled = plugin.getConfig().getBoolean("banking.enabled", true); - if (bankingEnabled) { - BankPreTransactionEvent pre = new BankPreTransactionEvent(name, null, BigDecimal.valueOf(amount), TransactionType.BANK_WITHDRAW); - if (plugin.getServer().isPrimaryThread()) { - plugin.getServer().getPluginManager().callEvent(pre); - } else { - try { - plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { - plugin.getServer().getPluginManager().callEvent(pre); - return null; - }).get(); - } catch (Exception e) { - plugin.getLogger().warning("[EzEconomy] Failed to fire BankPreTransactionEvent: " + e.getMessage()); - } - } - if (pre.isCancelled()) return false; - } + // --- Bank support --- - PreparedStatement ps = connection.prepareStatement( - "UPDATE banks SET balance = balance - ? WHERE name=? AND currency=? AND balance >= ?" - ); - ps.setDouble(1, amount); - ps.setString(2, name); - ps.setString(3, currency); - ps.setDouble(4, amount); - boolean ok = ps.executeUpdate() > 0; - if (ok && bankingEnabled) { - BankPostTransactionEvent post = new BankPostTransactionEvent(name, null, BigDecimal.valueOf(amount), TransactionType.BANK_WITHDRAW, true, BigDecimal.valueOf(current), BigDecimal.valueOf(current - amount)); - try { - plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { - plugin.getServer().getPluginManager().callEvent(post); - return null; - }).get(); - } catch (Exception e) { - plugin.getLogger().warning("[EzEconomy] Failed to fire BankPostTransactionEvent: " + e.getMessage()); - } - } - return ok; - } catch (SQLException e) { - plugin.getLogger().severe("[EzEconomy] MySQL tryWithdrawBank failed: " + e.getMessage()); - return false; - } - } + public boolean createBank(String name, UUID owner) { + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement("INSERT INTO banks (name, currency, balance) VALUES (?, ?, 0.0)"); + ps.setString(1, name); + ps.setString(2, "dollar"); + ps.executeUpdate(); + ps = conn.prepareStatement("INSERT INTO bank_members (bank, uuid, owner) VALUES (?, ?, ?)"); + ps.setString(1, name); + ps.setString(2, owner.toString()); + ps.setBoolean(3, true); + ps.executeUpdate(); + return true; + } catch (SQLException e) { return false; } } - @Override - public void depositBank(String name, String currency, double amount) { - com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); - UUID bankId = UUID.nameUUIDFromBytes(name.getBytes()); - if (lm != null) { - String token = null; - try { - token = lm.acquire(bankId, plugin.getConfig().getLong("redis.ttl-ms", 5000), plugin.getConfig().getLong("redis.retry-ms", 50), plugin.getConfig().getInt("redis.max-attempts", 100)); - if (token != null) { - ensureBankTables(); - try { - PreparedStatement sel = connection.prepareStatement("SELECT balance FROM banks WHERE name=? AND currency=?"); - sel.setString(1, name); - sel.setString(2, currency); - ResultSet rs = sel.executeQuery(); - double before = 0.0; - if (rs.next()) before = rs.getDouble(1); - - BankPreTransactionEvent pre = new BankPreTransactionEvent(name, null, BigDecimal.valueOf(amount), TransactionType.BANK_DEPOSIT); - try { - plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { - plugin.getServer().getPluginManager().callEvent(pre); - return null; - }).get(); - } catch (Exception e) { - plugin.getLogger().warning("[EzEconomy] Failed to fire BankPreTransactionEvent: " + e.getMessage()); - } - if (pre.isCancelled()) return; + public boolean deleteBank(String name) { + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement("DELETE FROM banks WHERE name=?"); + ps.setString(1, name); + int affected = ps.executeUpdate(); + ps = conn.prepareStatement("DELETE FROM bank_members WHERE bank=?"); + ps.setString(1, name); + ps.executeUpdate(); + return affected > 0; + } catch (SQLException e) { return false; } + } - PreparedStatement ps = connection.prepareStatement( - "INSERT INTO banks (name, currency, balance) VALUES (?, ?, ?) " + - "ON DUPLICATE KEY UPDATE balance = balance + VALUES(balance)" - ); - ps.setString(1, name); - ps.setString(2, currency); - ps.setDouble(3, amount); - ps.executeUpdate(); + public boolean bankExists(String name) { + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement("SELECT name FROM banks WHERE name=?"); + ps.setString(1, name); + return ps.executeQuery().next(); + } catch (SQLException e) { return false; } + } - BankPostTransactionEvent post = new BankPostTransactionEvent(name, null, BigDecimal.valueOf(amount), TransactionType.BANK_DEPOSIT, true, BigDecimal.valueOf(before), BigDecimal.valueOf(before + amount)); - if (plugin.getServer().isPrimaryThread()) { - plugin.getServer().getPluginManager().callEvent(post); - } else { - try { - plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { - plugin.getServer().getPluginManager().callEvent(post); - return null; - }).get(); - } catch (Exception e) { - plugin.getLogger().warning("[EzEconomy] Failed to fire BankPostTransactionEvent: " + e.getMessage()); - } - } - return; - } catch (SQLException e) { - plugin.getLogger().severe("[EzEconomy] MySQL depositBank failed: " + e.getMessage()); - return; - } - } - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } finally { - if (token != null) lm.release(bankId, token); - } - } - synchronized (lock) { - ensureBankTables(); - try { - PreparedStatement sel = connection.prepareStatement("SELECT balance FROM banks WHERE name=? AND currency=?"); - sel.setString(1, name); - sel.setString(2, currency); - ResultSet rs = sel.executeQuery(); - double before = 0.0; - if (rs.next()) before = rs.getDouble(1); + public double getBankBalance(String name, String currency) { + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement("SELECT balance FROM banks WHERE name=? AND currency=?"); + ps.setString(1, name); + ps.setString(2, currency); + ResultSet rs = ps.executeQuery(); + if (rs.next()) return rs.getDouble(1); + } catch (SQLException e) {} + return 0.0; + } - boolean bankingEnabled = plugin.getConfig().getBoolean("banking.enabled", true); - if (bankingEnabled) { - BankPreTransactionEvent pre = new BankPreTransactionEvent(name, null, BigDecimal.valueOf(amount), TransactionType.BANK_DEPOSIT); - try { - plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { - plugin.getServer().getPluginManager().callEvent(pre); - return null; - }).get(); - } catch (Exception e) { - plugin.getLogger().warning("[EzEconomy] Failed to fire BankPreTransactionEvent: " + e.getMessage()); - } - if (pre.isCancelled()) return; - } + public void setBankBalance(String name, String currency, double amount) { + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement("REPLACE INTO banks (name, currency, balance) VALUES (?, ?, ?)"); + ps.setString(1, name); + ps.setString(2, currency); + ps.setDouble(3, amount); + ps.executeUpdate(); + } catch (SQLException e) {} + } - PreparedStatement ps = connection.prepareStatement( - "INSERT INTO banks (name, currency, balance) VALUES (?, ?, ?) " + - "ON DUPLICATE KEY UPDATE balance = balance + VALUES(balance)" - ); - ps.setString(1, name); - ps.setString(2, currency); - ps.setDouble(3, amount); - ps.executeUpdate(); + @Override + public boolean tryWithdrawBank(String name, String currency, double amount) { + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement( + "UPDATE banks SET balance = balance - ? WHERE name=? AND currency=? AND balance >= ?"); + ps.setDouble(1, amount); + ps.setString(2, name); + ps.setString(3, currency); + ps.setDouble(4, amount); + return ps.executeUpdate() > 0; + } catch (SQLException e) { + plugin.getLogger().severe("[EzEconomy] MySQL tryWithdrawBank failed: " + e.getMessage()); + return false; + } + } - if (bankingEnabled) { - BankPostTransactionEvent post = new BankPostTransactionEvent(name, null, BigDecimal.valueOf(amount), TransactionType.BANK_DEPOSIT, true, BigDecimal.valueOf(before), BigDecimal.valueOf(before + amount)); - if (plugin.getServer().isPrimaryThread()) { - plugin.getServer().getPluginManager().callEvent(post); - } else { - try { - plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { - plugin.getServer().getPluginManager().callEvent(post); - return null; - }).get(); - } catch (Exception e) { - plugin.getLogger().warning("[EzEconomy] Failed to fire BankPostTransactionEvent: " + e.getMessage()); - } - } - } - } catch (SQLException e) { - plugin.getLogger().severe("[EzEconomy] MySQL depositBank failed: " + e.getMessage()); - } + @Override + public void depositBank(String name, String currency, double amount) { + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement( + "INSERT INTO banks (name, currency, balance) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE balance = balance + VALUES(balance)"); + ps.setString(1, name); + ps.setString(2, currency); + ps.setDouble(3, amount); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getLogger().severe("[EzEconomy] MySQL depositBank failed: " + e.getMessage()); } } public Set getBanks() { - com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); - // bank list is global; use local lock for list operations to avoid distributed overhead - synchronized (lock) { - ensureBankTables(); - Set set = ConcurrentHashMap.newKeySet(); - try { - PreparedStatement ps = connection.prepareStatement("SELECT name FROM banks"); - ResultSet rs = ps.executeQuery(); - while (rs.next()) set.add(rs.getString(1)); - } catch (SQLException e) {} - return set; - } + Set set = ConcurrentHashMap.newKeySet(); + try (Connection conn = getConn()) { + ResultSet rs = conn.prepareStatement("SELECT DISTINCT name FROM banks").executeQuery(); + while (rs.next()) set.add(rs.getString(1)); + } catch (SQLException e) {} + return set; } public boolean isBankOwner(String name, UUID uuid) { - com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); - UUID bankId = UUID.nameUUIDFromBytes(name.getBytes()); - if (lm != null) { - String token = null; - try { - token = lm.acquire(bankId, plugin.getConfig().getLong("redis.ttl-ms", 5000), plugin.getConfig().getLong("redis.retry-ms", 50), plugin.getConfig().getInt("redis.max-attempts", 100)); - if (token != null) { - ensureBankTables(); - try { - PreparedStatement ps = connection.prepareStatement("SELECT owner FROM bank_members WHERE bank=? AND uuid=?"); - ps.setString(1, name); - ps.setString(2, uuid.toString()); - ResultSet rs = ps.executeQuery(); - if (rs.next()) return rs.getBoolean(1); - } catch (SQLException e) {} - return false; - } - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } finally { - if (token != null) lm.release(bankId, token); - } - } - synchronized (lock) { - ensureBankTables(); - try { - PreparedStatement ps = connection.prepareStatement("SELECT owner FROM bank_members WHERE bank=? AND uuid=?"); - ps.setString(1, name); - ps.setString(2, uuid.toString()); - ResultSet rs = ps.executeQuery(); - if (rs.next()) return rs.getBoolean(1); - } catch (SQLException e) {} - return false; - } + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement("SELECT owner FROM bank_members WHERE bank=? AND uuid=?"); + ps.setString(1, name); + ps.setString(2, uuid.toString()); + ResultSet rs = ps.executeQuery(); + if (rs.next()) return rs.getBoolean(1); + } catch (SQLException e) {} + return false; } public boolean isBankMember(String name, UUID uuid) { - com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); - UUID bankId = UUID.nameUUIDFromBytes(name.getBytes()); - if (lm != null) { - String token = null; - try { - token = lm.acquire(bankId, plugin.getConfig().getLong("redis.ttl-ms", 5000), plugin.getConfig().getLong("redis.retry-ms", 50), plugin.getConfig().getInt("redis.max-attempts", 100)); - if (token != null) { - ensureBankTables(); - try { - PreparedStatement ps = connection.prepareStatement("SELECT uuid FROM bank_members WHERE bank=? AND uuid=?"); - ps.setString(1, name); - ps.setString(2, uuid.toString()); - ResultSet rs = ps.executeQuery(); - return rs.next(); - } catch (SQLException e) {} - return false; - } - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } finally { - if (token != null) lm.release(bankId, token); - } - } - synchronized (lock) { - ensureBankTables(); - try { - PreparedStatement ps = connection.prepareStatement("SELECT uuid FROM bank_members WHERE bank=? AND uuid=?"); - ps.setString(1, name); - ps.setString(2, uuid.toString()); - ResultSet rs = ps.executeQuery(); - return rs.next(); - } catch (SQLException e) {} - return false; - } + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement("SELECT uuid FROM bank_members WHERE bank=? AND uuid=?"); + ps.setString(1, name); + ps.setString(2, uuid.toString()); + return ps.executeQuery().next(); + } catch (SQLException e) {} + return false; } public boolean addBankMember(String name, UUID uuid) { - com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); - UUID bankId = UUID.nameUUIDFromBytes(name.getBytes()); - if (lm != null) { - String token = null; - try { - token = lm.acquire(bankId, plugin.getConfig().getLong("redis.ttl-ms", 5000), plugin.getConfig().getLong("redis.retry-ms", 50), plugin.getConfig().getInt("redis.max-attempts", 100)); - if (token != null) { - ensureBankTables(); - if (isBankMember(name, uuid)) return false; - try { - PreparedStatement ps = connection.prepareStatement("INSERT INTO bank_members (bank, uuid, owner) VALUES (?, ?, ?)"); - ps.setString(1, name); - ps.setString(2, uuid.toString()); - ps.setBoolean(3, false); - ps.executeUpdate(); - return true; - } catch (SQLException e) { return false; } - } - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } finally { - if (token != null) lm.release(bankId, token); - } - } - synchronized (lock) { - ensureBankTables(); - if (isBankMember(name, uuid)) return false; - try { - PreparedStatement ps = connection.prepareStatement("INSERT INTO bank_members (bank, uuid, owner) VALUES (?, ?, ?)"); - ps.setString(1, name); - ps.setString(2, uuid.toString()); - ps.setBoolean(3, false); - ps.executeUpdate(); - return true; - } catch (SQLException e) { return false; } - } + if (isBankMember(name, uuid)) return false; + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement("INSERT INTO bank_members (bank, uuid, owner) VALUES (?, ?, ?)"); + ps.setString(1, name); + ps.setString(2, uuid.toString()); + ps.setBoolean(3, false); + ps.executeUpdate(); + return true; + } catch (SQLException e) { return false; } } public boolean removeBankMember(String name, UUID uuid) { - com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); - UUID bankId = UUID.nameUUIDFromBytes(name.getBytes()); - if (lm != null) { - String token = null; - try { - token = lm.acquire(bankId, plugin.getConfig().getLong("redis.ttl-ms", 5000), plugin.getConfig().getLong("redis.retry-ms", 50), plugin.getConfig().getInt("redis.max-attempts", 100)); - if (token != null) { - ensureBankTables(); - try { - PreparedStatement ps = connection.prepareStatement("DELETE FROM bank_members WHERE bank=? AND uuid=?"); - ps.setString(1, name); - ps.setString(2, uuid.toString()); - int affected = ps.executeUpdate(); - return affected > 0; - } catch (SQLException e) { return false; } - } - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } finally { - if (token != null) lm.release(bankId, token); - } - } - synchronized (lock) { - ensureBankTables(); - try { - PreparedStatement ps = connection.prepareStatement("DELETE FROM bank_members WHERE bank=? AND uuid=?"); - ps.setString(1, name); - ps.setString(2, uuid.toString()); - int affected = ps.executeUpdate(); - return affected > 0; - } catch (SQLException e) { return false; } - } + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement("DELETE FROM bank_members WHERE bank=? AND uuid=?"); + ps.setString(1, name); + ps.setString(2, uuid.toString()); + return ps.executeUpdate() > 0; + } catch (SQLException e) { return false; } } public Set getBankMembers(String name) { - com.skyblockexp.ezeconomy.lock.LockManager lm = plugin.getLockManager(); - UUID bankId = UUID.nameUUIDFromBytes(name.getBytes()); - if (lm != null) { - String token = null; - try { - token = lm.acquire(bankId, plugin.getConfig().getLong("redis.ttl-ms", 5000), plugin.getConfig().getLong("redis.retry-ms", 50), plugin.getConfig().getInt("redis.max-attempts", 100)); - if (token != null) { - ensureBankTables(); - Set set = ConcurrentHashMap.newKeySet(); - try { - PreparedStatement ps = connection.prepareStatement("SELECT uuid FROM bank_members WHERE bank=?"); - ps.setString(1, name); - ResultSet rs = ps.executeQuery(); - while (rs.next()) { - try { set.add(UUID.fromString(rs.getString(1))); } catch (IllegalArgumentException ignored) {} - } - } catch (SQLException e) {} - return set; - } - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } finally { - if (token != null) lm.release(bankId, token); + Set set = ConcurrentHashMap.newKeySet(); + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement("SELECT uuid FROM bank_members WHERE bank=?"); + ps.setString(1, name); + ResultSet rs = ps.executeQuery(); + while (rs.next()) { + try { set.add(UUID.fromString(rs.getString(1))); } catch (IllegalArgumentException ignored) {} } - } - synchronized (lock) { - ensureBankTables(); - Set set = ConcurrentHashMap.newKeySet(); - try { - PreparedStatement ps = connection.prepareStatement("SELECT uuid FROM bank_members WHERE bank=?"); - ps.setString(1, name); - ResultSet rs = ps.executeQuery(); - while (rs.next()) { - try { set.add(UUID.fromString(rs.getString(1))); } catch (IllegalArgumentException ignored) {} - } - } catch (SQLException e) {} - return set; - } + } catch (SQLException e) {} + return set; } @Override public void logTransaction(com.skyblockexp.ezeconomy.api.storage.models.Transaction tx) { - synchronized (lock) { - try { - String sql = "INSERT INTO transactions (uuid, currency, amount, timestamp) VALUES (?, ?, ?, ?)"; - PreparedStatement ps = connection.prepareStatement(sql); - ps.setString(1, tx.getUuid().toString()); - ps.setString(2, tx.getCurrency()); - ps.setDouble(3, tx.getAmount()); - ps.setLong(4, tx.getTimestamp()); - ps.executeUpdate(); - } catch (SQLException e) { - plugin.getLogger().severe("[EzEconomy] MySQL logTransaction failed: " + e.getMessage()); - } + try (Connection conn = getConn()) { + PreparedStatement ps = conn.prepareStatement("INSERT INTO transactions (uuid, currency, amount, timestamp) VALUES (?, ?, ?, ?)"); + ps.setString(1, tx.getUuid().toString()); + ps.setString(2, tx.getCurrency()); + ps.setDouble(3, tx.getAmount()); + ps.setLong(4, tx.getTimestamp()); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getLogger().severe("[EzEconomy] MySQL logTransaction failed: " + e.getMessage()); } } - /** - * Removes balances for UUIDs that do not resolve to a known player. - * @return Set of removed UUIDs as strings - */ public java.util.Set cleanupOrphanedPlayers() { java.util.Set removed = new java.util.HashSet<>(); - synchronized (lock) { - try { - PreparedStatement ps = connection.prepareStatement("SELECT uuid FROM `" + table + "`"); - ResultSet rs = ps.executeQuery(); - while (rs.next()) { - String uuidStr = rs.getString(1); - try { - java.util.UUID uuid = java.util.UUID.fromString(uuidStr); - org.bukkit.OfflinePlayer player = org.bukkit.Bukkit.getOfflinePlayer(uuid); - if (player == null || player.getName() == null) { - PreparedStatement del = connection.prepareStatement("DELETE FROM `" + table + "` WHERE uuid=?"); - del.setString(1, uuidStr); - del.executeUpdate(); - removed.add(uuidStr); - } - } catch (IllegalArgumentException ignored) {} - } - } catch (SQLException e) { - plugin.getLogger().severe("[EzEconomy] MySQL cleanupOrphanedPlayers failed: " + e.getMessage()); + try (Connection conn = getConn()) { + ResultSet rs = conn.prepareStatement("SELECT uuid FROM `" + table + "`").executeQuery(); + while (rs.next()) { + String uuidStr = rs.getString(1); + try { + org.bukkit.OfflinePlayer player = org.bukkit.Bukkit.getOfflinePlayer(java.util.UUID.fromString(uuidStr)); + if (player == null || player.getName() == null) { + PreparedStatement del = conn.prepareStatement("DELETE FROM `" + table + "` WHERE uuid=?"); + del.setString(1, uuidStr); + del.executeUpdate(); + removed.add(uuidStr); + } + } catch (IllegalArgumentException ignored) {} } + } catch (SQLException e) { + plugin.getLogger().severe("[EzEconomy] MySQL cleanupOrphanedPlayers failed: " + e.getMessage()); } return removed; } - /** - * Returns the set of orphaned UUIDs that would be deleted by cleanup. - */ public java.util.Set previewOrphanedPlayers() { java.util.Set orphaned = new java.util.HashSet<>(); - synchronized (lock) { - try { - PreparedStatement ps = connection.prepareStatement("SELECT uuid FROM `" + table + "`"); - ResultSet rs = ps.executeQuery(); - while (rs.next()) { - String uuidStr = rs.getString(1); - try { - java.util.UUID uuid = java.util.UUID.fromString(uuidStr); - org.bukkit.OfflinePlayer player = org.bukkit.Bukkit.getOfflinePlayer(uuid); - if (player == null || player.getName() == null) { - orphaned.add(uuidStr); - } - } catch (IllegalArgumentException ignored) {} - } - } catch (SQLException e) { - plugin.getLogger().severe("[EzEconomy] MySQL previewOrphanedPlayers failed: " + e.getMessage()); + try (Connection conn = getConn()) { + ResultSet rs = conn.prepareStatement("SELECT uuid FROM `" + table + "`").executeQuery(); + while (rs.next()) { + String uuidStr = rs.getString(1); + try { + org.bukkit.OfflinePlayer player = org.bukkit.Bukkit.getOfflinePlayer(java.util.UUID.fromString(uuidStr)); + if (player == null || player.getName() == null) orphaned.add(uuidStr); + } catch (IllegalArgumentException ignored) {} } + } catch (SQLException e) { + plugin.getLogger().severe("[EzEconomy] MySQL previewOrphanedPlayers failed: " + e.getMessage()); } return orphaned; } diff --git a/src/main/java/com/skyblockexp/ezeconomy/tabcomplete/BalanceTabCompleter.java b/src/main/java/com/skyblockexp/ezeconomy/tabcomplete/BalanceTabCompleter.java index 03cf721..265f897 100644 --- a/src/main/java/com/skyblockexp/ezeconomy/tabcomplete/BalanceTabCompleter.java +++ b/src/main/java/com/skyblockexp/ezeconomy/tabcomplete/BalanceTabCompleter.java @@ -26,11 +26,15 @@ public List onTabComplete(CommandSender sender, Command command, String if (args.length == 1) { String partial = args[0].toLowerCase(); List res = new ArrayList<>(); - // suggest online players - res.addAll(Bukkit.getOnlinePlayers().stream() - .map(OfflinePlayer::getName) - .filter(n -> n != null && n.toLowerCase().startsWith(partial)) - .collect(Collectors.toList())); + Bukkit.getOnlinePlayers().forEach(p -> { + if (p.getName() != null && p.getName().toLowerCase().startsWith(partial)) res.add(p.getName()); + }); + var messenger = plugin.getCrossServerMessenger(); + if (messenger != null) { + messenger.getNetworkPlayers().stream() + .filter(n -> n.toLowerCase().startsWith(partial)) + .forEach(res::add); + } // suggest currencies var cfg = plugin.getConfig(); if (cfg.isConfigurationSection("multi-currency.currencies")) { diff --git a/src/main/java/com/skyblockexp/ezeconomy/tabcomplete/EcoTabCompleter.java b/src/main/java/com/skyblockexp/ezeconomy/tabcomplete/EcoTabCompleter.java index d2b0edb..0a4734f 100644 --- a/src/main/java/com/skyblockexp/ezeconomy/tabcomplete/EcoTabCompleter.java +++ b/src/main/java/com/skyblockexp/ezeconomy/tabcomplete/EcoTabCompleter.java @@ -27,7 +27,16 @@ public List onTabComplete(CommandSender sender, Command command, String .collect(Collectors.toList()); } if (args.length == 2) { - return com.skyblockexp.ezeconomy.util.PlayerLookup.namesStartingWith(args[1]); + java.util.Set names = new java.util.LinkedHashSet<>( + com.skyblockexp.ezeconomy.util.PlayerLookup.namesStartingWith(args[1])); + var messenger = plugin.getCrossServerMessenger(); + if (messenger != null) { + String p = args[1].toLowerCase(); + messenger.getNetworkPlayers().stream() + .filter(n -> n.toLowerCase().startsWith(p)) + .forEach(names::add); + } + return new ArrayList<>(names); } // suggest currency at position 4: /eco give [currency] if (args.length == 4) { diff --git a/src/main/java/com/skyblockexp/ezeconomy/tabcomplete/PayTabCompleter.java b/src/main/java/com/skyblockexp/ezeconomy/tabcomplete/PayTabCompleter.java index 8d46218..ededcfc 100644 --- a/src/main/java/com/skyblockexp/ezeconomy/tabcomplete/PayTabCompleter.java +++ b/src/main/java/com/skyblockexp/ezeconomy/tabcomplete/PayTabCompleter.java @@ -1,7 +1,6 @@ package com.skyblockexp.ezeconomy.tabcomplete; import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; import org.bukkit.command.Command; import org.bukkit.command.CommandSender; import org.bukkit.command.TabCompleter; @@ -28,9 +27,18 @@ public List onTabComplete(CommandSender sender, Command command, String .filter(s -> s.startsWith(partial)) .collect(Collectors.toList()); } - return Bukkit.getOnlinePlayers().stream() - .map(OfflinePlayer::getName) - .filter(name -> name != null && name.toLowerCase().startsWith(partial)) + Set names = new LinkedHashSet<>(); + Bukkit.getOnlinePlayers().forEach(p -> { + if (p.getName() != null) { + names.add(p.getName()); + } + }); + var messenger = plugin.getCrossServerMessenger(); + if (messenger != null) { + names.addAll(messenger.getNetworkPlayers()); + } + return names.stream() + .filter(name -> name.toLowerCase().startsWith(partial)) .collect(Collectors.toList()); } if (args.length == 2) { diff --git a/src/main/java/com/skyblockexp/ezeconomy/util/NumberUtil.java b/src/main/java/com/skyblockexp/ezeconomy/util/NumberUtil.java index c269ab1..394318b 100644 --- a/src/main/java/com/skyblockexp/ezeconomy/util/NumberUtil.java +++ b/src/main/java/com/skyblockexp/ezeconomy/util/NumberUtil.java @@ -103,16 +103,16 @@ public static String formatShort(java.math.BigDecimal value, int decimals) { java.math.BigDecimal display; if (abs.compareTo(trillion) >= 0) { display = value.divide(trillion, decimals, RoundingMode.HALF_UP); - suffix = "t"; + suffix = "T"; } else if (abs.compareTo(billion) >= 0) { display = value.divide(billion, decimals, RoundingMode.HALF_UP); - suffix = "b"; + suffix = "B"; } else if (abs.compareTo(million) >= 0) { display = value.divide(million, decimals, RoundingMode.HALF_UP); - suffix = "m"; + suffix = "M"; } else if (abs.compareTo(thousand) >= 0) { display = value.divide(thousand, decimals, RoundingMode.HALF_UP); - suffix = "k"; + suffix = "K"; } else { // small values: return plain integer/decimal string without suffix java.math.BigDecimal stripped = value.stripTrailingZeros(); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 0bb166b..c0ce64f 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -17,12 +17,26 @@ daily-reward: sound: ENTITY_EXPERIENCE_ORB_PICKUP debug: false -# When enabled, the storage provider will be asked to create/store a record -# for each player when they join the server. This helps ensure player data -# files/rows exist for admin reporting tools. Default: false +# When enabled, the storage provider will create/store a record (UUID + name) +# for each player when they join. Required for Velocity networks so that +# cross-server commands (/bal, /pay) can resolve players on other backends. +# Default: false (safe to leave off on single-server setups) store-on-join: enabled: false +# Cross-server messaging via the ezeconomy-velocity proxy plugin. +# Requires: ezeconomy-velocity-*.jar on the Velocity proxy, storage: mysql +# with all backends sharing the same database, and store-on-join.enabled: true. +# Leave disabled (false) for single-server setups. +cross-server: + enabled: false + # Set to true only while troubleshooting cross-server messaging traffic. + verbose-logging: false + +# Timeout used when async contexts must execute a sync payment event. +payment: + sync-event-timeout-ms: 5000 + # Banking feature toggle: set to `false` to disable built-in bank subsystem # (commands, GUIs, Vault bank methods, and bank PAPI placeholders). banking: @@ -63,6 +77,10 @@ multi-currency: # REDIS: uses a distributed Redis lock (requires ezeconomy-redis extension and redis.yml). # BUNGEECORD: use a proxy-backed lock transport via plugin messaging (requires bungeecord proxy component and bungeecord.yml). locking-strategy: LOCAL +locking: + ttl-ms: 5000 + retry-ms: 50 + max-attempts: 100 # Caching strategy used for in-memory / distributed caching. Options: # LOCAL - in-process memory cache only (default) diff --git a/src/main/resources/languages/en.yml b/src/main/resources/languages/en.yml index 6186084..40bfdbc 100644 --- a/src/main/resources/languages/en.yml +++ b/src/main/resources/languages/en.yml @@ -1,66 +1,74 @@ only_players: "&cThis command can only be used by players." no_permission: "&cYou do not have permission to use this command." -usage_balance: "&eUsage: /balance [player] [currency]" -usage_eco: "&eUsage: /eco [currency]" -usage_ezeconomy: "&eUsage: /ezeconomy " -usage_daily_reset: "&eUsage: /ezeconomy daily reset " -usage_pay: "&eUsage: /pay [currency]" -usage_bank: "&eUsage: /bank ..." -usage_currency: "&eUsage: /currency convert " -usage_bank_deposit: "&eUsage: /bank deposit [currency]" -usage_bank_withdraw: "&eUsage: /bank withdraw [currency]" +usage_balance: "&7Usage: &f/balance [player] [currency]" +usage_eco: "&7Usage: &f/eco [currency]" +usage_ezeconomy: "&7Usage: &f/ezeconomy " +usage_daily_reset: "&7Usage: &f/ezeconomy daily reset " +usage_pay: "&7Usage: &f/pay [currency]" +usage_bank: "&7Usage: &f/bank ..." +usage_currency: "&7Usage: &f/currency convert " +usage_bank_deposit: "&7Usage: &f/bank deposit [currency]" +usage_bank_withdraw: "&7Usage: &f/bank withdraw [currency]" invalid_amount: "&cInvalid amount." not_enough_money: "&cYou do not have enough money." -paid: "&aYou paid {player} {amount}." -received: "&aYou received {amount} from {player}." -paid_all_summary: "&aPaid {count} players {amount} each (total {total})." -paid_other_currency: "&aYou paid {player} {amount} (≈ {amount_default})." -received_other_currency: "&aYou received {amount} from {player} (≈ {amount_default})." +paid: "&6You sent &e{amount} &6to &e{player}&6." +received: "&aYou received &e{amount} &afrom &e{player}&a." +paid_other_currency: "&6You sent &e{amount} &7(≈ &e{amount_default}&7) &6to &e{player}&6." +received_other_currency: "&aYou received &e{amount} &7(≈ &e{amount_default}&7) &afrom &e{player}&a." +paid_all_summary: "&6You paid &e{count} &6players &e{amount} &6each &7(total &e{total}&7)&6." player_not_found: "&cPlayer not found." cannot_pay_self: "&cYou cannot pay yourself." preferred_currency: "&aYour preferred currency: &e{currency}" available_currencies: "&aAvailable currencies:" set_currency: "&aPreferred currency set to &e{currency}" -unknown_currency: "&cUnknown currency: {currency}" -use_currency: "&7Use /currency to set your preference." -top_balances: "&6Top {top} balances:" -top_balances_page: "&6Top balances - Page {page}/{total_pages} (Page size: {page_size})" -baltop_invalid_page: "&cInvalid page {page}. Please choose a page between 1 and {total_pages}." -rank_balance: "&e#{rank}: {player} - {balance}" -unknown_player: "Unknown Player" -your_balance: "&aYour balance: {balance}" -others_balance: "&a{player}'s balance: {balance}" +unknown_currency: "&cUnknown currency: &7{currency}" +use_currency: "&7Use &f/currency &7to set your preference." +top_balances: "&8&m──────────────\n&6&l✦ &e&lBalance Top &8- &7Top &f{top}\n&8&m──────────────" +top_balances_page: "&8&m──────────────\n&6&l✦ &e&lBalance Top &8- &7Page &f{page}&7/&f{total_pages}\n&8&m──────────────" +baltop_invalid_page: "&cInvalid page &7{page}&c. Choose between &71 &cand &7{total_pages}&c." +baltop_footer: "&8&m──────────────" +rank_balance: " &6#{rank} &f{player} &8- &e{balance}" +unknown_player: "&7Unknown Player" +your_balance: "&aYour balance: &e{balance}" +others_balance: "&e{player}&a's balance: &e{balance}" no_permission_others_balance: "&cYou do not have permission to view others' balances." -bank_created: "&aBank '{name}' created." -bank_deleted: "&aBank '{name}' deleted." -bank_balance: "&aBank '{name}' balance: {balance}" -deposited: "&aDeposited {amount} to '{name}'." -withdrew: "&aWithdrew {amount} from '{name}'." -added_member: "&aAdded member {player} to bank '{name}'." -removed_member: "&aRemoved member {player} from bank '{name}'." -bank_info: "&aBank: {name}\nBalance: {balance}\nMembers: {members}" +bank_created: "&aBank &e'{name}' &acreated." +bank_deleted: "&cBank &e'{name}' &cdeleted." +bank_balance: "&aBank &e'{name}' &abalance: &e{balance}" +deposited: "&6Deposited &e{amount} &6to &e'{name}'&6." +withdrew: "&6Withdrew &e{amount} &6from &e'{name}'&6." +added_member: "&aAdded &e{player} &ato bank &e'{name}'&a." +removed_member: "&cRemoved &e{player} &cfrom bank &e'{name}'&c." +bank_info: "&8&m──────────────\n&6&lBank: &e{name}\n&7Balance: &e{balance}\n&7Members: &f{members}\n&8&m──────────────" unknown_subcommand: "&cUnknown subcommand." -unknown_action: "&cUnknown action. Use give, take, or set." +unknown_action: "&cUnknown action. Use &7give&c, &7take&c, or &7set&c." must_be_positive: "&cAmount must be positive." -tax_header: "&e--- EzEconomy Tax Totals ---" -tax_transaction: "&6Transaction Tax: &f{amount}" -tax_interest: "&6Interest Tax: &f{amount}" -tax_exchange: "&6Exchange Tax: &f{amount}" -tax_total: "&6Total Collected: &f{amount}" +tax_header: "&8&m──────────────\n&6&l✦ &e&lTax Totals\n&8&m──────────────" +tax_transaction: " &7Transaction Tax: &e{amount}" +tax_interest: " &7Interest Tax: &e{amount}" +tax_exchange: " &7Exchange Tax: &e{amount}" +tax_total: " &6Total Collected: &e{amount}" tax_reset: "&aAll tax totals have been reset." -set: "&aSet {player}'s balance to {balance}." -tax_usage: "&7Use /tax reset to clear all totals." -daily_reset: "&aDaily reward cooldown reset for {player}." +eco_give: "&aGave &e{amount} &ato &e{player}&a." +eco_take: "&cTook &e{amount} &cfrom &e{player}&c." +set: "&aSet &e{player}&a's balance to &e{balance}&a." +tax_usage: "&7Use &f/tax reset &7to clear all totals." +daily_reset: "&aDaily reward cooldown reset for &e{player}&a." storage_unavailable: "&cStorage provider unavailable. Check server logs." multi_currency_disabled: "&cMulti-currency is not enabled on this server." -cleanup_usage: "&eThis will remove orphaned UUIDs (player files with no known player) from storage. Type /ezeconomy cleanup confirm to proceed." -cleanup_preview_empty: "&aNo orphaned player entries found. Nothing will be deleted." -cleanup_preview: "&eThe following orphaned player entries will be deleted if you confirm: {entries}" -cleanup_confirm: "&eType /ezeconomy cleanup confirm to proceed." +cleanup_usage: "&eThis will remove orphaned UUIDs from storage.\n&7Type &f/ezeconomy cleanup confirm &7to proceed." +cleanup_preview_empty: "&aNo orphaned player entries found." +cleanup_preview: "&eThe following entries will be deleted if you confirm:\n&7{entries}" +cleanup_confirm: "&7Type &f/ezeconomy cleanup confirm &7to proceed." cleanup_complete_empty: "&aNo orphaned player entries found." -cleanup_complete: "&aRemoved orphaned player entries: {entries}" -daily_reward_success: "&aYou received {amount} in {currency} as daily reward!" +cleanup_complete: "&aRemoved orphaned player entries: &7{entries}" +daily_reward_success: "&aYou received &e{amount} &ain &e{currency} &aas a daily reward!" reload_messages_success: "&aMessages reloaded successfully." reload_success: "&aAll configurations reloaded successfully." price_message_format: "{amount} {symbol}" -conversion_too_small: "&cConversion would round to 0 in the target currency. Increase the amount or use a currency with more decimals." \ No newline at end of file +conversion_too_small: "&cConversion would round to 0. Increase the amount or use a currency with more decimals." +unknown_conversion: "&cUnable to convert between those currencies." +no_pending_payment: "&cYou have no pending payment to confirm." +payment_confirm_required: "&eYou are about to pay &6{amount}&e. Type &f/pay confirm &ewithin &f{timeout}s &eto proceed." +payment_cancelled: "&cPayment cancelled." +usage_bank_create: "&7Usage: &f/bank create " \ No newline at end of file diff --git a/src/main/resources/languages/es.yml b/src/main/resources/languages/es.yml index 561d76e..acd43f4 100644 --- a/src/main/resources/languages/es.yml +++ b/src/main/resources/languages/es.yml @@ -7,6 +7,8 @@ usage_ezeconomy: "&eUso: /ezeconomy " usage_daily_reset: "&eUso: /ezeconomy daily reset " usage_pay: "&eUso: /pay [moneda]" usage_bank: "&eUso: /bank ..." +usage_bank_deposit: "&eUso: &f/bank deposit [moneda]" +usage_bank_withdraw: "&eUso: &f/bank withdraw [moneda]" usage_currency: "&eUso: /currency convert " invalid_amount: "&cCantidad inválida." not_enough_money: "&cNo tienes suficiente dinero." @@ -25,6 +27,7 @@ use_currency: "&7Usa /currency para establecer tu preferencia." top_balances: "&6Top {top} balances:" top_balances_page: "&6Top balances - Página {page}/{total_pages} (Tamaño de página: {page_size})" baltop_invalid_page: "&cPágina inválida {page}. Elige una página entre 1 y {total_pages}." +baltop_footer: "&8&m──────────────" rank_balance: "&e#{rank}: {player} - {balance}" unknown_player: "Jugador desconocido" your_balance: "&aTu saldo: {balance}" @@ -47,6 +50,9 @@ tax_interest: "&6Impuesto de Interés: &f{amount}" tax_exchange: "&6Impuesto de Cambio: &f{amount}" tax_total: "&6Total Recaudado: &f{amount}" tax_reset: "&aTodos los totales de impuestos han sido reiniciados." +eco_give: "&aSe dio &e{amount} &aa &e{player}&a." +eco_take: "&cSe quitó &e{amount} &ca &e{player}&c." +tax_usage: "&7Usa &f/tax reset &7para reiniciar todos los totales." daily_reset: "&aReinicio del enfriamiento de recompensa diaria para {player}." storage_unavailable: "&cProveedor de almacenamiento no disponible. Verifique los registros del servidor." multi_currency_disabled: "&cLa multi-moneda no está habilitada en este servidor." @@ -60,4 +66,9 @@ daily_reward_success: "&a¡Has recibido {amount} en {currency} como recompensa d reload_messages_success: "&aMensajes recargados exitosamente." reload_success: "&aTodas las configuraciones recargadas exitosamente." price_message_format: "{amount} {symbol}" -conversion_too_small: "&cLa conversión se redondearía a 0 en la moneda destino. Aumenta la cantidad o usa una moneda con más decimales." \ No newline at end of file +conversion_too_small: "&cLa conversión se redondearía a 0 en la moneda destino. Aumenta la cantidad o usa una moneda con más decimales." +unknown_conversion: "&cNo se puede convertir entre esas monedas." +no_pending_payment: "&cNo tienes ningún pago pendiente por confirmar." +payment_confirm_required: "&eEstás a punto de pagar &6{amount}&e. Escribe &f/pay confirm &een &f{timeout}s &epara continuar." +payment_cancelled: "&cPago cancelado." +usage_bank_create: "&eUso: &f/bank create " \ No newline at end of file diff --git a/src/main/resources/languages/fr.yml b/src/main/resources/languages/fr.yml index 76a3209..3ccaa13 100644 --- a/src/main/resources/languages/fr.yml +++ b/src/main/resources/languages/fr.yml @@ -1,3 +1,4 @@ +set: "&aSolde de {player} défini à {balance}." only_players: "&cCette commande ne peut être utilisée que par les joueurs." no_permission: "&cVous n'avez pas la permission d'utiliser ce commande." usage_balance: "&eUtilisation: /balance [joueur] [devise]" @@ -6,6 +7,8 @@ usage_ezeconomy: "&eUtilisation: /ezeconomy " usage_daily_reset: "&eUtilisation: /ezeconomy daily reset " usage_pay: "&eUtilisation: /pay [devise]" usage_bank: "&eUtilisation: /bank ..." +usage_bank_deposit: "&eUtilisation: &f/bank deposit [devise]" +usage_bank_withdraw: "&eUtilisation: &f/bank withdraw [devise]" usage_currency: "&eUtilisation: /currency convert " invalid_amount: "&cMontant invalide." not_enough_money: "&cVous n'avez pas assez d'argent." @@ -24,6 +27,7 @@ use_currency: "&7Utilisez /currency pour définir votre préférence." top_balances: "&6Top {top} des soldes:" top_balances_page: "&6Top des soldes - Page {page}/{total_pages} (Taille de page: {page_size})" baltop_invalid_page: "&cPage invalide {page}. Veuillez choisir une page entre 1 et {total_pages}." +baltop_footer: "&8&m──────────────" rank_balance: "&e#{rank}: {player} - {balance}" unknown_player: "Joueur Inconnu" your_balance: "&aVotre solde: {balance}" @@ -46,6 +50,9 @@ tax_interest: "&6Taxe d'Intérêt: &f{amount}" tax_exchange: "&6Taxe de Change: &f{amount}" tax_total: "&6Total Collecté: &f{amount}" tax_reset: "&aTous les totaux de taxes ont été réinitialisés." +eco_give: "&aDonné &e{amount} &aà &e{player}&a." +eco_take: "&cRetiré &e{amount} &cde &e{player}&c." +tax_usage: "&7Utilisez &f/tax reset &7pour réinitialiser tous les totaux." daily_reset: "&aRéinitialisation du délai de récupération de la récompense quotidienne pour {player}." storage_unavailable: "&cFournisseur de stockage indisponible. Vérifiez les journaux du serveur." multi_currency_disabled: "&cLe multi-devise n'est pas activé sur ce serveur." @@ -59,4 +66,9 @@ daily_reward_success: "&aVous avez reçu {amount} en {currency} comme récompens reload_messages_success: "&aMessages rechargés avec succès." reload_success: "&aToutes les configurations rechargées avec succès." price_message_format: "{amount} {symbol}" -conversion_too_small: "&cLa conversion serait arrondie à 0 dans la devise cible. Augmentez le montant ou utilisez une devise avec plus de décimales." \ No newline at end of file +conversion_too_small: "&cLa conversion serait arrondie à 0 dans la devise cible. Augmentez le montant ou utilisez une devise avec plus de décimales." +unknown_conversion: "&cImpossible de convertir entre ces devises." +no_pending_payment: "&cVous n'avez aucun paiement en attente à confirmer." +payment_confirm_required: "&eVous êtes sur le point de payer &6{amount}&e. Tapez &f/pay confirm &edans &f{timeout}s &epour continuer." +payment_cancelled: "&cPaiement annulé." +usage_bank_create: "&eUtilisation: &f/bank create " \ No newline at end of file diff --git a/src/main/resources/languages/nl.yml b/src/main/resources/languages/nl.yml index 69a42e7..2b9a819 100644 --- a/src/main/resources/languages/nl.yml +++ b/src/main/resources/languages/nl.yml @@ -7,6 +7,8 @@ usage_ezeconomy: "&eGebruik: /ezeconomy " usage_daily_reset: "&eGebruik: /ezeconomy daily reset " usage_pay: "&eGebruik: /pay [devise]" usage_bank: "&eGebruik: /bank ..." +usage_bank_deposit: "&eGebruik: &f/bank deposit [devise]" +usage_bank_withdraw: "&eGebruik: &f/bank withdraw [devise]" usage_currency: "&eGebruik: /currency convert " invalid_amount: "&cOngeldig bedrag." not_enough_money: "&cJe hebt niet genoeg geld." @@ -25,6 +27,7 @@ use_currency: "&7Gebruik /currency om je voorkeur in te stellen." top_balances: "&6Top {top} balansen:" top_balances_page: "&6Top balansen - Pagina {page}/{total_pages} (Pagina grootte: {page_size})" baltop_invalid_page: "&cOngeldige pagina {page}. Kies een pagina tussen 1 en {total_pages}." +baltop_footer: "&8&m──────────────" rank_balance: "&e#{rank}: {player} - {balance}" unknown_player: "Onbekende Speler" your_balance: "&aJe saldo: {balance}" @@ -47,6 +50,9 @@ tax_interest: "&6Rente Belasting: &f{amount}" tax_exchange: "&6Wisselkoers Belasting: &f{amount}" tax_total: "&6Totaal Geïnd: &f{amount}" tax_reset: "&aAlle belasting totalen zijn gereset." +eco_give: "&a{amount} &agegeven aan &e{player}&a." +eco_take: "&c{amount} &cafgenomen van &e{player}&c." +tax_usage: "&7Gebruik &f/tax reset &7om alle totalen te wissen." daily_reset: "&aDagelijkse beloning cooldown opnieuw ingesteld voor {player}." storage_unavailable: "&cOpslagprovider niet beschikbaar. Controleer server logs." multi_currency_disabled: "&cMulti-valuta is niet ingeschakeld op deze server." @@ -60,4 +66,9 @@ daily_reward_success: "&aJe hebt {amount} in {currency} ontvangen als dagelijkse reload_messages_success: "&aBerichten succesvol opnieuw geladen." reload_success: "&aAlle configuraties succesvol opnieuw geladen." price_message_format: "{amount} {symbol}" -conversion_too_small: "&cConversie zou afronden naar 0 in de doelvaluta. Verhoog het bedrag of gebruik een valuta met meer decimalen." \ No newline at end of file +conversion_too_small: "&cConversie zou afronden naar 0 in de doelvaluta. Verhoog het bedrag of gebruik een valuta met meer decimalen." +unknown_conversion: "&cKan niet converteren tussen die valuta's." +no_pending_payment: "&cJe hebt geen openstaande betaling om te bevestigen." +payment_confirm_required: "&eJe staat op het punt &6{amount} &ete betalen. Typ &f/pay confirm &ebinnen &f{timeout}s &eom door te gaan." +payment_cancelled: "&cBetaling geannuleerd." +usage_bank_create: "&eGebruik: &f/bank create " \ No newline at end of file diff --git a/src/main/resources/languages/zh.yml b/src/main/resources/languages/zh.yml index bdb64ae..57a1abc 100644 --- a/src/main/resources/languages/zh.yml +++ b/src/main/resources/languages/zh.yml @@ -1,3 +1,4 @@ +set: "&a已将 {player} 的余额设置为 {balance}。" only_players: "&c该命令只能由玩家使用。" no_permission: "&c你没有权限使用此命令。" usage_balance: "&e用法: /balance [玩家] [货币]" @@ -6,6 +7,8 @@ usage_ezeconomy: "&e用法: /ezeconomy " usage_daily_reset: "&e用法: /ezeconomy daily reset <玩家>" usage_pay: "&e用法: /pay <玩家> <金额> [货币]" usage_bank: "&e用法: /bank ..." +usage_bank_deposit: "&e用法: &f/bank deposit <名称> <金额> [货币]" +usage_bank_withdraw: "&e用法: &f/bank withdraw <名称> <金额> [货币]" usage_currency: "&e用法: /currency convert " invalid_amount: "&c金额无效。" not_enough_money: "&c你的钱不够。" @@ -24,7 +27,9 @@ use_currency: "&7使用 /currency <名称> 设置你的首选项。" top_balances: "&6前 {top} 名余额:" top_balances_page: "&6余额排行 - 第 {page}/{total_pages} 页 (每页 {page_size} 名)" baltop_invalid_page: "&c无效页码 {page}。请选择 1 到 {total_pages} 之间的页码。" +baltop_footer: "&8&m──────────────" rank_balance: "&e#{rank}: {player} - {balance}" +unknown_player: "未知玩家" your_balance: "&a你的余额: {balance}" others_balance: "&a{player} 的余额: {balance}" no_permission_others_balance: "&c你没有权限查看他人余额。" @@ -45,6 +50,9 @@ tax_interest: "&6利息税: &f{amount}" tax_exchange: "&6汇率税: &f{amount}" tax_total: "&6总征收: &f{amount}" tax_reset: "&a所有税收总计已重置。" +eco_give: "&a已给予 &e{player} &a{amount}&a。" +eco_take: "&c已从 &e{player} &c扣除 &e{amount}&c。" +tax_usage: "&7使用 &f/tax reset &7来清除所有税收总计。" daily_reset: "&a已为 {player} 重置每日奖励冷却。" storage_unavailable: "&c存储提供程序不可用。请检查服务器日志。" multi_currency_disabled: "&c此服务器未启用多货币功能。" @@ -58,4 +66,9 @@ daily_reward_success: "&a你收到了 {amount} {currency} 作为每日奖励!" reload_messages_success: "&a消息重新加载成功。" reload_success: "&a所有配置重新加载成功。" price_message_format: "{amount} {symbol}" -conversion_too_small: "&c转换后在目标货币中会被四舍五入为 0。请增加数量或使用具有更多小数位的货币。" \ No newline at end of file +conversion_too_small: "&c转换后在目标货币中会被四舍五入为 0。请增加数量或使用具有更多小数位的货币。" +unknown_conversion: "&c无法在这些货币之间进行转换。" +no_pending_payment: "&c你没有待确认的付款。" +payment_confirm_required: "&e你即将支付 &6{amount}&e。请在 &f{timeout}秒 &e内输入 &f/pay confirm &e以继续。" +payment_cancelled: "&c付款已取消。" +usage_bank_create: "&e用法: &f/bank create <名称>" \ No newline at end of file diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index a81c1e8..4407c7e 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -25,9 +25,11 @@ commands: pay: description: Pay money to another player or all players usage: /pay [currency] + aliases: [ezpay] payall: description: Pay money to all players (alias for /pay *) usage: /payall [currency] + aliases: [ezpayall] currency: description: View or set your preferred currency usage: /currency [currency] diff --git a/src/main/resources/user-gui.yml b/src/main/resources/user-gui.yml index 76802b9..e1cff36 100644 --- a/src/main/resources/user-gui.yml +++ b/src/main/resources/user-gui.yml @@ -2,14 +2,6 @@ # Enable opening the user GUI when players run /eco with no arguments open-on-eco: true -# Per-action enable flags -actions: - balance: true - pay: true - history: true - -# Additional options can be added here for future GUI customization - # Inventory titles (supports MiniMessage formatting) title: menu: "EzEconomy - Menu" diff --git a/src/test/java/com/skyblockexp/ezeconomy/core/CurrencyFormatterMoneyConfigTest.java b/src/test/java/com/skyblockexp/ezeconomy/core/CurrencyFormatterMoneyConfigTest.java index 9f84543..1b38cd4 100644 --- a/src/test/java/com/skyblockexp/ezeconomy/core/CurrencyFormatterMoneyConfigTest.java +++ b/src/test/java/com/skyblockexp/ezeconomy/core/CurrencyFormatterMoneyConfigTest.java @@ -13,8 +13,8 @@ public void moneyFormat_useCompact_enabled_and_precision() { plugin.getConfig().set("money-format.useCompact", true); plugin.getConfig().set("money-format.compact.precision", 2); - // 1500 should be compacted with 2 decimals -> 1.50k - assertEquals("1.50k", plugin.getCurrencyFormatter().formatShort(1500.0, null)); + // 1500 should be compacted with 2 decimals -> 1.50K + assertEquals("1.50K", plugin.getCurrencyFormatter().formatShort(1500.0, null)); } @Test diff --git a/src/test/java/com/skyblockexp/ezeconomy/core/EzEconomyPluginFormatShortTest.java b/src/test/java/com/skyblockexp/ezeconomy/core/EzEconomyPluginFormatShortTest.java index 9c6cbcd..ebe84d5 100644 --- a/src/test/java/com/skyblockexp/ezeconomy/core/EzEconomyPluginFormatShortTest.java +++ b/src/test/java/com/skyblockexp/ezeconomy/core/EzEconomyPluginFormatShortTest.java @@ -10,10 +10,10 @@ public class EzEconomyPluginFormatShortTest { public void formatShort_withoutCurrency_smallValues() { EzEconomyPlugin plugin = new EzEconomyPlugin(); // When currency is null, formatShort returns NumberUtil's short output - assertEquals("1.5k", plugin.getCurrencyFormatter().formatShort(1500.0, null)); - assertEquals("1k", plugin.getCurrencyFormatter().formatShort(1000.0, null)); + assertEquals("1.5K", plugin.getCurrencyFormatter().formatShort(1500.0, null)); + assertEquals("1K", plugin.getCurrencyFormatter().formatShort(1000.0, null)); assertEquals("999", plugin.getCurrencyFormatter().formatShort(999.0, null)); - assertEquals("2.5m", plugin.getCurrencyFormatter().formatShort(2_500_000.0, null)); + assertEquals("2.5M", plugin.getCurrencyFormatter().formatShort(2_500_000.0, null)); } @Test @@ -21,9 +21,9 @@ public void formatShort_respectsDecimalsConfig() { EzEconomyPlugin plugin = new EzEconomyPlugin(); // override config to show 2 decimals plugin.getConfig().set("currency.format.short.decimals", 2); - assertEquals("1.50k", plugin.getCurrencyFormatter().formatShort(1500.0, null)); + assertEquals("1.50K", plugin.getCurrencyFormatter().formatShort(1500.0, null)); // large value - assertEquals("2.50m", plugin.getCurrencyFormatter().formatShort(2_500_000.0, null)); + assertEquals("2.50M", plugin.getCurrencyFormatter().formatShort(2_500_000.0, null)); } @Test @@ -33,9 +33,9 @@ public void formatShort_perCurrencyDecimals() { plugin.getConfig().set("currency.format.short.decimals", 1); plugin.getConfig().set("multi-currency.currencies.test.short.decimals", 2); // when specifying currency key, per-currency decimals should be used - assertEquals("1.50k", plugin.getCurrencyFormatter().formatShort(1500.0, "test")); + assertEquals("1.50K", plugin.getCurrencyFormatter().formatShort(1500.0, "test")); // other currency falls back to global - assertEquals("1.5k", plugin.getCurrencyFormatter().formatShort(1500.0, "other")); + assertEquals("1.5K", plugin.getCurrencyFormatter().formatShort(1500.0, "other")); } @Test @@ -54,6 +54,6 @@ public void formatShort_perCurrencyEnabledAndThreshold() { // 1500 below per-currency threshold -> fallback to full format assertEquals(plugin.getCurrencyFormatter().format(1500.0, "highthresh"), plugin.getCurrencyFormatter().formatShort(1500.0, "highthresh")); // 2500 above threshold -> uses short - assertEquals("2.5k", plugin.getCurrencyFormatter().formatShort(2500.0, "highthresh")); + assertEquals("2.5K", plugin.getCurrencyFormatter().formatShort(2500.0, "highthresh")); } } diff --git a/src/test/java/com/skyblockexp/ezeconomy/core/VaultEconomyImplConcurrencyTest.java b/src/test/java/com/skyblockexp/ezeconomy/core/VaultEconomyImplConcurrencyTest.java new file mode 100644 index 0000000..ce1480b --- /dev/null +++ b/src/test/java/com/skyblockexp/ezeconomy/core/VaultEconomyImplConcurrencyTest.java @@ -0,0 +1,85 @@ +package com.skyblockexp.ezeconomy.core; + +import com.skyblockexp.ezeconomy.feature.support.TestSupport; +import com.skyblockexp.ezeconomy.lock.LocalLockManager; +import net.milkbowl.vault.economy.EconomyResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.MockBukkit; + +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class VaultEconomyImplConcurrencyTest { + private Object server; + private EzEconomyPlugin plugin; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + plugin = TestSupport.loadPlugin(server); + } + + @AfterEach + void teardown() { + MockBukkit.unmock(); + } + + @Test + void withdrawPlayer_serializesConcurrentDebitAttempts() throws Exception { + UUID playerId = UUID.randomUUID(); + RaceyStorage storage = new RaceyStorage(); + storage.setBalance(playerId, "dollar", 100.0); + TestSupport.injectField(plugin, "storage", storage); + plugin.setLockManager(new LocalLockManager()); + + VaultEconomyImpl economy = new VaultEconomyImpl(plugin); + CountDownLatch startLatch = new CountDownLatch(1); + var pool = Executors.newFixedThreadPool(2); + try { + Future first = pool.submit(() -> { + startLatch.await(3, TimeUnit.SECONDS); + return economy.withdrawPlayer(plugin.getServer().getOfflinePlayer(playerId), 100.0); + }); + Future second = pool.submit(() -> { + startLatch.await(3, TimeUnit.SECONDS); + return economy.withdrawPlayer(plugin.getServer().getOfflinePlayer(playerId), 100.0); + }); + startLatch.countDown(); + + EconomyResponse r1 = first.get(3, TimeUnit.SECONDS); + EconomyResponse r2 = second.get(3, TimeUnit.SECONDS); + int successCount = (r1.type == EconomyResponse.ResponseType.SUCCESS ? 1 : 0) + + (r2.type == EconomyResponse.ResponseType.SUCCESS ? 1 : 0); + + assertEquals(1, successCount, "exactly one withdraw should succeed under contention"); + assertEquals(0.0, storage.getBalance(playerId, "dollar"), 0.0001, "balance must not go negative or be double-spent"); + } finally { + pool.shutdownNow(); + } + } + + private static final class RaceyStorage extends TestSupport.MockStorage { + @Override + public boolean tryWithdraw(UUID uuid, String currency, double amount) { + double bal = getBalance(uuid, currency); + if (bal < amount) { + return false; + } + try { + Thread.sleep(25L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + setBalance(uuid, currency, bal - amount); + return true; + } + } +} diff --git a/src/test/java/com/skyblockexp/ezeconomy/listener/PlayerJoinListenerTest.java b/src/test/java/com/skyblockexp/ezeconomy/listener/PlayerJoinListenerTest.java index 57b3bd1..2406c37 100644 --- a/src/test/java/com/skyblockexp/ezeconomy/listener/PlayerJoinListenerTest.java +++ b/src/test/java/com/skyblockexp/ezeconomy/listener/PlayerJoinListenerTest.java @@ -9,6 +9,7 @@ import org.junit.jupiter.api.Test; import org.mockbukkit.MockBukkit; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; public class PlayerJoinListenerTest { @@ -47,4 +48,22 @@ public void testStoreOnJoin_createsPlayerRecord() throws Exception { assertTrue(yml.playerExists(player.getUniqueId())); } + + @Test + public void testStoreOnJoinDisabled_doesNotCreatePlayerRecord() throws Exception { + plugin.getConfig().set("store-on-join.enabled", false); + YamlConfiguration cfg = new YamlConfiguration(); + cfg.set("yml.data-folder", "test-store-on-join"); + cfg.set("yml.per-player-file-naming", "uuid"); + + YMLStorageProvider yml = new YMLStorageProvider(plugin, cfg); + TestSupport.createTestDataFolder(plugin, "test-store-on-join"); + TestSupport.injectField(plugin, "storage", yml); + + Object playerObj = server.getClass().getMethod("addPlayer", String.class).invoke(server, "joinerNoStore"); + org.bukkit.entity.Player player = (org.bukkit.entity.Player) playerObj; + Thread.sleep(100); + + assertFalse(yml.playerExists(player.getUniqueId())); + } } diff --git a/src/test/java/com/skyblockexp/ezeconomy/unit/NumberUtilUnitTest.java b/src/test/java/com/skyblockexp/ezeconomy/unit/NumberUtilUnitTest.java index dc1dabf..478f04c 100644 --- a/src/test/java/com/skyblockexp/ezeconomy/unit/NumberUtilUnitTest.java +++ b/src/test/java/com/skyblockexp/ezeconomy/unit/NumberUtilUnitTest.java @@ -27,10 +27,10 @@ public void testParseDecimal() { @org.junit.jupiter.api.Test public void testFormatShortValues() { assertEquals("500", NumberUtil.formatShort(new java.math.BigDecimal("500"))); - assertEquals("1k", NumberUtil.formatShort(new java.math.BigDecimal("1000"))); - assertEquals("1.5k", NumberUtil.formatShort(new java.math.BigDecimal("1500"))); - assertEquals("2.5m", NumberUtil.formatShort(new java.math.BigDecimal("2500000"))); - assertEquals("1b", NumberUtil.formatShort(new java.math.BigDecimal("1000000000"))); - assertEquals("1.2t", NumberUtil.formatShort(new java.math.BigDecimal("1200000000000"))); + assertEquals("1K", NumberUtil.formatShort(new java.math.BigDecimal("1000"))); + assertEquals("1.5K", NumberUtil.formatShort(new java.math.BigDecimal("1500"))); + assertEquals("2.5M", NumberUtil.formatShort(new java.math.BigDecimal("2500000"))); + assertEquals("1B", NumberUtil.formatShort(new java.math.BigDecimal("1000000000"))); + assertEquals("1.2T", NumberUtil.formatShort(new java.math.BigDecimal("1200000000000"))); } }