diff --git a/docs/features/README.md b/docs/features/README.md index 75e211b22..0e78b24fe 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -54,6 +54,7 @@ This directory documents all **64** feature modules currently present in ServerF - [VillagerOptimizer](villageroptimizer.md) — Reduces villager processing cost by applying configurable optimization rules without changing intended gameplay more than necessary. - [WorldEditVisualizer](worldeditvisualizer.md) — Visualizes WorldEdit selections for authorized builders with temporary particles or display markers. - [Graveyard](graveyard.md) — Stores death contents durably and renders claimable packet-only graves with active-server-time expiry and crash recovery. +- [Economy](economy.md) — Provides a MySQL-authoritative, multi-currency network economy with scoped balances, audited idempotent transactions, cross-server synchronization, player payments, administration, Vault compatibility, and operational verification. - [Lottery](lottery.md) — Runs scheduled ticket-based draws with donations, offline payouts, history, leaderboards, and a small staff command set. ## Moderation and administration diff --git a/docs/features/economy-deployment-checklist.md b/docs/features/economy-deployment-checklist.md new file mode 100644 index 000000000..0c7a0b3b4 --- /dev/null +++ b/docs/features/economy-deployment-checklist.md @@ -0,0 +1,51 @@ +# Economy network deployment checklist + +Use this checklist before enabling Economy on a production HauntedMC network. + +## Required shared infrastructure + +- Every Paper instance must use the same authoritative MySQL database for shared/global currencies. +- Redis messaging should use the same configured connection and channel on all participating instances. Redis is an invalidation and notification transport only; MySQL remains authoritative. +- DataRegistry must resolve the same immutable player ID and UUID pair on every instance. +- Every participating instance must run the same ServerFeatures build. Do not operate mixed Economy schema or messaging versions during rollout. +- Keep the MySQL host and every Paper host synchronized with reliable NTP. Cooldown, daily-limit and audit timestamps depend on a consistent network clock. + +## Stable scope keys + +- Give every logical gamemode one permanent local key, such as `survival`, `skyblock`, or `kitpvp`. +- Set the top-level `server_key`, `gamemode_key`, or `local_key` to that logical gamemode key. A per-currency `scope.local_key` may override it when replicas need an explicitly shared local scope. +- Physical replicas of one gamemode must use the same logical local key when they should share gamemode-local balances. +- Do not rename a logical key after accounts exist. A different key intentionally selects a different account scope. +- Configure Crowns and Credits as `GLOBAL` on every participating gamemode. +- Configure Essence, Relics, Soulstones, and Money as gamemode-local `SERVER` currencies. +- Expose only gamemode-local Money as the Vault primary currency on each Paper server. + +## Configuration consistency + +- Keep precision, starting balance, bounds, negative-balance policy, rounding, payment limits, cooldown, and payment-default settings identical for every server sharing a currency account scope. +- Keep each currency ID in one scope family across the network. For example, `crowns` must not be global on one gamemode and local on another. +- Startup must remain blocked when the persisted currency-family or scope definition conflicts with configuration. Do not bypass these guards. + +## Rollout + +1. Back up the economy database. +2. Deploy the same plugin build to every participating Paper instance. +3. Enable and validate one non-production gamemode first. +4. Run `/economy status`, `/economy currencies`, and `/economy verify`. +5. Test one local Money transfer between physical replicas of the same gamemode. +6. Test one local currency transaction while the recipient is online on another gamemode; only the originating gamemode account should change. +7. Test one global Crowns or Credits payment while the recipient is online on another gamemode; both servers should converge on the same committed MySQL balance. +8. Repeat the global test with Redis temporarily unavailable. The transaction must still commit safely and the remote display must heal through authoritative refresh after messaging returns or the refresh interval elapses. +9. Retry one native mutation with the same source and idempotency key. It must replay the original operation without changing the balance twice. +10. Retry the same key with a different amount or recipient. It must return an idempotency conflict and apply nothing. +11. Confirm Vault reports the correct gamemode-local Money balance and cannot expose Crowns, Credits, or other named currencies. +12. Enable the remaining gamemodes only after all checks pass. + +## Operational rules + +- Never repair balances by editing cache or Redis data. +- Never use PlaceholderAPI values to authorize monetary behavior; they are cache-only display values. +- Never change balances outside `EconomyApi`, the Economy administration commands, or the registered Vault provider. +- Preserve operation IDs, sources, and idempotency keys in logs for every high-value integration. +- Keep financial data models immutable at API boundaries and retain null-safe failure handling in every compensation path. +- Treat `/economy verify` findings, identity conflicts, currency-definition conflicts, or repeated temporary database failures as release-blocking incidents. diff --git a/docs/features/economy-hauntedmc-example.yml b/docs/features/economy-hauntedmc-example.yml new file mode 100644 index 000000000..52dea1f8a --- /dev/null +++ b/docs/features/economy-hauntedmc-example.yml @@ -0,0 +1,237 @@ +# Example Economy feature configuration for one HauntedMC gamemode. +# Change server_key per logical gamemode (survival, skyblock, kitpvp, ...). +# Physical replicas of the same gamemode must use the same server_key. + +enabled: true +network_key: hauntedmc +server_key: survival + +database: + connection: system_data_rw + +messaging: + enabled: true + connection: hauntedmc + channel: serverfeatures.economy.balance + +vault: + enabled: true + primary_currency: money + conflict_policy: FAIL + +currencies: + crowns: + enabled: true + scope: + type: GLOBAL + display: + singular: Crown + plural: Crowns + symbol: "♛" + format: "{amount} {plural}" + fractional_digits: 0 + grouping: true + balances: + starting: "0" + minimum: "0" + maximum: "999999999999999999999999999999" + allow_negative: false + rounding: UNNECESSARY + commands: + root: crowns + aliases: [crown] + balance: true + balance_others: true + pay: true + paytoggle: true + history: true + top: true + payments: + default_enabled: true + allow_offline_recipient: true + minimum: "1" + maximum: "1000000" + confirmation_threshold: "10000" + daily_send_limit: "0" + daily_receive_limit: "0" + cooldown: 1s + + credits: + enabled: true + scope: + type: GLOBAL + display: + singular: Credit + plural: Credits + symbol: "✦" + format: "{amount} {plural}" + fractional_digits: 0 + grouping: true + balances: + starting: "0" + minimum: "0" + maximum: "999999999999999999999999999999" + allow_negative: false + rounding: UNNECESSARY + commands: + root: credits + aliases: [credit] + balance: true + balance_others: true + pay: true + paytoggle: true + history: true + top: true + payments: + default_enabled: true + allow_offline_recipient: true + minimum: "1" + maximum: "1000000" + confirmation_threshold: "10000" + daily_send_limit: "0" + daily_receive_limit: "0" + cooldown: 1s + + essence: + enabled: true + scope: + type: SERVER + display: + singular: Essence + plural: Essence + symbol: "✧" + format: "{amount} {plural}" + fractional_digits: 0 + grouping: true + balances: + starting: "0" + minimum: "0" + maximum: "999999999999999999999999999999" + allow_negative: false + rounding: UNNECESSARY + commands: + root: essence + aliases: [] + balance: true + balance_others: true + pay: true + paytoggle: true + history: true + top: true + payments: + default_enabled: true + allow_offline_recipient: true + minimum: "1" + maximum: "0" + confirmation_threshold: "0" + daily_send_limit: "0" + daily_receive_limit: "0" + cooldown: 1s + + relics: + enabled: true + scope: + type: SERVER + display: + singular: Relic + plural: Relics + symbol: "◆" + format: "{amount} {plural}" + fractional_digits: 0 + grouping: true + balances: + starting: "0" + minimum: "0" + maximum: "999999999999999999999999999999" + allow_negative: false + rounding: UNNECESSARY + commands: + root: relics + aliases: [relic] + balance: true + balance_others: true + pay: true + paytoggle: true + history: true + top: true + payments: + default_enabled: true + allow_offline_recipient: true + minimum: "1" + maximum: "0" + confirmation_threshold: "0" + daily_send_limit: "0" + daily_receive_limit: "0" + cooldown: 1s + + soulstones: + enabled: true + scope: + type: SERVER + display: + singular: Soulstone + plural: Soulstones + symbol: "◈" + format: "{amount} {plural}" + fractional_digits: 0 + grouping: true + balances: + starting: "0" + minimum: "0" + maximum: "999999999999999999999999999999" + allow_negative: false + rounding: UNNECESSARY + commands: + root: soulstones + aliases: [soulstone] + balance: true + balance_others: true + pay: true + paytoggle: true + history: true + top: true + payments: + default_enabled: true + allow_offline_recipient: true + minimum: "1" + maximum: "0" + confirmation_threshold: "0" + daily_send_limit: "0" + daily_receive_limit: "0" + cooldown: 1s + + money: + enabled: true + scope: + type: SERVER + display: + singular: Coin + plural: Coins + symbol: "$" + format: "{symbol}{amount}" + fractional_digits: 2 + grouping: true + balances: + starting: "0.00" + minimum: "0.00" + maximum: "999999999999999999999999999999.99999999" + allow_negative: false + rounding: HALF_UP + commands: + root: money + aliases: [balance, bal] + balance: true + balance_others: true + pay: true + paytoggle: true + history: true + top: true + payments: + default_enabled: true + allow_offline_recipient: true + minimum: "0.01" + maximum: "0.00" + confirmation_threshold: "100000.00" + daily_send_limit: "0.00" + daily_receive_limit: "0.00" + cooldown: 1s diff --git a/docs/features/economy-incident-response.md b/docs/features/economy-incident-response.md new file mode 100644 index 000000000..a1b4bab38 --- /dev/null +++ b/docs/features/economy-incident-response.md @@ -0,0 +1,32 @@ +# Economy incident response + +Use this runbook for any suspected balance, transaction, identity, MySQL, Redis or Vault incident. Treat the transaction journal and operation IDs as the primary evidence chain. + +## Immediate containment + +1. Do not edit balances, Redis data or ORM tables manually. +2. Disable the integration that is producing unexpected mutations, or disable Economy on the affected Paper instance when the source is unknown. +3. Freeze the affected account with `/economy freeze ` when continued player access could increase the impact. +4. Record the player UUID, canonical player ID, currency, scope key, operation ID, source, idempotency key, server name and exact UTC time. +5. Preserve current application, MySQL and proxy logs before restarting services. + +## Diagnosis + +- Run `/economy status`, `/economy currencies` and `/economy verify`. +- Check that every participating Paper server runs the same ServerFeatures build and resolves the same logical scope keys. +- Check MySQL availability, replication health and clock synchronization before investigating Redis. +- Treat Redis as notification/cache infrastructure only. A Redis outage does not justify reverting a committed MySQL transaction. +- For a retried native integration call, verify that the source and idempotency key are unchanged. Reusing the key with a different request must return `IDEMPOTENCY_CONFLICT`. +- For a cross-server payment notification, verify the immutable transfer journal before trusting chat or cache observations. + +## Recovery rules + +- Restore or compensate value only through a reviewed, audited Economy operation with a mandatory reason and a retained operation ID. +- Never delete transaction or transaction-entry rows to hide an error. +- Never retry an uncertain high-value operation with a new idempotency key. +- Keep an account frozen until `/economy verify` is healthy and the responsible transaction chain is understood. +- Escalate identity mismatches, currency-definition conflicts, journal arithmetic failures or repeated transient database errors as release-blocking incidents. + +## Post-incident validation + +After remediation, rerun `/economy verify`, compare the account history with the intended business event, validate the balance from MySQL through the strong Economy API, and test one idempotent replay before unfreezing the account. diff --git a/docs/features/economy.md b/docs/features/economy.md new file mode 100644 index 000000000..133fa847d --- /dev/null +++ b/docs/features/economy.md @@ -0,0 +1,433 @@ +# Economy + +Economy is the authoritative ServerFeatures multi-currency balance service. MySQL owns every balance and every committed mutation. Redis is used only for invalidation and notification hints; it is never trusted as a source of money. + +## Currency scopes + +Each currency resolves to one stable account scope: + +- `SERVER`: local to one **logical gamemode key**. Despite the enum name, this is not required to be a physical Paper instance. Replicas such as `survival-1` and `survival-2` should use the same logical key when they must share the same gamemode-local balances. +- `GROUP`: shared by every server using the configured group key. +- `GLOBAL`: one balance for the whole configured network. + +The durable account identity is: + +```text +DataRegistry player ID + currency ID + resolved scope key +``` + +A player can therefore have one global `crowns` account and separate `money` accounts for Survival, Skyblock and KitPvP at the same time. + +A dedicated `player_economy_identity` table permanently binds every economy player ID to exactly one UUID, and every UUID to exactly one player ID, before any account is created. Player names remain display metadata only. Any identity conflict fails closed across all currencies and scopes instead of reassigning value. + +## HauntedMC topology + +The intended HauntedMC setup is supported without special-case code: + +| Currency | Scope | Result | +| --- | --- | --- | +| Crowns | `GLOBAL` | Same balance everywhere | +| Credits | `GLOBAL` | Same balance everywhere | +| Essence | `SERVER` | Separate balance for each gamemode | +| Relics | `SERVER` | Separate balance for each gamemode | +| Soulstones | `SERVER` | Separate balance for each gamemode | +| Money | `SERVER` | Separate balance for each gamemode; exposed through Vault | + +The same model supports future currencies in `SERVER`, `GROUP` or `GLOBAL` scope. + +### Survival example + +```yaml +network_key: hauntedmc +# This is the logical gamemode key, not necessarily the physical instance name. +server_key: survival + +database: + connection: system_data_rw + +messaging: + enabled: true + connection: hauntedmc + channel: serverfeatures.economy.balance + +cache: + # Authoritative MySQL refresh for online players. This heals missed Redis messages. + authoritative_refresh_interval: 10s + +vault: + enabled: true + primary_currency: money + conflict_policy: FAIL + +currencies: + crowns: + scope: + type: GLOBAL + display: + singular: crown + plural: crowns + symbol: "" + format: "{amount} {plural}" + fractional_digits: 0 + grouping: true + balances: + starting: "0" + minimum: "0" + maximum: "100000000" + allow_negative: false + rounding: DOWN + commands: + root: crowns + aliases: [] + balance: true + balance_others: true + pay: true + paytoggle: true + history: true + top: true + payments: + default_enabled: true + minimum: "1" + maximum: "100000" + confirmation_threshold: "10000" + daily_send_limit: "0" + daily_receive_limit: "0" + cooldown: 1s + + credits: + scope: + type: GLOBAL + display: + singular: credit + plural: credits + symbol: "" + format: "{amount} {plural}" + fractional_digits: 0 + grouping: true + balances: + starting: "0" + minimum: "0" + maximum: "100000000" + allow_negative: false + rounding: DOWN + commands: + root: credits + aliases: [] + balance: true + balance_others: true + pay: true + paytoggle: true + history: true + top: true + payments: + default_enabled: true + minimum: "1" + maximum: "100000" + confirmation_threshold: "10000" + daily_send_limit: "0" + daily_receive_limit: "0" + cooldown: 1s + + essence: + scope: + type: SERVER + display: + singular: essence + plural: essence + symbol: "" + format: "{amount} {plural}" + fractional_digits: 0 + grouping: true + balances: + starting: "0" + minimum: "0" + maximum: "100000000" + allow_negative: false + rounding: DOWN + commands: + root: essence + aliases: [] + balance: true + balance_others: true + pay: false + paytoggle: false + history: true + top: true + payments: + default_enabled: false + minimum: "1" + maximum: "0" + confirmation_threshold: "0" + daily_send_limit: "0" + daily_receive_limit: "0" + cooldown: 0ms + + relics: + scope: + type: SERVER + display: + singular: relic + plural: relics + symbol: "" + format: "{amount} {plural}" + fractional_digits: 0 + grouping: true + balances: + starting: "0" + minimum: "0" + maximum: "100000000" + allow_negative: false + rounding: DOWN + commands: + root: relics + aliases: [] + balance: true + balance_others: true + pay: false + paytoggle: false + history: true + top: true + payments: + default_enabled: false + minimum: "1" + maximum: "0" + confirmation_threshold: "0" + daily_send_limit: "0" + daily_receive_limit: "0" + cooldown: 0ms + + soulstones: + scope: + type: SERVER + display: + singular: soulstone + plural: soulstones + symbol: "" + format: "{amount} {plural}" + fractional_digits: 0 + grouping: true + balances: + starting: "0" + minimum: "0" + maximum: "100000000" + allow_negative: false + rounding: DOWN + commands: + root: soulstones + aliases: [] + balance: true + balance_others: true + pay: false + paytoggle: false + history: true + top: true + payments: + default_enabled: false + minimum: "1" + maximum: "0" + confirmation_threshold: "0" + daily_send_limit: "0" + daily_receive_limit: "0" + cooldown: 0ms + + money: + scope: + type: SERVER + display: + singular: coin + plural: coins + symbol: "$" + format: "{symbol}{amount}" + fractional_digits: 2 + grouping: true + balances: + starting: "0.00" + minimum: "0.00" + maximum: "999999999999.99" + allow_negative: false + rounding: HALF_UP + commands: + root: money + aliases: [balance, bal] + balance: true + balance_others: true + pay: true + paytoggle: true + history: true + top: false + payments: + default_enabled: true + minimum: "0.01" + maximum: "1000000.00" + confirmation_threshold: "100000.00" + daily_send_limit: "0.00" + daily_receive_limit: "0.00" + cooldown: 1s +``` + +Use the same currency definitions on Skyblock, KitPvP and other gamemodes, but set their top-level logical key accordingly: + +```yaml +server_key: skyblock +``` + +This makes `crowns` and `credits` resolve to `hauntedmc/global`, while local currencies resolve to `hauntedmc/server/skyblock`. + +When multiple physical instances serve one gamemode, either give each instance the same top-level `server_key`, or override an individual currency explicitly: + +```yaml +server_key: survival-1 +currencies: + money: + scope: + type: SERVER + local_key: survival +``` + +Both replicas then use `hauntedmc/server/survival` for Money. A wrong logical key intentionally creates a different account, so these keys must be managed as stable identifiers. + +For a grouped currency: + +```yaml +scope: + type: GROUP + group_key: survival-network +``` + +## Network-wide transfer behavior + +A global payment from gamemode A to a player online on gamemode B follows this path: + +1. A resolves both canonical DataRegistry identities. +2. MySQL locks both global account rows in deterministic order. +3. Balance, paytoggle, account status, cooldown and daily limits are rechecked while locked. +4. Sender debit, recipient credit and both immutable journal entries commit in one MySQL transaction. +5. A returns success only after commit. +6. Redis publishes an invalidation hint and the committed operation ID. +7. B verifies that operation against the MySQL transaction journal and reloads the recipient account from MySQL before displaying the notification. +8. Periodic authoritative refresh heals the cache if Redis is unavailable or a message is missed. + +Redis messages never contain an authoritative balance and cannot mint, remove or overwrite money. A duplicated message can only cause a redundant refresh; notification operation IDs are deduplicated. + +For a local currency, a transfer initiated on Survival affects the recipient's Survival-scoped account even when that recipient is currently on Skyblock. It does not alter their Skyblock account. The balance becomes visible immediately when the recipient is on a server using the same local scope, or on the next authoritative refresh/join when they return to that gamemode. + +Known offline players remain valid payment recipients. This cannot be disabled per server, because different servers cannot reliably distinguish “offline” from “online elsewhere” without making monetary behavior depend on presence races. + +## Transaction guarantees + +- MySQL is authoritative; there is no local-file fallback and no write-behind balance queue. +- Every mutation and its journal entries commit atomically. +- Transfers lock both accounts in canonical player-ID order. +- Concurrent withdrawals cannot spend the same balance twice. +- Account creation is protected by deterministic IDs and database uniqueness, so starting balances are applied once. +- Account creation, including a zero or non-zero starting balance, receives its own immutable journal entry. +- Player ID and UUID ownership is immutable; an identity mismatch fails closed instead of reassigning an account. +- Payment cooldowns and daily send/receive limits are stored and checked transactionally, so switching gamemodes cannot bypass them for a shared currency. +- Account freezes and payment preferences are scoped with the account and are themselves audited transactions. +- Monetary configuration for a shared scope is fingerprinted in MySQL. Servers with conflicting precision, bounds, starting balance, negative policy, rounding or payment policies fail startup instead of running a split-brain currency. + +## Idempotency + +Native callers provide both a stable `source` and an `idempotencyKey`. The unique pair identifies one logical request across the whole network. + +The stored request fingerprint binds the operation type, account or transfer parties, scope, normalized amount, actor, reason, metadata and bypass policy. Reusing the same key for the same request returns the original operation as `IDEMPOTENT_REPLAY`. Reusing it for a different request returns `IDEMPOTENCY_CONFLICT`; the second request is not applied. + +Integration sources should be globally stable names such as `lottery`, `shop`, or `quest-rewards`. Retry attempts for one logical operation must reuse the same idempotency key. + +## Storage + +The feature registers these ORM entities: + +- `system_economy_currency_family` +- `system_economy_currency_definition` +- `player_economy_identity` +- `player_economy_balance` +- `player_economy_settings` +- `system_economy_transaction` +- `system_economy_transaction_entry` +- `player_economy_daily_usage` + +`/economy verify` is read-only. It checks balance bounds, journal arithmetic, orphaned settings/entries and transactions without entries; it never repairs or rewrites balances. + +## Failure behavior + +- MySQL unavailable: mutations fail closed and no success is returned. +- Redis unavailable: committed transactions continue safely; caches heal from MySQL on their configured refresh interval. +- Duplicate/reordered Redis delivery: versioned invalidation and authoritative reload prevent stale overwrites. +- Server crash after commit: the balance and journal remain committed; idempotent callers can safely replay the request. +- Lottery built-in backend: one automatic retry reuses the exact same idempotency key, so an uncertain first response cannot charge or pay twice. +- Server crash before commit: the transaction rolls back. + +## Player commands + +Each currency registers only its enabled command tree: + +```text +/ +/ balance [player] +/ pay +/ confirm +/ paytoggle [on|off|status] +/ history [page] +/ top [page] +``` + +Disabled subcommands are absent from Brigadier suggestions. + +## Administration + +```text +/economy status +/economy currencies +/economy balance +/economy add +/economy remove +/economy set +/economy payments +/economy freeze +/economy unfreeze +/economy history [page] +/economy verify +``` + +Administrative balance changes require a reason, are journaled, and return an operation ID. + +## Vault + +Vault is optional and exposes exactly one configured primary currency per Paper server. For HauntedMC this should be gamemode-local `money`: + +```yaml +vault: + enabled: true + primary_currency: money +``` + +Standard Vault does not tell an economy provider which consuming plugin made a call, so it cannot route different third-party plugins to different currencies on the same server. HauntedMC integrations that need Crowns, Credits, Essence, Relics or Soulstones must use the native `EconomyApi`. + +Vault calls are synchronous by contract. The adapter performs a bounded persisted DataRegistry identity lookup when required and a short indexed MySQL operation. It returns success only after commit and converts lookup/database failures into failed `EconomyResponse` values. It never acknowledges a queued write. External Vault providers do not provide caller idempotency, so the built-in native API remains preferable for high-value HauntedMC operations. + +Conflict policies: + +- `FAIL`: reject Vault registration if another provider is active. +- `SKIP`: leave the other provider active while native Economy remains available. +- `REPLACE`: unregister all existing Vault economy registrations, activate ServerFeatures Economy, verify that Vault selected it, and restore displaced providers when Economy shuts down. + +## Native API + +`EconomyApi` is registered through the feature service catalog. All native mutations are asynchronous and explicitly select a currency/account scope. Strong balance reads and all mutations use MySQL; cache reads are an optional display optimization only. + +## PlaceholderAPI + +Cache-only placeholders include: + +```text +%economy_money_balance% +%economy_money_raw% +%economy_money_scope% +%economy_money_payments% +%economy_money_ready% +%economy_primary_balance% +%economy_primary_raw% +``` + +Placeholder evaluation never blocks the Paper thread. Online-player cache entries are refreshed from authoritative MySQL periodically and after network invalidations. The `ready` suffix reports whether an authoritative account snapshot is cached. Placeholders are display-only and must never be used to authorize purchases, rewards, withdrawals or other monetary decisions. diff --git a/docs/features/lottery.md b/docs/features/lottery.md index 545abe69d..8cfa9cbf6 100644 --- a/docs/features/lottery.md +++ b/docs/features/lottery.md @@ -122,3 +122,21 @@ Entries are sorted by UUID and assigned ticket ranges. The feature uses a random ``` Placeholder reads use cached snapshots only and perform no database access. + +## Economy backend + +Lottery supports an explicit monetary backend: + +```yaml +economy: + backend: VAULT + builtin: + currency: money +``` + +- `VAULT` preserves compatibility with legacy servers and external Vault providers. +- `BUILTIN` uses the native ServerFeatures Economy API and the selected currency. The currency may be server-local, group-shared or global. + +There is no automatic fallback. A missing configured backend causes Lottery to fail with a clear startup error rather than silently using another balance system. Built-in withdrawals, refunds and payouts use deterministic idempotency identifiers. A missing or warming display cache never becomes an authoritative zero-balance rejection: the built-in withdrawal performs the real MySQL balance check. Vault mode retains the existing compensation behavior because external Vault providers do not expose an idempotency API. + +For the HauntedMC topology, Lottery can use global Crowns/Credits or a gamemode-local currency by selecting its currency ID in `economy.builtin.currency`. The resolved scope comes from Economy configuration; Lottery does not construct or override account scopes itself. diff --git a/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyAccountRef.java b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyAccountRef.java new file mode 100644 index 000000000..690a81a4c --- /dev/null +++ b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyAccountRef.java @@ -0,0 +1,35 @@ +package nl.hauntedmc.serverfeatures.api.economy; + +import java.util.Objects; +import java.util.UUID; + +/** Canonical account reference accepted by the native economy API. */ +public record EconomyAccountRef( + Long playerId, + UUID playerUuid, + String playerName, + String currencyId, + String scopeKey +) { + public EconomyAccountRef { + Objects.requireNonNull(playerUuid, "playerUuid"); + if (playerId != null && playerId <= 0L) { + throw new IllegalArgumentException("playerId must be positive when provided"); + } + if (currencyId == null || currencyId.isBlank()) { + throw new IllegalArgumentException("currencyId must not be blank"); + } + currencyId = currencyId.trim().toLowerCase(java.util.Locale.ROOT); + if (currencyId.length() > 64) { + throw new IllegalArgumentException("currencyId must not exceed 64 characters"); + } + playerName = playerName == null ? "" : playerName.trim(); + if (playerName.length() > 32) { + throw new IllegalArgumentException("playerName must not exceed 32 characters"); + } + scopeKey = scopeKey == null || scopeKey.isBlank() ? null : scopeKey.trim(); + if (scopeKey != null && scopeKey.length() > 128) { + throw new IllegalArgumentException("scopeKey must not exceed 128 characters"); + } + } +} diff --git a/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyApi.java b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyApi.java new file mode 100644 index 000000000..8708093ba --- /dev/null +++ b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyApi.java @@ -0,0 +1,27 @@ +package nl.hauntedmc.serverfeatures.api.economy; + +import java.util.Collection; +import java.util.Optional; +import java.util.concurrent.CompletionStage; + +/** Native asynchronous multi-currency economy API. */ +public interface EconomyApi { + + CompletionStage balance(EconomyAccountRef account); + + Optional cachedBalance(EconomyAccountRef account); + + CompletionStage deposit(EconomyMutationRequest request); + + CompletionStage withdraw(EconomyMutationRequest request); + + CompletionStage setBalance(EconomyMutationRequest request); + + CompletionStage transfer(EconomyTransferRequest request); + + Optional currency(String currencyId); + + Collection currencies(); + + String format(String currencyId, java.math.BigDecimal amount); +} diff --git a/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyBalance.java b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyBalance.java new file mode 100644 index 000000000..559cd7fba --- /dev/null +++ b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyBalance.java @@ -0,0 +1,12 @@ +package nl.hauntedmc.serverfeatures.api.economy; + +import java.math.BigDecimal; +import java.util.Objects; + +/** Committed balance snapshot. */ +public record EconomyBalance(EconomyAccountRef account, BigDecimal balance, long version) { + public EconomyBalance { + Objects.requireNonNull(account, "account"); + Objects.requireNonNull(balance, "balance"); + } +} diff --git a/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyCurrency.java b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyCurrency.java new file mode 100644 index 000000000..7d1290637 --- /dev/null +++ b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyCurrency.java @@ -0,0 +1,33 @@ +package nl.hauntedmc.serverfeatures.api.economy; + +import java.math.BigDecimal; +import java.util.Objects; + +/** Public immutable currency definition. */ +public record EconomyCurrency( + String id, + String singular, + String plural, + String symbol, + int fractionalDigits, + EconomyScope scope, + BigDecimal minimumBalance, + BigDecimal maximumBalance, + boolean paymentsEnabled +) { + public EconomyCurrency { + if (id == null || id.isBlank()) { + throw new IllegalArgumentException("id must not be blank"); + } + id = id.trim().toLowerCase(java.util.Locale.ROOT); + singular = Objects.requireNonNullElse(singular, id); + plural = Objects.requireNonNullElse(plural, singular); + symbol = Objects.requireNonNullElse(symbol, ""); + if (fractionalDigits < 0 || fractionalDigits > 8) { + throw new IllegalArgumentException("fractionalDigits must be between 0 and 8"); + } + Objects.requireNonNull(scope, "scope"); + Objects.requireNonNull(minimumBalance, "minimumBalance"); + Objects.requireNonNull(maximumBalance, "maximumBalance"); + } +} diff --git a/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyMutationRequest.java b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyMutationRequest.java new file mode 100644 index 000000000..8f20ee679 --- /dev/null +++ b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyMutationRequest.java @@ -0,0 +1,31 @@ +package nl.hauntedmc.serverfeatures.api.economy; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.Objects; + +/** Request for a one-account economy mutation. */ +public record EconomyMutationRequest( + String source, + String idempotencyKey, + EconomyAccountRef account, + BigDecimal amount, + Long actorPlayerId, + String actorName, + String reason, + Map metadata +) { + public EconomyMutationRequest { + source = EconomyRequestValidation.source(source); + idempotencyKey = EconomyRequestValidation.text(idempotencyKey, "idempotencyKey", 160, true); + Objects.requireNonNull(account, "account"); + Objects.requireNonNull(amount, "amount"); + if (actorPlayerId != null && actorPlayerId <= 0L) { + throw new IllegalArgumentException("actorPlayerId must be positive when provided"); + } + actorName = EconomyRequestValidation.text(actorName, "actorName", 64, false); + reason = EconomyRequestValidation.text(reason, "reason", 255, false); + metadata = EconomyRequestValidation.metadata(metadata); + } + +} diff --git a/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyRequestValidation.java b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyRequestValidation.java new file mode 100644 index 000000000..864a4306a --- /dev/null +++ b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyRequestValidation.java @@ -0,0 +1,59 @@ +package nl.hauntedmc.serverfeatures.api.economy; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +final class EconomyRequestValidation { + private static final int MAX_METADATA_ENTRIES = 32; + private static final int MAX_METADATA_KEY_LENGTH = 64; + private static final int MAX_METADATA_VALUE_LENGTH = 512; + private static final int MAX_METADATA_TOTAL_LENGTH = 2_048; + + private EconomyRequestValidation() { + } + + static String source(String value) { + String normalized = text(value, "source", 64, true).toLowerCase(Locale.ROOT); + if (!normalized.matches("[a-z0-9][a-z0-9_.:-]{0,63}")) { + throw new IllegalArgumentException("source contains unsupported characters"); + } + return normalized; + } + + static String text(String value, String name, int maximumLength, boolean required) { + String normalized = value == null ? "" : value.trim(); + if (required && normalized.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + if (normalized.length() > maximumLength) { + throw new IllegalArgumentException(name + " must not exceed " + maximumLength + " characters"); + } + return normalized; + } + + static Map metadata(Map metadata) { + if (metadata == null || metadata.isEmpty()) { + return Map.of(); + } + if (metadata.size() > MAX_METADATA_ENTRIES) { + throw new IllegalArgumentException("metadata must not exceed " + MAX_METADATA_ENTRIES + " entries"); + } + Map copy = new LinkedHashMap<>(); + int totalLength = 0; + for (Map.Entry entry : metadata.entrySet()) { + String key = text(entry.getKey(), "metadata key", MAX_METADATA_KEY_LENGTH, true); + String value = text(entry.getValue(), "metadata value", MAX_METADATA_VALUE_LENGTH, false); + totalLength = Math.addExact(totalLength, Math.addExact(key.length(), value.length())); + if (totalLength > MAX_METADATA_TOTAL_LENGTH) { + throw new IllegalArgumentException( + "metadata must not exceed " + MAX_METADATA_TOTAL_LENGTH + " total characters" + ); + } + if (copy.putIfAbsent(key, value) != null) { + throw new IllegalArgumentException("metadata contains a duplicate normalized key: " + key); + } + } + return Map.copyOf(copy); + } +} diff --git a/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyResult.java b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyResult.java new file mode 100644 index 000000000..4a2a0b472 --- /dev/null +++ b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyResult.java @@ -0,0 +1,27 @@ +package nl.hauntedmc.serverfeatures.api.economy; + +import java.math.BigDecimal; +import java.util.Objects; +import java.util.UUID; + +/** Result of a committed or rejected economy mutation. */ +public record EconomyResult( + EconomyResultStatus status, + UUID operationId, + BigDecimal balance, + BigDecimal counterpartBalance, + String message +) { + public EconomyResult { + Objects.requireNonNull(status, "status"); + message = message == null ? "" : message; + } + + public boolean successful() { + return status == EconomyResultStatus.SUCCESS || status == EconomyResultStatus.IDEMPOTENT_REPLAY; + } + + public boolean replayed() { + return status == EconomyResultStatus.IDEMPOTENT_REPLAY; + } +} diff --git a/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyResultStatus.java b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyResultStatus.java new file mode 100644 index 000000000..f0ca63a47 --- /dev/null +++ b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyResultStatus.java @@ -0,0 +1,16 @@ +package nl.hauntedmc.serverfeatures.api.economy; + +/** Structured result status for native economy operations. */ +public enum EconomyResultStatus { + SUCCESS, + IDEMPOTENT_REPLAY, + IDEMPOTENCY_CONFLICT, + INSUFFICIENT_FUNDS, + ACCOUNT_FROZEN, + PAYMENTS_DISABLED, + LIMIT_EXCEEDED, + UNKNOWN_CURRENCY, + UNKNOWN_PLAYER, + INVALID_AMOUNT, + TEMPORARY_FAILURE +} diff --git a/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyScope.java b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyScope.java new file mode 100644 index 000000000..7ff694a5b --- /dev/null +++ b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyScope.java @@ -0,0 +1,17 @@ +package nl.hauntedmc.serverfeatures.api.economy; + +import java.util.Objects; + +/** Stable resolved scope for one economy account. */ +public record EconomyScope(EconomyScopeType type, String key) { + public EconomyScope { + Objects.requireNonNull(type, "type"); + if (key == null || key.isBlank()) { + throw new IllegalArgumentException("key must not be blank"); + } + key = key.trim(); + if (key.length() > 128) { + throw new IllegalArgumentException("key must not exceed 128 characters"); + } + } +} diff --git a/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyScopeType.java b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyScopeType.java new file mode 100644 index 000000000..fbf05dfa3 --- /dev/null +++ b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyScopeType.java @@ -0,0 +1,8 @@ +package nl.hauntedmc.serverfeatures.api.economy; + +/** Storage scope used by a configured currency. */ +public enum EconomyScopeType { + SERVER, + GROUP, + GLOBAL +} diff --git a/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyTransferRequest.java b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyTransferRequest.java new file mode 100644 index 000000000..c309decfa --- /dev/null +++ b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/economy/EconomyTransferRequest.java @@ -0,0 +1,34 @@ +package nl.hauntedmc.serverfeatures.api.economy; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.Objects; + +/** Request for an atomic transfer between two accounts in one currency scope. */ +public record EconomyTransferRequest( + String source, + String idempotencyKey, + EconomyAccountRef sender, + EconomyAccountRef recipient, + BigDecimal amount, + Long actorPlayerId, + String actorName, + String reason, + Map metadata, + boolean bypassPaymentsToggle +) { + public EconomyTransferRequest { + source = EconomyRequestValidation.source(source); + idempotencyKey = EconomyRequestValidation.text(idempotencyKey, "idempotencyKey", 160, true); + Objects.requireNonNull(sender, "sender"); + Objects.requireNonNull(recipient, "recipient"); + Objects.requireNonNull(amount, "amount"); + if (actorPlayerId != null && actorPlayerId <= 0L) { + throw new IllegalArgumentException("actorPlayerId must be positive when provided"); + } + actorName = EconomyRequestValidation.text(actorName, "actorName", 64, false); + reason = EconomyRequestValidation.text(reason, "reason", 255, false); + metadata = EconomyRequestValidation.metadata(metadata); + } + +} diff --git a/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/feature/meta/BaseMeta.java b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/feature/meta/BaseMeta.java index 77e83b5b3..6a79757f0 100644 --- a/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/feature/meta/BaseMeta.java +++ b/serverfeatures-api/src/main/java/nl/hauntedmc/serverfeatures/api/feature/meta/BaseMeta.java @@ -15,6 +15,10 @@ default List getDependencies() { return List.of(); } + default List getOptionalDependencies() { + return List.of(); + } + default List getPluginDependencies() { return List.of(); } diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/BukkitBaseFeature.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/BukkitBaseFeature.java index 00b3d88c5..a9484183a 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/BukkitBaseFeature.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/BukkitBaseFeature.java @@ -32,6 +32,10 @@ public List getDependencies() { return context.meta().getDependencies(); } + public List getOptionalDependencies() { + return context.meta().getOptionalDependencies(); + } + public List getPluginDependencies() { return context.meta().getPluginDependencies(); } diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/Economy.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/Economy.java new file mode 100644 index 000000000..cda6ddd0c --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/Economy.java @@ -0,0 +1,314 @@ +package nl.hauntedmc.serverfeatures.features.economy; + +import net.kyori.adventure.text.Component; +import nl.hauntedmc.dataprovider.api.orm.ORMContext; +import nl.hauntedmc.dataprovider.database.DatabaseType; +import nl.hauntedmc.serverfeatures.api.economy.EconomyApi; +import nl.hauntedmc.serverfeatures.api.io.config.ConfigMap; +import nl.hauntedmc.serverfeatures.api.io.localization.MessageMap; +import nl.hauntedmc.serverfeatures.api.util.text.placeholder.MessagePlaceholders; +import nl.hauntedmc.serverfeatures.features.BukkitBaseFeature; +import nl.hauntedmc.serverfeatures.features.FeatureContext; +import nl.hauntedmc.serverfeatures.features.economy.command.CurrencyCommand; +import nl.hauntedmc.serverfeatures.features.economy.command.EconomyAdminCommand; +import nl.hauntedmc.serverfeatures.features.economy.config.EconomySettings; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyBalanceEntity; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyCurrencyDefinitionEntity; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyCurrencyFamilyEntity; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyDailyUsageEntity; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyPlayerIdentityEntity; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyPlayerSettingsEntity; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyTransactionEntity; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyTransactionEntryEntity; +import nl.hauntedmc.serverfeatures.features.economy.listener.EconomyPlayerListener; +import nl.hauntedmc.serverfeatures.features.economy.messaging.EconomyMessaging; +import nl.hauntedmc.serverfeatures.features.economy.meta.Meta; +import nl.hauntedmc.serverfeatures.features.economy.persistence.EconomyRepository; +import nl.hauntedmc.serverfeatures.features.economy.placeholder.EconomyPlaceholder; +import nl.hauntedmc.serverfeatures.features.economy.service.EconomyService; +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; + +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** Durable multi-currency economy with server, group and network-global scopes. */ +public final class Economy extends BukkitBaseFeature { + private static final String ORM_CONNECTION = "economyOrmConnection"; + private static final String MESSAGING_CONNECTION = "economyMessagingConnection"; + + private final List currencyCommands = new ArrayList<>(); + private EconomySettings settings; + private EconomyService service; + private EconomyMessaging messaging; + private String messagingStatus = "disabled"; + private EconomyVaultIntegration vault; + private String vaultStatus = "disabled"; + private EconomyPlaceholder placeholder; + + public Economy(FeatureContext context) { + super(context); + } + + @Override + public ConfigMap getDefaultConfig() { + ConfigMap defaults = new ConfigMap(); + defaults.put("enabled", false); + defaults.put("network_key", "hauntedmc"); + defaults.put("server_key", "$server"); + defaults.put("database.connection", "system_data_rw"); + defaults.put("messaging.enabled", true); + defaults.put("messaging.connection", "hauntedmc"); + defaults.put("messaging.channel", "serverfeatures.economy.balance"); + defaults.put("cache.authoritative_refresh_interval", "10s"); + defaults.put("vault.enabled", true); + defaults.put("vault.primary_currency", "money"); + defaults.put("vault.conflict_policy", "FAIL"); + + defaults.put("currencies.money.enabled", true); + defaults.put("currencies.money.scope.type", "SERVER"); + defaults.put("currencies.money.display.singular", "coin"); + defaults.put("currencies.money.display.plural", "coins"); + defaults.put("currencies.money.display.symbol", "$"); + defaults.put("currencies.money.display.format", "{symbol}{amount}"); + defaults.put("currencies.money.display.fractional_digits", 2); + defaults.put("currencies.money.display.grouping", true); + defaults.put("currencies.money.balances.starting", "0.00"); + defaults.put("currencies.money.balances.minimum", "0.00"); + defaults.put("currencies.money.balances.maximum", "999999999999.99"); + defaults.put("currencies.money.balances.allow_negative", false); + defaults.put("currencies.money.balances.rounding", "HALF_UP"); + defaults.put("currencies.money.commands.root", "money"); + defaults.put("currencies.money.commands.aliases", List.of("balance", "bal")); + defaults.put("currencies.money.commands.balance", true); + defaults.put("currencies.money.commands.balance_others", true); + defaults.put("currencies.money.commands.pay", true); + defaults.put("currencies.money.commands.paytoggle", true); + defaults.put("currencies.money.commands.history", true); + defaults.put("currencies.money.commands.top", false); + defaults.put("currencies.money.payments.default_enabled", true); + defaults.put("currencies.money.payments.minimum", "0.01"); + defaults.put("currencies.money.payments.maximum", "1000000.00"); + defaults.put("currencies.money.payments.confirmation_threshold", "100000.00"); + defaults.put("currencies.money.payments.daily_send_limit", "0.00"); + defaults.put("currencies.money.payments.daily_receive_limit", "0.00"); + defaults.put("currencies.money.payments.cooldown", "1s"); + return defaults; + } + + @Override + public MessageMap getDefaultMessages() { + MessageMap messages = new MessageMap(); + messages.add("economy.player_only", "Dit commando kan alleen door een speler worden gebruikt."); + messages.add("economy.error", "De economieactie is mislukt: {reason}"); + messages.add("economy.invalid_amount", "Ongeldig bedrag: {reason}"); + messages.add("economy.balance.self", "Je saldo: {balance}"); + messages.add("economy.balance.other", "Saldo van {player}: {balance}"); + messages.add("economy.pay.cooldown", "Wacht nog {seconds} seconde(n) voor een nieuwe betaling."); + messages.add("economy.pay.confirm", "Bevestig de betaling van {amount} aan {player} met {command}."); + messages.add("economy.pay.no_confirmation", "Er staat geen geldige betaling klaar om te bevestigen."); + messages.add("economy.pay.failed", "De betaling is mislukt: {reason}"); + messages.add("economy.pay.sent", "Je betaalde {amount} aan {player}. Nieuw saldo: {balance}"); + messages.add("economy.pay.received", "Je ontving {amount} van {player}. Nieuw saldo: {balance}"); + messages.add("economy.paytoggle.enabled", "Je accepteert betalingen van andere spelers."); + messages.add("economy.paytoggle.disabled", "Je accepteert geen betalingen van andere spelers."); + messages.add("economy.history.header", "Transactiegeschiedenis pagina {page}"); + messages.add("economy.history.empty", "Geen transacties gevonden."); + messages.add("economy.history.entry", "{type} {amount} → {balance} · {operation}"); + messages.add("economy.top.header", "Ranglijst pagina {page}"); + messages.add("economy.top.entry", "#{rank} {player} · {balance}"); + messages.add("economy.admin.status", "Economy · server {server} · {currencies} currencies · Vault {vault} · messaging {messaging}"); + messages.add("economy.admin.currency", "{currency} · {scope} · {scope_key} · {command}"); + messages.add("economy.admin.balance", "{player} · {currency} · {scope} · {balance}"); + messages.add("economy.admin.reason_required", "Een reden is verplicht."); + messages.add("economy.admin.changed", "Saldo van {player} ({currency}) aangepast naar {balance}. Transactie {operation}"); + messages.add("economy.admin.payments", "Betalingen voor {player} staan nu {state}."); + messages.add("economy.admin.frozen", "Account {player}/{currency} is bevroren."); + messages.add("economy.admin.unfrozen", "Account {player}/{currency} is vrijgegeven."); + messages.add("economy.admin.verify", "Status {health} · accounts {accounts} · transacties {transactions} · ongeldige saldi {invalid} · ongeldige regels {invalid_entries} · losse instellingen {orphan_settings} · losse regels {orphan_entries} · identiteitsfouten {identity_mismatches} · accounts zonder journaal {accounts_without_entries} · lege transacties {empty_transactions}"); + return messages; + } + + @Override + public void initialize() { + String serverName = getConfigHandler().getGlobalSetting("server_name", String.class, "server"); + settings = EconomySettings.load(getConfigHandler(), serverName); + + var dataManager = getLifecycleManager().getDataManager(); + dataManager.initDataProvider(getFeatureName()); + dataManager.registerConnection(ORM_CONNECTION, DatabaseType.MYSQL, settings.databaseConnection()); + ORMContext orm = dataManager.createORMContext( + ORM_CONNECTION, + EconomyCurrencyFamilyEntity.class, + EconomyCurrencyDefinitionEntity.class, + EconomyPlayerIdentityEntity.class, + EconomyBalanceEntity.class, + EconomyPlayerSettingsEntity.class, + EconomyTransactionEntity.class, + EconomyTransactionEntryEntity.class, + EconomyDailyUsageEntity.class + ).orElseThrow(() -> new IllegalStateException( + "Economy requires MYSQL/" + settings.databaseConnection() + " and could not create its ORM context." + )); + + EconomyRepository repository = new EconomyRepository(orm); + repository.validateDefinitions(settings, System.currentTimeMillis()); + service = new EconomyService(this, settings, repository); + getLifecycleManager().getApiManager().registerService(EconomyApi.class, service); + + messagingStatus = "disabled"; + if (settings.messaging().enabled()) { + dataManager.registerRedisMessagingDataAccess(MESSAGING_CONNECTION, settings.messaging().connection()) + .ifPresentOrElse(access -> { + EconomyMessaging candidate = new EconomyMessaging( + this, + access, + settings.messaging().channel() + ); + try { + candidate.start(); + messaging = candidate; + service.setMessaging(candidate); + messagingStatus = "active"; + } catch (RuntimeException failure) { + candidate.close(); + messagingStatus = "unavailable"; + getLogger().warning( + "Economy messaging could not start; database correctness remains active: " + + failure.getMessage() + ); + } + }, () -> { + messagingStatus = "unavailable"; + getLogger().warning( + "Economy messaging is unavailable; database correctness remains active." + ); + }); + } + + currencyCommands.clear(); + for (EconomySettings.Currency currency : settings.currencies().values()) { + CurrencyCommand command = new CurrencyCommand(this, currency); + currencyCommands.add(command); + getLifecycleManager().getCommandManager().registerBrigadierCommand(command); + } + getLifecycleManager().getCommandManager().registerBrigadierCommand(new EconomyAdminCommand(this)); + getLifecycleManager().getListenerManager().registerListener(new EconomyPlayerListener(this)); + + if (Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) { + EconomyPlaceholder candidate = new EconomyPlaceholder(this); + if (candidate.register()) { + placeholder = candidate; + } + } + + initializeVault(); + service.start(); + } + + @Override + public void disable() { + currencyCommands.forEach(CurrencyCommand::close); + currencyCommands.clear(); + if (placeholder != null) { + placeholder.unregister(); + placeholder = null; + } + if (vault != null) { + vault.close(); + vault = null; + } + vaultStatus = "disabled"; + if (messaging != null) { + messaging.close(); + messaging = null; + } + messagingStatus = "disabled"; + if (service != null) { + service.close(); + service = null; + } + settings = null; + } + + public void evict(UUID playerUuid) { + if (service != null) { + service.evict(playerUuid); + } + currencyCommands.forEach(command -> command.evict(playerUuid)); + } + + public EconomySettings settings() { + return settings; + } + + public EconomyService service() { + return service; + } + + public String vaultStatus() { + EconomyVaultIntegration current = vault; + return current == null ? vaultStatus : current.status(); + } + + private void initializeVault() { + if (!settings.vault().enabled()) { + vaultStatus = "disabled"; + return; + } + if (!Bukkit.getPluginManager().isPluginEnabled("Vault")) { + vaultStatus = "vault-missing"; + getLogger().warning("Vault integration is enabled, but Vault is not installed."); + return; + } + try { + Class implementation = Class.forName( + "nl.hauntedmc.serverfeatures.features.economy.vault.VaultProviderRegistration", + true, + getClass().getClassLoader() + ); + EconomyVaultIntegration integration = implementation + .asSubclass(EconomyVaultIntegration.class) + .getConstructor(Economy.class) + .newInstance(this); + integration.register(); + vault = integration; + vaultStatus = integration.status(); + } catch (InvocationTargetException exception) { + Throwable cause = exception.getCause(); + if (cause instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (cause instanceof Error error) { + throw error; + } + throw new IllegalStateException("Could not initialize Vault economy integration", cause); + } catch (ReflectiveOperationException | LinkageError exception) { + throw new IllegalStateException("Could not initialize Vault economy integration", exception); + } + } + + public String messagingStatus() { + return messagingStatus; + } + + public void send(CommandSender audience, String key) { + send(audience, key, Map.of()); + } + + public void send(CommandSender audience, String key, Map values) { + audience.sendMessage(component(audience, key, values)); + } + + public Component component(CommandSender audience, String key, Map values) { + var placeholders = MessagePlaceholders.builder(); + values.forEach(placeholders::addString); + return getLocalizationHandler().getMessage(key) + .withPlaceholders(placeholders.build()) + .forAudience(audience) + .build(); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/EconomyVaultIntegration.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/EconomyVaultIntegration.java new file mode 100644 index 000000000..2eea5a656 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/EconomyVaultIntegration.java @@ -0,0 +1,11 @@ +package nl.hauntedmc.serverfeatures.features.economy; + +/** Late-bound Vault lifecycle boundary that keeps Vault optional at class-load time. */ +public interface EconomyVaultIntegration extends AutoCloseable { + void register(); + + String status(); + + @Override + void close(); +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/command/CurrencyCommand.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/command/CurrencyCommand.java new file mode 100644 index 000000000..ff78bfc4c --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/command/CurrencyCommand.java @@ -0,0 +1,415 @@ +package nl.hauntedmc.serverfeatures.features.economy.command; + +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.tree.LiteralCommandNode; +import io.papermc.paper.command.brigadier.CommandSourceStack; +import io.papermc.paper.command.brigadier.Commands; +import nl.hauntedmc.serverfeatures.api.command.brigadier.BrigadierCommand; +import nl.hauntedmc.serverfeatures.api.economy.EconomyResult; +import nl.hauntedmc.serverfeatures.api.economy.EconomyTransferRequest; +import nl.hauntedmc.serverfeatures.features.economy.Economy; +import nl.hauntedmc.serverfeatures.features.economy.config.EconomySettings; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.HistoryItem; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.Identity; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.TopEntry; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** One dynamically registered player command root for a configured currency. */ +public final class CurrencyCommand implements BrigadierCommand { + private static final int PAGE_SIZE = 10; + private static final long CONFIRMATION_TTL_MILLIS = 30_000L; + + private final Economy feature; + private final EconomySettings.Currency currency; + private final ConcurrentHashMap confirmations = new ConcurrentHashMap<>(); + + public CurrencyCommand(Economy feature, EconomySettings.Currency currency) { + this.feature = feature; + this.currency = currency; + } + + @Override + public @NotNull String name() { + return currency.commands().root(); + } + + @Override + public List aliases() { + return currency.commands().aliases(); + } + + @Override + public String description() { + return "Manage your " + currency.display().plural() + "."; + } + + @Override + public @NotNull LiteralCommandNode buildTree() { + LiteralArgumentBuilder root = Commands.literal(name()) + .requires(source -> canUseAnyCommand(source.getSender())); + if (currency.commands().balance()) { + root.executes(context -> allowed(context.getSource().getSender(), "balance") + ? balance(context.getSource().getSender(), null) + : 0); + LiteralArgumentBuilder balance = Commands.literal("balance") + .requires(source -> allowed(source.getSender(), "balance")) + .executes(context -> balance(context.getSource().getSender(), null)); + if (currency.commands().balanceOthers()) { + balance.then(Commands.argument("player", StringArgumentType.word()) + .requires(source -> allowed(source.getSender(), "balance.others")) + .executes(context -> balance( + context.getSource().getSender(), + StringArgumentType.getString(context, "player") + ))); + } + root.then(balance); + } + if (currency.commands().pay()) { + root.then(Commands.literal("pay") + .requires(source -> allowed(source.getSender(), "pay")) + .then(Commands.argument("player", StringArgumentType.word()) + .then(Commands.argument("amount", StringArgumentType.word()) + .executes(context -> pay( + context.getSource().getSender(), + StringArgumentType.getString(context, "player"), + StringArgumentType.getString(context, "amount"), + false + ))))); + if (currency.payments().confirmationThreshold().signum() > 0) { + root.then(Commands.literal("confirm") + .requires(source -> allowed(source.getSender(), "pay")) + .executes(context -> confirm(context.getSource().getSender()))); + } + } + if (currency.commands().paytoggle()) { + LiteralArgumentBuilder toggle = Commands.literal("paytoggle") + .requires(source -> allowed(source.getSender(), "paytoggle")) + .executes(context -> showPayToggle(context.getSource().getSender())); + toggle.then(Commands.literal("on").executes(context -> setPayToggle(context.getSource().getSender(), true))); + toggle.then(Commands.literal("off").executes(context -> setPayToggle(context.getSource().getSender(), false))); + toggle.then(Commands.literal("status").executes(context -> showPayToggle(context.getSource().getSender()))); + root.then(toggle); + } + if (currency.commands().history()) { + root.then(Commands.literal("history") + .requires(source -> allowed(source.getSender(), "history")) + .executes(context -> history(context.getSource().getSender(), 1)) + .then(Commands.argument("page", IntegerArgumentType.integer(1, 10_000)) + .executes(context -> history( + context.getSource().getSender(), + IntegerArgumentType.getInteger(context, "page") + )))); + } + if (currency.commands().top()) { + root.then(Commands.literal("top") + .requires(source -> allowed(source.getSender(), "top")) + .executes(context -> top(context.getSource().getSender(), 1)) + .then(Commands.argument("page", IntegerArgumentType.integer(1, 10_000)) + .executes(context -> top( + context.getSource().getSender(), + IntegerArgumentType.getInteger(context, "page") + )))); + } + return root.build(); + } + + private int balance(CommandSender sender, String target) { + Player player = sender instanceof Player online ? online : null; + if (target == null && player == null) { + feature.send(sender, "economy.player_only"); + return 0; + } + String identifier = target == null ? player.getUniqueId().toString() : target; + feature.service().resolveIdentifier(identifier) + .thenCompose(identity -> feature.service().balance(feature.service().account(identity, currency.id()))) + .whenComplete((balance, failure) -> feature.service().main(() -> { + if (failure != null) { + feature.send(sender, "economy.error", Map.of("reason", feature.service().userFacingFailure(failure))); + return; + } + feature.send(sender, target == null ? "economy.balance.self" : "economy.balance.other", Map.of( + "player", balance.account().playerName(), + "currency", currency.display().plural(), + "balance", feature.service().format(currency.id(), balance.balance()) + )); + })); + return 1; + } + + private int pay(CommandSender sender, String target, String rawAmount, boolean confirmed) { + Player player = requirePlayer(sender); + if (player == null) { + return 0; + } + BigDecimal amount; + try { + amount = parseAmount(rawAmount); + } catch (IllegalArgumentException exception) { + feature.send(player, "economy.invalid_amount", Map.of("reason", exception.getMessage())); + return 0; + } + if (!confirmed && currency.payments().confirmationThreshold().signum() > 0 + && amount.compareTo(currency.payments().confirmationThreshold()) >= 0) { + prepareConfirmation(player, target, amount); + return 1; + } + executePayment(player, target, amount); + return 1; + } + + private int confirm(CommandSender sender) { + Player player = requirePlayer(sender); + if (player == null) { + return 0; + } + PendingPayment pending = confirmations.remove(player.getUniqueId()); + if (pending == null || System.currentTimeMillis() - pending.createdAt() > CONFIRMATION_TTL_MILLIS) { + feature.send(player, "economy.pay.no_confirmation"); + return 0; + } + executePayment(player, pending.recipient(), pending.amount()); + return 1; + } + + private void prepareConfirmation(Player player, String target, BigDecimal amount) { + feature.service().resolveIdentifier(target).whenComplete((recipient, failure) -> + feature.service().main(() -> { + if (failure != null) { + feature.send(player, "economy.pay.failed", Map.of("reason", feature.service().userFacingFailure(failure))); + return; + } + if (!player.isOnline()) { + return; + } + purgeExpiredConfirmations(); + confirmations.put( + player.getUniqueId(), + new PendingPayment(recipient, amount, System.currentTimeMillis()) + ); + feature.send(player, "economy.pay.confirm", Map.of( + "player", recipient.playerName(), + "amount", feature.service().format(currency.id(), amount), + "command", "/" + name() + " confirm" + )); + }) + ); + } + + private void executePayment(Player player, String target, BigDecimal amount) { + feature.service().resolveIdentifier(target).whenComplete((recipient, failure) -> { + if (failure != null) { + feature.service().main(() -> feature.send( + player, + "economy.pay.failed", + Map.of("reason", feature.service().userFacingFailure(failure)) + )); + return; + } + executePayment(player, recipient, amount); + }); + } + + private void executePayment(Player player, Identity recipient, BigDecimal amount) { + feature.service().resolveIdentifier(player.getUniqueId().toString()).thenCompose(sender -> { + ResolvedPayment resolved = new ResolvedPayment(sender, recipient); + return feature.service().transfer(new EconomyTransferRequest( + "player-command", + UUID.randomUUID().toString(), + feature.service().account(sender, currency.id()), + feature.service().account(recipient, currency.id()), + amount, + resolved.sender().playerId(), + resolved.sender().playerName(), + "Player payment", + Map.of("command", name()), + false + )).thenApply(result -> new PaymentResult(resolved, result)); + }).whenComplete((payment, failure) -> feature.service().main(() -> { + if (failure != null) { + feature.send(player, "economy.pay.failed", Map.of("reason", feature.service().userFacingFailure(failure))); + return; + } + EconomyResult result = payment.result(); + if (!result.successful()) { + feature.send(player, "economy.pay.failed", Map.of("reason", result.message())); + return; + } + String formatted = feature.service().format(currency.id(), amount); + feature.send(player, "economy.pay.sent", Map.of( + "player", payment.resolved().recipient().playerName(), + "amount", formatted, + "balance", feature.service().format(currency.id(), result.balance()) + )); + })); + } + + private int setPayToggle(CommandSender sender, boolean enabled) { + Player player = requirePlayer(sender); + if (player == null) { + return 0; + } + feature.service().resolveIdentifier(player.getUniqueId().toString()) + .thenCompose(identity -> feature.service().setPaymentsEnabled( + feature.service().account(identity, currency.id()), + enabled, + identity.playerId(), + identity.playerName(), + "Player payment preference", + "player-command" + )) + .whenComplete((account, failure) -> feature.service().main(() -> { + if (failure != null) { + feature.send(player, "economy.error", Map.of("reason", feature.service().userFacingFailure(failure))); + return; + } + feature.send(player, account.paymentsEnabled() + ? "economy.paytoggle.enabled" : "economy.paytoggle.disabled"); + })); + return 1; + } + + private int showPayToggle(CommandSender sender) { + Player player = requirePlayer(sender); + if (player == null) { + return 0; + } + feature.service().resolveIdentifier(player.getUniqueId().toString()) + .thenCompose(identity -> feature.service().accountState( + feature.service().account(identity, currency.id()) + )) + .whenComplete((account, failure) -> feature.service().main(() -> { + if (failure != null) { + feature.send(player, "economy.error", Map.of("reason", feature.service().userFacingFailure(failure))); + return; + } + feature.send(player, account.paymentsEnabled() + ? "economy.paytoggle.enabled" : "economy.paytoggle.disabled"); + })); + return 1; + } + + private int history(CommandSender sender, int page) { + Player player = requirePlayer(sender); + if (player == null) { + return 0; + } + feature.service().resolveIdentifier(player.getUniqueId().toString()) + .thenCompose(identity -> feature.service().history( + feature.service().account(identity, currency.id()), page, PAGE_SIZE + )) + .whenComplete((history, failure) -> feature.service().main(() -> { + if (failure != null) { + feature.send(player, "economy.error", Map.of("reason", feature.service().userFacingFailure(failure))); + return; + } + feature.send(player, "economy.history.header", Map.of("page", Integer.toString(page))); + if (history.entries().isEmpty()) { + feature.send(player, "economy.history.empty"); + return; + } + for (HistoryItem item : history.entries()) { + feature.send(player, "economy.history.entry", Map.of( + "type", item.transactionType(), + "amount", feature.service().format(currency.id(), item.delta()), + "balance", feature.service().format(currency.id(), item.balanceAfter()), + "operation", item.operationId().toString() + )); + } + })); + return 1; + } + + private int top(CommandSender sender, int page) { + feature.service().top(currency.id(), page, PAGE_SIZE).whenComplete((entries, failure) -> + feature.service().main(() -> { + if (failure != null) { + feature.send(sender, "economy.error", Map.of("reason", feature.service().userFacingFailure(failure))); + return; + } + feature.send(sender, "economy.top.header", Map.of("page", Integer.toString(page))); + int rank = (page - 1) * PAGE_SIZE; + for (TopEntry entry : entries) { + rank++; + feature.send(sender, "economy.top.entry", Map.of( + "rank", Integer.toString(rank), + "player", entry.playerName(), + "balance", feature.service().format(currency.id(), entry.balance()) + )); + } + })); + return 1; + } + + private BigDecimal parseAmount(String raw) { + if (raw == null || !raw.matches("[0-9]+(?:\\.[0-9]{1,8})?")) { + throw new IllegalArgumentException("Use a positive decimal amount"); + } + BigDecimal amount = new BigDecimal(raw).setScale( + currency.display().fractionalDigits(), currency.balances().rounding() + ); + if (amount.signum() <= 0) { + throw new IllegalArgumentException("Amount must be positive"); + } + return amount; + } + + public void evict(UUID playerUuid) { + if (playerUuid != null) { + confirmations.remove(playerUuid); + } + } + + public void close() { + confirmations.clear(); + } + + private void purgeExpiredConfirmations() { + long cutoff = System.currentTimeMillis() - CONFIRMATION_TTL_MILLIS; + confirmations.entrySet().removeIf(entry -> entry.getValue().createdAt() < cutoff); + } + + private Player requirePlayer(CommandSender sender) { + if (sender instanceof Player player) { + return player; + } + feature.send(sender, "economy.player_only"); + return null; + } + + private boolean canUseAnyCommand(CommandSender sender) { + EconomySettings.Commands commands = currency.commands(); + return commands.balance() && allowed(sender, "balance") + || commands.pay() && allowed(sender, "pay") + || commands.paytoggle() && allowed(sender, "paytoggle") + || commands.history() && allowed(sender, "history") + || commands.top() && allowed(sender, "top"); + } + + private boolean allowed(CommandSender sender, String action) { + return sender.hasPermission(permission(action)) + || sender.hasPermission("serverfeatures.feature.economy." + action); + } + + private String permission(String action) { + return "serverfeatures.feature.economy.currency." + currency.id() + "." + action; + } + + private record PendingPayment(Identity recipient, BigDecimal amount, long createdAt) { + } + + private record ResolvedPayment(Identity sender, Identity recipient) { + } + + private record PaymentResult(ResolvedPayment resolved, EconomyResult result) { + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/command/EconomyAdminCommand.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/command/EconomyAdminCommand.java new file mode 100644 index 000000000..101c8e5da --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/command/EconomyAdminCommand.java @@ -0,0 +1,414 @@ +package nl.hauntedmc.serverfeatures.features.economy.command; + +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.tree.LiteralCommandNode; +import io.papermc.paper.command.brigadier.CommandSourceStack; +import io.papermc.paper.command.brigadier.Commands; +import nl.hauntedmc.serverfeatures.api.command.brigadier.BrigadierCommand; +import nl.hauntedmc.serverfeatures.api.economy.EconomyMutationRequest; +import nl.hauntedmc.serverfeatures.api.economy.EconomyResult; +import nl.hauntedmc.serverfeatures.features.economy.Economy; +import nl.hauntedmc.serverfeatures.features.economy.config.EconomySettings; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.HistoryItem; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.Identity; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.TransactionType; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.UUID; + +/** Audited administrator interface. */ +public final class EconomyAdminCommand implements BrigadierCommand { + private static final int PAGE_SIZE = 10; + private final Economy feature; + + public EconomyAdminCommand(Economy feature) { + this.feature = feature; + } + + @Override + public @NotNull String name() { + return "economy"; + } + + @Override + public String description() { + return "Inspect and administer Economy accounts."; + } + + @Override + public @NotNull LiteralCommandNode buildTree() { + LiteralArgumentBuilder root = Commands.literal(name()) + .requires(source -> hasAnyAdminPermission(source.getSender())) + .executes(context -> statusIfPermitted(context.getSource().getSender())); + root.then(Commands.literal("status") + .requires(source -> source.getSender().hasPermission(permission("status"))) + .executes(context -> status(context.getSource().getSender()))); + root.then(Commands.literal("currencies") + .requires(source -> source.getSender().hasPermission(permission("status"))) + .executes(context -> currencies(context.getSource().getSender()))); + root.then(Commands.literal("balance") + .requires(source -> source.getSender().hasPermission(permission("balance"))) + .then(playerCurrencyArguments(this::balance))); + root.then(mutation("add", TransactionType.ADMIN_ADD)); + root.then(mutation("remove", TransactionType.ADMIN_REMOVE)); + root.then(mutation("set", TransactionType.ADMIN_SET)); + root.then(Commands.literal("payments") + .requires(source -> source.getSender().hasPermission(permission("payments"))) + .then(Commands.argument("player", StringArgumentType.word()) + .then(Commands.argument("currency", StringArgumentType.word()) + .then(Commands.literal("on").executes(context -> payments( + context.getSource().getSender(), + StringArgumentType.getString(context, "player"), + StringArgumentType.getString(context, "currency"), + true + ))) + .then(Commands.literal("off").executes(context -> payments( + context.getSource().getSender(), + StringArgumentType.getString(context, "player"), + StringArgumentType.getString(context, "currency"), + false + )))))); + root.then(freeze("freeze", true)); + root.then(freeze("unfreeze", false)); + root.then(Commands.literal("history") + .requires(source -> source.getSender().hasPermission(permission("history"))) + .then(Commands.argument("player", StringArgumentType.word()) + .then(Commands.argument("currency", StringArgumentType.word()) + .executes(context -> history( + context.getSource().getSender(), + StringArgumentType.getString(context, "player"), + StringArgumentType.getString(context, "currency"), + 1 + )) + .then(Commands.argument("page", IntegerArgumentType.integer(1, 10_000)) + .executes(context -> history( + context.getSource().getSender(), + StringArgumentType.getString(context, "player"), + StringArgumentType.getString(context, "currency"), + IntegerArgumentType.getInteger(context, "page") + )))))); + root.then(Commands.literal("verify") + .requires(source -> source.getSender().hasPermission(permission("verify"))) + .executes(context -> verify(context.getSource().getSender()))); + return root.build(); + } + + private LiteralArgumentBuilder mutation(String action, TransactionType type) { + return Commands.literal(action) + .requires(source -> source.getSender().hasPermission(permission(action))) + .then(Commands.argument("player", StringArgumentType.word()) + .then(Commands.argument("currency", StringArgumentType.word()) + .then(Commands.argument("amount", StringArgumentType.word()) + .then(Commands.argument("reason", StringArgumentType.greedyString()) + .executes(context -> mutate( + context.getSource().getSender(), + StringArgumentType.getString(context, "player"), + StringArgumentType.getString(context, "currency"), + StringArgumentType.getString(context, "amount"), + StringArgumentType.getString(context, "reason"), + type + )))))); + } + + private LiteralArgumentBuilder freeze(String literal, boolean frozen) { + return Commands.literal(literal) + .requires(source -> source.getSender().hasPermission(permission("freeze"))) + .then(Commands.argument("player", StringArgumentType.word()) + .then(Commands.argument("currency", StringArgumentType.word()) + .then(Commands.argument("reason", StringArgumentType.greedyString()) + .executes(context -> freeze( + context.getSource().getSender(), + StringArgumentType.getString(context, "player"), + StringArgumentType.getString(context, "currency"), + StringArgumentType.getString(context, "reason"), + frozen + ))))); + } + + private com.mojang.brigadier.builder.ArgumentBuilder playerCurrencyArguments( + AdminAction action + ) { + return Commands.argument("player", StringArgumentType.word()) + .then(Commands.argument("currency", StringArgumentType.word()) + .executes(context -> action.execute( + context.getSource().getSender(), + StringArgumentType.getString(context, "player"), + StringArgumentType.getString(context, "currency") + ))); + } + + private int statusIfPermitted(CommandSender sender) { + if (!sender.hasPermission(permission("status"))) { + feature.send(sender, "economy.error", Map.of("reason", "No permission")); + return 0; + } + return status(sender); + } + + private int status(CommandSender sender) { + feature.send(sender, "economy.admin.status", Map.of( + "server", feature.settings().serverKey(), + "currencies", Integer.toString(feature.settings().currencies().size()), + "vault", feature.vaultStatus(), + "messaging", feature.messagingStatus() + )); + return 1; + } + + private int currencies(CommandSender sender) { + for (EconomySettings.Currency currency : feature.settings().currencies().values()) { + feature.send(sender, "economy.admin.currency", Map.of( + "currency", currency.id(), + "scope", currency.scope().type().name(), + "scope_key", currency.scope().key(), + "command", "/" + currency.commands().root() + )); + } + return 1; + } + + private int balance(CommandSender sender, String target, String currencyId) { + resolve(target, currencyId, (identity, currency) -> feature.service().balance( + feature.service().account(identity, currency.id()) + ).whenComplete((balance, failure) -> feature.service().main(() -> { + if (failure != null) { + fail(sender, failure); + return; + } + feature.send(sender, "economy.admin.balance", Map.of( + "player", identity.playerName(), + "currency", currency.id(), + "scope", currency.scope().key(), + "balance", feature.service().format(currency.id(), balance.balance()) + )); + })), sender); + return 1; + } + + private int mutate( + CommandSender sender, + String target, + String currencyId, + String rawAmount, + String reason, + TransactionType type + ) { + if (reason == null || reason.isBlank()) { + feature.send(sender, "economy.admin.reason_required"); + return 0; + } + resolve(target, currencyId, (identity, currency) -> { + BigDecimal amount = parseAmount(rawAmount, currency, type == TransactionType.ADMIN_SET); + Long actorId = sender instanceof Player player + ? feature.service().resolveSync(player).map(Identity::playerId).orElse(null) + : null; + EconomyMutationRequest request = new EconomyMutationRequest( + "admin-command", + UUID.randomUUID().toString(), + feature.service().account(identity, currency.id()), + amount, + actorId, + sender.getName(), + reason, + Map.of("transaction_type", type.name()) + ); + feature.service().mutate(request, type, true).whenComplete((result, failure) -> + feature.service().main(() -> mutationResult(sender, identity, currency, result, failure))); + }, sender); + return 1; + } + + private int payments(CommandSender sender, String target, String currencyId, boolean enabled) { + resolve(target, currencyId, (identity, currency) -> { + Long actorId = sender instanceof Player player + ? feature.service().resolveSync(player).map(Identity::playerId).orElse(null) + : null; + feature.service().setPaymentsEnabled( + feature.service().account(identity, currency.id()), + enabled, + actorId, + sender.getName(), + "Administrator changed payment preference", + "admin-command" + ).whenComplete((account, failure) -> feature.service().main(() -> { + if (failure != null) { + fail(sender, failure); + return; + } + feature.send(sender, "economy.admin.payments", Map.of( + "player", identity.playerName(), + "state", enabled ? "on" : "off" + )); + })); + }, sender); + return 1; + } + + private int freeze(CommandSender sender, String target, String currencyId, String reason, boolean frozen) { + resolve(target, currencyId, (identity, currency) -> { + Long actor = sender instanceof Player player + ? feature.service().resolveSync(player).map(Identity::playerId).orElse(null) + : null; + feature.service().setFrozen( + feature.service().account(identity, currency.id()), + frozen, + actor, + sender.getName(), + reason + ).whenComplete((account, failure) -> feature.service().main(() -> { + if (failure != null) { + fail(sender, failure); + return; + } + feature.send(sender, frozen ? "economy.admin.frozen" : "economy.admin.unfrozen", Map.of( + "player", identity.playerName(), + "currency", currency.id() + )); + })); + }, sender); + return 1; + } + + private int history(CommandSender sender, String target, String currencyId, int page) { + resolve(target, currencyId, (identity, currency) -> feature.service().history( + feature.service().account(identity, currency.id()), page, PAGE_SIZE + ).whenComplete((history, failure) -> feature.service().main(() -> { + if (failure != null) { + fail(sender, failure); + return; + } + feature.send(sender, "economy.history.header", Map.of("page", Integer.toString(page))); + for (HistoryItem item : history.entries()) { + feature.send(sender, "economy.history.entry", Map.of( + "type", item.transactionType(), + "amount", feature.service().format(currency.id(), item.delta()), + "balance", feature.service().format(currency.id(), item.balanceAfter()), + "operation", item.operationId().toString() + )); + } + })), sender); + return 1; + } + + private int verify(CommandSender sender) { + feature.service().verify().whenComplete((report, failure) -> feature.service().main(() -> { + if (failure != null) { + fail(sender, failure); + return; + } + feature.send(sender, "economy.admin.verify", Map.of( + "health", report.healthy() ? "healthy" : "issues", + "accounts", Long.toString(report.accountCount()), + "transactions", Long.toString(report.transactionCount()), + "invalid", Long.toString(report.invalidBalanceCount()), + "invalid_entries", Long.toString(report.invalidEntryCount()), + "orphan_settings", Long.toString(report.orphanSettingsCount()), + "orphan_entries", Long.toString(report.orphanEntryCount()), + "identity_mismatches", Long.toString(report.identityMismatchCount()), + "accounts_without_entries", Long.toString(report.accountWithoutEntriesCount()), + "empty_transactions", Long.toString(report.transactionWithoutEntriesCount()) + )); + })); + return 1; + } + + private void resolve(String target, String currencyId, ResolvedAction action, CommandSender sender) { + EconomySettings.Currency currency; + try { + currency = feature.settings().requireCurrency(currencyId); + } catch (RuntimeException exception) { + feature.send(sender, "economy.error", Map.of("reason", exception.getMessage())); + return; + } + feature.service().resolveIdentifier(target).whenComplete((identity, failure) -> { + if (failure != null) { + feature.service().main(() -> fail(sender, failure)); + return; + } + try { + action.execute(identity, currency); + } catch (RuntimeException exception) { + feature.service().main(() -> fail(sender, exception)); + } + }); + } + + private void mutationResult( + CommandSender sender, + Identity identity, + EconomySettings.Currency currency, + EconomyResult result, + Throwable failure + ) { + if (failure != null) { + fail(sender, failure); + return; + } + if (!result.successful()) { + feature.send(sender, "economy.error", Map.of("reason", result.message())); + return; + } + feature.send(sender, "economy.admin.changed", Map.of( + "player", identity.playerName(), + "currency", currency.id(), + "balance", feature.service().format(currency.id(), result.balance()), + "operation", result.operationId() == null ? "-" : result.operationId().toString() + )); + } + + private BigDecimal parseAmount(String raw, EconomySettings.Currency currency, boolean setOperation) { + String pattern = setOperation && currency.balances().allowNegative() + ? "-?[0-9]+(?:\\.[0-9]{1,8})?" + : "[0-9]+(?:\\.[0-9]{1,8})?"; + if (raw == null || !raw.matches(pattern)) { + throw new IllegalArgumentException("Invalid amount"); + } + BigDecimal amount = new BigDecimal(raw).setScale( + currency.display().fractionalDigits(), currency.balances().rounding() + ); + boolean invalid = setOperation + ? !currency.balances().allowNegative() && amount.signum() < 0 + : amount.signum() <= 0; + if (invalid) { + String message = setOperation ? "Amount is outside the currency bounds" : "Amount must be positive"; + throw new IllegalArgumentException(message); + } + return amount; + } + + private void fail(CommandSender sender, Throwable failure) { + feature.send(sender, "economy.error", Map.of("reason", feature.service().userFacingFailure(failure))); + } + + private boolean hasAnyAdminPermission(CommandSender sender) { + return sender.hasPermission(permission("status")) + || sender.hasPermission(permission("balance")) + || sender.hasPermission(permission("add")) + || sender.hasPermission(permission("remove")) + || sender.hasPermission(permission("set")) + || sender.hasPermission(permission("payments")) + || sender.hasPermission(permission("freeze")) + || sender.hasPermission(permission("history")) + || sender.hasPermission(permission("verify")); + } + + private String permission(String action) { + return "serverfeatures.feature.economy.admin." + action; + } + + @FunctionalInterface + private interface AdminAction { + int execute(CommandSender sender, String player, String currency); + } + + @FunctionalInterface + private interface ResolvedAction { + void execute(Identity identity, EconomySettings.Currency currency); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/config/EconomySettings.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/config/EconomySettings.java new file mode 100644 index 000000000..529b469f8 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/config/EconomySettings.java @@ -0,0 +1,531 @@ +package nl.hauntedmc.serverfeatures.features.economy.config; + +import nl.hauntedmc.serverfeatures.api.economy.EconomyScope; +import nl.hauntedmc.serverfeatures.api.economy.EconomyScopeType; +import nl.hauntedmc.serverfeatures.api.io.config.ConfigNode; +import nl.hauntedmc.serverfeatures.framework.config.FeatureConfigHandler; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** Strict immutable configuration for the Economy feature. */ +public record EconomySettings( + String networkKey, + String serverKey, + String databaseConnection, + Vault vault, + Messaging messaging, + Cache cache, + Map currencies +) { + + private static final String KEY_PATTERN = "[a-z0-9][a-z0-9_.-]{0,63}"; + + public EconomySettings { + networkKey = key(networkKey, "network_key"); + serverKey = key(serverKey, "server_key"); + databaseConnection = requireText(databaseConnection, "database.connection"); + Objects.requireNonNull(vault, "vault"); + Objects.requireNonNull(messaging, "messaging"); + Objects.requireNonNull(cache, "cache"); + currencies = Collections.unmodifiableMap(new LinkedHashMap<>(currencies)); + if (currencies.isEmpty()) { + throw new IllegalArgumentException("At least one enabled currency is required"); + } + if (vault.enabled() && !currencies.containsKey(vault.primaryCurrency())) { + throw new IllegalArgumentException("vault.primary_currency must reference an enabled currency"); + } + validateCommandLabels(currencies.values()); + } + + public static EconomySettings load(FeatureConfigHandler config, String globalServerName) { + String networkKey = key(text(config.node(), "network_key", "hauntedmc"), "network_key"); + String configuredServer = firstNonBlank( + text(config.node(), "local_key", ""), + text(config.node(), "gamemode_key", ""), + text(config.node(), "server_key", "$server") + ); + String serverKey = "$server".equalsIgnoreCase(configuredServer) + ? key(globalServerName, "global server_name") + : key(configuredServer, "server_key"); + String connection = text(config.node(), "database.connection", "system_data_rw"); + + Vault vault = new Vault( + bool(config.node(), "vault.enabled", true), + normalizeCurrencyId(text(config.node(), "vault.primary_currency", "money")), + enumValue(VaultConflictPolicy.class, text(config.node(), "vault.conflict_policy", "FAIL"), + "vault.conflict_policy") + ); + Messaging messaging = new Messaging( + bool(config.node(), "messaging.enabled", true), + text(config.node(), "messaging.connection", "hauntedmc"), + text(config.node(), "messaging.channel", "serverfeatures.economy.balance") + ); + Cache cache = new Cache(duration( + config.node(), + "cache.authoritative_refresh_interval", + "10s", + Duration.ofSeconds(1), + Duration.ofMinutes(5) + )); + + ConfigNode currenciesNode = config.node("currencies"); + Map currencies = new LinkedHashMap<>(); + for (Map.Entry entry : currenciesNode.children().entrySet()) { + String id = normalizeCurrencyId(entry.getKey()); + ConfigNode node = entry.getValue(); + if (!bool(node, "enabled", true)) { + continue; + } + EconomyScopeType scopeType = scopeType( + text(node, "scope.type", "SERVER"), + "currencies." + id + ".scope.type" + ); + String scopeKey = switch (scopeType) { + case SERVER -> networkKey + "/server/" + localScopeKey(node, id, serverKey); + case GROUP -> networkKey + "/group/" + key( + text(node, "scope.group_key", ""), + "currencies." + id + ".scope.group_key" + ); + case GLOBAL -> networkKey + "/global"; + }; + if (scopeKey.length() > 128) { + throw new IllegalArgumentException( + "Resolved scope key for currency " + id + " exceeds 128 characters" + ); + } + + int fractionalDigits = integer(node, "display.fractional_digits", 2, 0, 8); + RoundingMode roundingMode = enumValue( + RoundingMode.class, + text(node, "balances.rounding", "HALF_UP"), + "currencies." + id + ".balances.rounding" + ); + BigDecimal starting = amount(node, "balances.starting", "0", fractionalDigits, roundingMode); + BigDecimal minimum = amount(node, "balances.minimum", "0", fractionalDigits, roundingMode); + BigDecimal maximum = amount( + node, + "balances.maximum", + "999999999999999999999999999999.99999999", + fractionalDigits, + roundingMode + ); + boolean allowNegative = bool(node, "balances.allow_negative", false); + if (!allowNegative && minimum.signum() < 0) { + throw new IllegalArgumentException("Currency " + id + " has a negative minimum while allow_negative is false"); + } + if (minimum.compareTo(maximum) > 0) { + throw new IllegalArgumentException("Currency " + id + " minimum balance exceeds maximum balance"); + } + if (starting.compareTo(minimum) < 0 || starting.compareTo(maximum) > 0) { + throw new IllegalArgumentException("Currency " + id + " starting balance is outside configured bounds"); + } + + Commands commands = new Commands( + commandLabel(text(node, "commands.root", id)), + aliases(node.getAt("commands.aliases")), + bool(node, "commands.balance", true), + bool(node, "commands.balance_others", true), + bool(node, "commands.pay", true), + bool(node, "commands.paytoggle", true), + bool(node, "commands.history", true), + bool(node, "commands.top", false) + ); + ConfigNode offlineRecipientSetting = node.getAt("payments.allow_offline_recipient"); + if (!offlineRecipientSetting.isNull() + && !offlineRecipientSetting.as(Boolean.class, true)) { + throw new IllegalArgumentException( + "Currency " + id + " cannot disable offline recipients: " + + "known players must remain payable across the network" + ); + } + String minimumPaymentDefault = BigDecimal.ONE + .movePointLeft(fractionalDigits) + .setScale(fractionalDigits) + .toPlainString(); + Payments payments = new Payments( + bool(node, "payments.default_enabled", true), + positiveAmount(node, "payments.minimum", fractionalDigits, roundingMode, minimumPaymentDefault), + nonNegativeAmount(node, "payments.maximum", fractionalDigits, roundingMode, "0"), + nonNegativeAmount(node, "payments.confirmation_threshold", fractionalDigits, roundingMode, "0"), + nonNegativeAmount(node, "payments.daily_send_limit", fractionalDigits, roundingMode, "0"), + nonNegativeAmount(node, "payments.daily_receive_limit", fractionalDigits, roundingMode, "0"), + duration(node, "payments.cooldown", "1s", Duration.ZERO, Duration.ofHours(1)) + ); + if (!commands.pay() && commands.paytoggle()) { + throw new IllegalArgumentException("Currency " + id + " enables paytoggle while pay is disabled"); + } + if (payments.maximum().signum() > 0 && payments.maximum().compareTo(payments.minimum()) < 0) { + throw new IllegalArgumentException("Currency " + id + " payment maximum is below the minimum"); + } + if (payments.dailySendLimit().signum() > 0 + && payments.dailySendLimit().compareTo(payments.minimum()) < 0) { + throw new IllegalArgumentException("Currency " + id + " daily send limit is below the minimum payment"); + } + if (payments.dailyReceiveLimit().signum() > 0 + && payments.dailyReceiveLimit().compareTo(payments.minimum()) < 0) { + throw new IllegalArgumentException( + "Currency " + id + " daily receive limit is below the minimum payment" + ); + } + + Currency currency = new Currency( + id, + new EconomyScope(scopeType, scopeKey), + new Display( + text(node, "display.singular", id), + text(node, "display.plural", id), + text(node, "display.symbol", ""), + text(node, "display.format", "{symbol}{amount}"), + fractionalDigits, + bool(node, "display.grouping", true) + ), + new Balances(starting, minimum, maximum, allowNegative, roundingMode), + commands, + payments + ); + if (currencies.putIfAbsent(id, currency) != null) { + throw new IllegalArgumentException("Duplicate normalized currency id: " + id); + } + } + return new EconomySettings(networkKey, serverKey, connection, vault, messaging, cache, currencies); + } + + public Currency requireCurrency(String id) { + Currency currency = currencies.get(normalizeCurrencyId(id)); + if (currency == null) { + throw new IllegalArgumentException("Unknown currency: " + id); + } + return currency; + } + + public record Vault(boolean enabled, String primaryCurrency, VaultConflictPolicy conflictPolicy) { + public Vault { + primaryCurrency = normalizeCurrencyId(primaryCurrency); + Objects.requireNonNull(conflictPolicy, "conflictPolicy"); + } + } + + public record Messaging(boolean enabled, String connection, String channel) { + public Messaging { + connection = requireText(connection, "messaging.connection"); + channel = requireText(channel, "messaging.channel"); + } + } + + public record Cache(Duration authoritativeRefreshInterval) { + public Cache { + Objects.requireNonNull(authoritativeRefreshInterval, "authoritativeRefreshInterval"); + if (authoritativeRefreshInterval.isZero() || authoritativeRefreshInterval.isNegative()) { + throw new IllegalArgumentException("cache.authoritative_refresh_interval must be positive"); + } + } + } + + public record Currency( + String id, + EconomyScope scope, + Display display, + Balances balances, + Commands commands, + Payments payments + ) { + public Currency { + id = normalizeCurrencyId(id); + Objects.requireNonNull(scope, "scope"); + Objects.requireNonNull(display, "display"); + Objects.requireNonNull(balances, "balances"); + Objects.requireNonNull(commands, "commands"); + Objects.requireNonNull(payments, "payments"); + } + } + + public record Display( + String singular, + String plural, + String symbol, + String format, + int fractionalDigits, + boolean grouping + ) { + public Display { + singular = requireText(singular, "display.singular"); + plural = requireText(plural, "display.plural"); + symbol = symbol == null ? "" : symbol; + format = requireText(format, "display.format"); + } + } + + public record Balances( + BigDecimal starting, + BigDecimal minimum, + BigDecimal maximum, + boolean allowNegative, + RoundingMode rounding + ) { + public Balances { + Objects.requireNonNull(starting, "starting"); + Objects.requireNonNull(minimum, "minimum"); + Objects.requireNonNull(maximum, "maximum"); + Objects.requireNonNull(rounding, "rounding"); + } + } + + public record Commands( + String root, + List aliases, + boolean balance, + boolean balanceOthers, + boolean pay, + boolean paytoggle, + boolean history, + boolean top + ) { + public Commands { + root = commandLabel(root); + aliases = List.copyOf(aliases); + } + } + + public record Payments( + boolean defaultEnabled, + BigDecimal minimum, + BigDecimal maximum, + BigDecimal confirmationThreshold, + BigDecimal dailySendLimit, + BigDecimal dailyReceiveLimit, + Duration cooldown + ) { + public Payments { + Objects.requireNonNull(minimum, "minimum"); + Objects.requireNonNull(maximum, "maximum"); + Objects.requireNonNull(confirmationThreshold, "confirmationThreshold"); + Objects.requireNonNull(dailySendLimit, "dailySendLimit"); + Objects.requireNonNull(dailyReceiveLimit, "dailyReceiveLimit"); + Objects.requireNonNull(cooldown, "cooldown"); + } + } + + public enum VaultConflictPolicy { + FAIL, + SKIP, + REPLACE + } + + public static String normalizeCurrencyId(String value) { + String id = requireText(value, "currency id").toLowerCase(Locale.ROOT); + if (!id.matches(KEY_PATTERN)) { + throw new IllegalArgumentException("Invalid currency id: " + value); + } + return id; + } + + + private static EconomyScopeType scopeType(String raw, String field) { + String normalized = raw.trim().toUpperCase(Locale.ROOT); + if (normalized.equals("LOCAL") || normalized.equals("GAMEMODE")) { + return EconomyScopeType.SERVER; + } + return enumValue(EconomyScopeType.class, normalized, field); + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return ""; + } + + private static String localScopeKey(ConfigNode node, String currencyId, String fallback) { + String configured = text(node, "scope.local_key", ""); + if (configured.isBlank()) { + configured = text(node, "scope.gamemode_key", ""); + } + if (configured.isBlank()) { + configured = text(node, "scope.server_key", ""); + } + return key(configured.isBlank() ? fallback : configured, + "currencies." + currencyId + ".scope.local_key"); + } + + private static void validateCommandLabels(Iterable currencies) { + Set labels = new LinkedHashSet<>(Set.of("economy")); + for (Currency currency : currencies) { + List candidateLabels = new ArrayList<>(); + candidateLabels.add(currency.commands().root()); + candidateLabels.addAll(currency.commands().aliases()); + for (String label : candidateLabels) { + if (!labels.add(label.toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException( + "Reserved or duplicate economy command label: " + label + ); + } + } + } + } + + private static List aliases(ConfigNode node) { + if (node.isNull()) { + return List.of(); + } + List result = new ArrayList<>(); + for (String alias : node.listOf(String.class)) { + String normalized = commandLabel(alias); + if (!result.contains(normalized)) { + result.add(normalized); + } + } + return result; + } + + private static String commandLabel(String value) { + String label = requireText(value, "command label").toLowerCase(Locale.ROOT); + if (!label.matches("[a-z0-9][a-z0-9_-]{0,31}")) { + throw new IllegalArgumentException("Invalid command label: " + value); + } + return label; + } + + private static String key(String value, String field) { + String normalized = requireText(value, field).toLowerCase(Locale.ROOT).replace(' ', '-'); + if (!normalized.matches(KEY_PATTERN)) { + throw new IllegalArgumentException(field + " must match " + KEY_PATTERN); + } + return normalized; + } + + private static String requireText(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return value.trim(); + } + + private static String text(ConfigNode node, String path, String fallback) { + String value = node.getAt(path).as(String.class, fallback); + return value == null ? fallback : value.trim(); + } + + private static boolean bool(ConfigNode node, String path, boolean fallback) { + return node.getAt(path).as(Boolean.class, fallback); + } + + private static int integer(ConfigNode node, String path, int fallback, int minimum, int maximum) { + int value = node.getAt(path).as(Integer.class, fallback); + if (value < minimum || value > maximum) { + throw new IllegalArgumentException(path + " must be between " + minimum + " and " + maximum); + } + return value; + } + + private static BigDecimal amount( + ConfigNode node, + String path, + String fallback, + int fractionalDigits, + RoundingMode roundingMode + ) { + String value = text(node, path, fallback); + try { + BigDecimal amount = new BigDecimal(value); + long integerDigits = (long) amount.precision() - amount.scale(); + if (amount.scale() > 8 || amount.signum() != 0 && integerDigits > 30L) { + throw new IllegalArgumentException(path + " exceeds DECIMAL(38,8) storage precision"); + } + if (amount.scale() > fractionalDigits) { + amount = amount.setScale(fractionalDigits, roundingMode); + } + BigDecimal normalized = amount.setScale(fractionalDigits, roundingMode); + if (normalized.precision() > 38) { + throw new IllegalArgumentException(path + " exceeds DECIMAL(38,8) storage precision"); + } + return normalized; + } catch (NumberFormatException | ArithmeticException exception) { + throw new IllegalArgumentException("Invalid amount at " + path + ": " + value, exception); + } + } + + private static BigDecimal positiveAmount( + ConfigNode node, + String path, + int fractionalDigits, + RoundingMode roundingMode, + String fallback + ) { + BigDecimal value = amount(node, path, fallback, fractionalDigits, roundingMode); + if (value.signum() <= 0) { + throw new IllegalArgumentException(path + " must be positive"); + } + return value; + } + + private static BigDecimal nonNegativeAmount( + ConfigNode node, + String path, + int fractionalDigits, + RoundingMode roundingMode, + String fallback + ) { + BigDecimal value = amount(node, path, fallback, fractionalDigits, roundingMode); + if (value.signum() < 0) { + throw new IllegalArgumentException(path + " must not be negative"); + } + return value; + } + + private static Duration duration( + ConfigNode node, + String path, + String fallback, + Duration minimum, + Duration maximum + ) { + String raw = text(node, path, fallback).toLowerCase(Locale.ROOT); + long multiplier; + String number; + if (raw.endsWith("ms")) { + multiplier = 1L; + number = raw.substring(0, raw.length() - 2); + } else if (raw.endsWith("s")) { + multiplier = 1_000L; + number = raw.substring(0, raw.length() - 1); + } else if (raw.endsWith("m")) { + multiplier = 60_000L; + number = raw.substring(0, raw.length() - 1); + } else if (raw.endsWith("h")) { + multiplier = 3_600_000L; + number = raw.substring(0, raw.length() - 1); + } else { + throw new IllegalArgumentException("Invalid duration at " + path + ": " + raw); + } + try { + Duration duration = Duration.ofMillis(Math.multiplyExact(Long.parseLong(number.trim()), multiplier)); + if (duration.compareTo(minimum) < 0 || duration.compareTo(maximum) > 0) { + throw new IllegalArgumentException(path + " is outside the allowed range"); + } + return duration; + } catch (NumberFormatException | ArithmeticException exception) { + throw new IllegalArgumentException("Invalid duration at " + path + ": " + raw, exception); + } + } + + private static > E enumValue(Class type, String raw, String field) { + try { + return Enum.valueOf(type, raw.trim().toUpperCase(Locale.ROOT)); + } catch (RuntimeException exception) { + throw new IllegalArgumentException("Invalid " + field + ": " + raw, exception); + } + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyBalanceEntity.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyBalanceEntity.java new file mode 100644 index 000000000..824840866 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyBalanceEntity.java @@ -0,0 +1,74 @@ +package nl.hauntedmc.serverfeatures.features.economy.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; + +import java.math.BigDecimal; + +@Entity +@Table( + name = "player_economy_balance", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_economy_balance_account", + columnNames = {"player_id", "currency_id", "scope_key"} + ), + @UniqueConstraint( + name = "uq_economy_balance_uuid_account", + columnNames = {"player_uuid", "currency_id", "scope_key"} + ) + }, + indexes = { + @Index(name = "idx_economy_balance_top", columnList = "currency_id,scope_key,balance"), + @Index(name = "idx_economy_balance_player_scope", columnList = "player_id,scope_key") + } +) +public class EconomyBalanceEntity { + @Id + @Column(name = "id", length = 192, nullable = false) + private String id; + @Column(name = "player_id", nullable = false) + private long playerId; + @Column(name = "player_uuid", length = 36, nullable = false) + private String playerUuid; + @Column(name = "player_name", length = 32, nullable = false) + private String playerName; + @Column(name = "currency_id", length = 64, nullable = false) + private String currencyId; + @Column(name = "scope_key", length = 128, nullable = false) + private String scopeKey; + @Column(name = "balance", precision = 38, scale = 8, nullable = false) + private BigDecimal balance; + @Version + @Column(name = "version", nullable = false) + private long version; + @Column(name = "created_at", nullable = false) + private long createdAt; + @Column(name = "updated_at", nullable = false) + private long updatedAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public long getPlayerId() { return playerId; } + public void setPlayerId(long playerId) { this.playerId = playerId; } + public String getPlayerUuid() { return playerUuid; } + public void setPlayerUuid(String playerUuid) { this.playerUuid = playerUuid; } + public String getPlayerName() { return playerName; } + public void setPlayerName(String playerName) { this.playerName = playerName; } + public String getCurrencyId() { return currencyId; } + public void setCurrencyId(String currencyId) { this.currencyId = currencyId; } + public String getScopeKey() { return scopeKey; } + public void setScopeKey(String scopeKey) { this.scopeKey = scopeKey; } + public BigDecimal getBalance() { return balance; } + public void setBalance(BigDecimal balance) { this.balance = balance; } + public long getVersion() { return version; } + public long getCreatedAt() { return createdAt; } + public void setCreatedAt(long createdAt) { this.createdAt = createdAt; } + public long getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(long updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyCurrencyDefinitionEntity.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyCurrencyDefinitionEntity.java new file mode 100644 index 000000000..80cf2a298 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyCurrencyDefinitionEntity.java @@ -0,0 +1,70 @@ +package nl.hauntedmc.serverfeatures.features.economy.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.math.BigDecimal; + +@Entity +@Table( + name = "system_economy_currency_definition", + uniqueConstraints = @UniqueConstraint( + name = "uq_economy_currency_definition", + columnNames = {"currency_id", "scope_key"} + ) +) +public class EconomyCurrencyDefinitionEntity { + @Id + @Column(name = "id", length = 192, nullable = false) + private String id; + @Column(name = "currency_id", length = 64, nullable = false) + private String currencyId; + @Column(name = "scope_key", length = 128, nullable = false) + private String scopeKey; + @Column(name = "scope_type", length = 16, nullable = false) + private String scopeType; + @Column(name = "fractional_digits", nullable = false) + private int fractionalDigits; + @Column(name = "starting_balance", precision = 38, scale = 8, nullable = false) + private BigDecimal startingBalance; + @Column(name = "minimum_balance", precision = 38, scale = 8, nullable = false) + private BigDecimal minimumBalance; + @Column(name = "maximum_balance", precision = 38, scale = 8, nullable = false) + private BigDecimal maximumBalance; + @Column(name = "allow_negative", nullable = false) + private boolean allowNegative; + @Column(name = "definition_hash", length = 64, nullable = false) + private String definitionHash; + @Column(name = "created_at", nullable = false) + private long createdAt; + @Column(name = "updated_at", nullable = false) + private long updatedAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getCurrencyId() { return currencyId; } + public void setCurrencyId(String currencyId) { this.currencyId = currencyId; } + public String getScopeKey() { return scopeKey; } + public void setScopeKey(String scopeKey) { this.scopeKey = scopeKey; } + public String getScopeType() { return scopeType; } + public void setScopeType(String scopeType) { this.scopeType = scopeType; } + public int getFractionalDigits() { return fractionalDigits; } + public void setFractionalDigits(int fractionalDigits) { this.fractionalDigits = fractionalDigits; } + public BigDecimal getStartingBalance() { return startingBalance; } + public void setStartingBalance(BigDecimal startingBalance) { this.startingBalance = startingBalance; } + public BigDecimal getMinimumBalance() { return minimumBalance; } + public void setMinimumBalance(BigDecimal minimumBalance) { this.minimumBalance = minimumBalance; } + public BigDecimal getMaximumBalance() { return maximumBalance; } + public void setMaximumBalance(BigDecimal maximumBalance) { this.maximumBalance = maximumBalance; } + public boolean isAllowNegative() { return allowNegative; } + public void setAllowNegative(boolean allowNegative) { this.allowNegative = allowNegative; } + public String getDefinitionHash() { return definitionHash; } + public void setDefinitionHash(String definitionHash) { this.definitionHash = definitionHash; } + public long getCreatedAt() { return createdAt; } + public void setCreatedAt(long createdAt) { this.createdAt = createdAt; } + public long getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(long updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyCurrencyFamilyEntity.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyCurrencyFamilyEntity.java new file mode 100644 index 000000000..d9d5015db --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyCurrencyFamilyEntity.java @@ -0,0 +1,57 @@ +package nl.hauntedmc.serverfeatures.features.economy.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +/** Network-level guard preventing one currency ID from resolving to incompatible scope families. */ +@Entity +@Table( + name = "system_economy_currency_family", + uniqueConstraints = @UniqueConstraint( + name = "uq_economy_currency_family", + columnNames = {"network_key", "currency_id"} + ) +) +public class EconomyCurrencyFamilyEntity { + @Id + @Column(name = "id", length = 160, nullable = false) + private String id; + @Column(name = "network_key", length = 64, nullable = false) + private String networkKey; + @Column(name = "currency_id", length = 64, nullable = false) + private String currencyId; + @Column(name = "scope_type", length = 16, nullable = false) + private String scopeType; + @Column(name = "fractional_digits", nullable = false) + private int fractionalDigits; + @Column(name = "global_scope_key", length = 128) + private String globalScopeKey; + @Column(name = "family_hash", length = 64, nullable = false) + private String familyHash; + @Column(name = "created_at", nullable = false) + private long createdAt; + @Column(name = "updated_at", nullable = false) + private long updatedAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getNetworkKey() { return networkKey; } + public void setNetworkKey(String networkKey) { this.networkKey = networkKey; } + public String getCurrencyId() { return currencyId; } + public void setCurrencyId(String currencyId) { this.currencyId = currencyId; } + public String getScopeType() { return scopeType; } + public void setScopeType(String scopeType) { this.scopeType = scopeType; } + public int getFractionalDigits() { return fractionalDigits; } + public void setFractionalDigits(int fractionalDigits) { this.fractionalDigits = fractionalDigits; } + public String getGlobalScopeKey() { return globalScopeKey; } + public void setGlobalScopeKey(String globalScopeKey) { this.globalScopeKey = globalScopeKey; } + public String getFamilyHash() { return familyHash; } + public void setFamilyHash(String familyHash) { this.familyHash = familyHash; } + public long getCreatedAt() { return createdAt; } + public void setCreatedAt(long createdAt) { this.createdAt = createdAt; } + public long getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(long updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyDailyUsageEntity.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyDailyUsageEntity.java new file mode 100644 index 000000000..46191a259 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyDailyUsageEntity.java @@ -0,0 +1,48 @@ +package nl.hauntedmc.serverfeatures.features.economy.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +import java.math.BigDecimal; + +@Entity +@Table(name = "player_economy_daily_usage") +public class EconomyDailyUsageEntity { + @Id + @Column(name = "id", length = 224, nullable = false) + private String id; + @Column(name = "account_id", length = 192, nullable = false) + private String accountId; + @Column(name = "usage_date", length = 10, nullable = false) + private String usageDate; + @Column(name = "sent_amount", precision = 38, scale = 8, nullable = false) + private BigDecimal sentAmount; + @Column(name = "received_amount", precision = 38, scale = 8, nullable = false) + private BigDecimal receivedAmount; + @Column(name = "sent_count", nullable = false) + private int sentCount; + @Version + @Column(name = "version", nullable = false) + private long version; + @Column(name = "updated_at", nullable = false) + private long updatedAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getAccountId() { return accountId; } + public void setAccountId(String accountId) { this.accountId = accountId; } + public String getUsageDate() { return usageDate; } + public void setUsageDate(String usageDate) { this.usageDate = usageDate; } + public BigDecimal getSentAmount() { return sentAmount; } + public void setSentAmount(BigDecimal sentAmount) { this.sentAmount = sentAmount; } + public BigDecimal getReceivedAmount() { return receivedAmount; } + public void setReceivedAmount(BigDecimal receivedAmount) { this.receivedAmount = receivedAmount; } + public int getSentCount() { return sentCount; } + public void setSentCount(int sentCount) { this.sentCount = sentCount; } + public long getVersion() { return version; } + public long getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(long updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyPlayerIdentityEntity.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyPlayerIdentityEntity.java new file mode 100644 index 000000000..a75f0dd57 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyPlayerIdentityEntity.java @@ -0,0 +1,46 @@ +package nl.hauntedmc.serverfeatures.features.economy.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; + +/** Canonical immutable player ID to UUID ownership used by all economy scopes and currencies. */ +@Entity +@Table( + name = "player_economy_identity", + uniqueConstraints = @UniqueConstraint( + name = "uq_economy_identity_uuid", + columnNames = "player_uuid" + ) +) +public class EconomyPlayerIdentityEntity { + @Id + @Column(name = "player_id", nullable = false) + private long playerId; + @Column(name = "player_uuid", length = 36, nullable = false) + private String playerUuid; + @Column(name = "player_name", length = 32, nullable = false) + private String playerName; + @Version + @Column(name = "version", nullable = false) + private long version; + @Column(name = "created_at", nullable = false) + private long createdAt; + @Column(name = "updated_at", nullable = false) + private long updatedAt; + + public long getPlayerId() { return playerId; } + public void setPlayerId(long playerId) { this.playerId = playerId; } + public String getPlayerUuid() { return playerUuid; } + public void setPlayerUuid(String playerUuid) { this.playerUuid = playerUuid; } + public String getPlayerName() { return playerName; } + public void setPlayerName(String playerName) { this.playerName = playerName; } + public long getVersion() { return version; } + public long getCreatedAt() { return createdAt; } + public void setCreatedAt(long createdAt) { this.createdAt = createdAt; } + public long getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(long updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyPlayerSettingsEntity.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyPlayerSettingsEntity.java new file mode 100644 index 000000000..530f85484 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyPlayerSettingsEntity.java @@ -0,0 +1,50 @@ +package nl.hauntedmc.serverfeatures.features.economy.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +@Entity +@Table(name = "player_economy_settings") +public class EconomyPlayerSettingsEntity { + @Id + @Column(name = "account_id", length = 192, nullable = false) + private String accountId; + @Column(name = "payments_enabled", nullable = false) + private boolean paymentsEnabled; + @Column(name = "account_status", length = 24, nullable = false) + private String accountStatus; + @Column(name = "status_reason", length = 255) + private String statusReason; + @Column(name = "status_actor_player_id") + private Long statusActorPlayerId; + @Column(name = "last_payment_at") + private Long lastPaymentAt; + @Version + @Column(name = "version", nullable = false) + private long version; + @Column(name = "created_at", nullable = false) + private long createdAt; + @Column(name = "updated_at", nullable = false) + private long updatedAt; + + public String getAccountId() { return accountId; } + public void setAccountId(String accountId) { this.accountId = accountId; } + public boolean isPaymentsEnabled() { return paymentsEnabled; } + public void setPaymentsEnabled(boolean paymentsEnabled) { this.paymentsEnabled = paymentsEnabled; } + public String getAccountStatus() { return accountStatus; } + public void setAccountStatus(String accountStatus) { this.accountStatus = accountStatus; } + public String getStatusReason() { return statusReason; } + public void setStatusReason(String statusReason) { this.statusReason = statusReason; } + public Long getStatusActorPlayerId() { return statusActorPlayerId; } + public void setStatusActorPlayerId(Long statusActorPlayerId) { this.statusActorPlayerId = statusActorPlayerId; } + public Long getLastPaymentAt() { return lastPaymentAt; } + public void setLastPaymentAt(Long lastPaymentAt) { this.lastPaymentAt = lastPaymentAt; } + public long getVersion() { return version; } + public long getCreatedAt() { return createdAt; } + public void setCreatedAt(long createdAt) { this.createdAt = createdAt; } + public long getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(long updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyTransactionEntity.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyTransactionEntity.java new file mode 100644 index 000000000..547b93fd7 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyTransactionEntity.java @@ -0,0 +1,83 @@ +package nl.hauntedmc.serverfeatures.features.economy.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +@Entity +@Table( + name = "system_economy_transaction", + uniqueConstraints = @UniqueConstraint( + name = "uq_economy_transaction_idempotency", + columnNames = {"source", "idempotency_key_hash"} + ), + indexes = { + @Index(name = "idx_economy_transaction_scope", columnList = "currency_id,scope_key,created_at"), + @Index(name = "idx_economy_transaction_operation", columnList = "operation_id") + } +) +public class EconomyTransactionEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + @Column(name = "operation_id", length = 36, nullable = false, unique = true) + private String operationId; + @Column(name = "source", length = 64, nullable = false) + private String source; + @Column(name = "idempotency_key", length = 160, nullable = false) + private String idempotencyKey; + @Column(name = "idempotency_key_hash", length = 64, nullable = false) + private String idempotencyKeyHash; + @Column(name = "request_fingerprint", length = 64, nullable = false) + private String requestFingerprint; + @Column(name = "transaction_type", length = 32, nullable = false) + private String transactionType; + @Column(name = "currency_id", length = 64, nullable = false) + private String currencyId; + @Column(name = "scope_key", length = 128, nullable = false) + private String scopeKey; + @Column(name = "actor_player_id") + private Long actorPlayerId; + @Column(name = "actor_name", length = 64, nullable = false) + private String actorName; + @Column(name = "reason", length = 255, nullable = false) + private String reason; + @Column(name = "metadata_json", length = 4096, nullable = false) + private String metadataJson; + @Column(name = "created_at", nullable = false) + private long createdAt; + + public Long getId() { return id; } + public String getOperationId() { return operationId; } + public void setOperationId(String operationId) { this.operationId = operationId; } + public String getSource() { return source; } + public void setSource(String source) { this.source = source; } + public String getIdempotencyKey() { return idempotencyKey; } + public void setIdempotencyKey(String idempotencyKey) { this.idempotencyKey = idempotencyKey; } + public String getIdempotencyKeyHash() { return idempotencyKeyHash; } + public void setIdempotencyKeyHash(String idempotencyKeyHash) { this.idempotencyKeyHash = idempotencyKeyHash; } + public String getRequestFingerprint() { return requestFingerprint; } + public void setRequestFingerprint(String requestFingerprint) { this.requestFingerprint = requestFingerprint; } + public String getTransactionType() { return transactionType; } + public void setTransactionType(String transactionType) { this.transactionType = transactionType; } + public String getCurrencyId() { return currencyId; } + public void setCurrencyId(String currencyId) { this.currencyId = currencyId; } + public String getScopeKey() { return scopeKey; } + public void setScopeKey(String scopeKey) { this.scopeKey = scopeKey; } + public Long getActorPlayerId() { return actorPlayerId; } + public void setActorPlayerId(Long actorPlayerId) { this.actorPlayerId = actorPlayerId; } + public String getActorName() { return actorName; } + public void setActorName(String actorName) { this.actorName = actorName; } + public String getReason() { return reason; } + public void setReason(String reason) { this.reason = reason; } + public String getMetadataJson() { return metadataJson; } + public void setMetadataJson(String metadataJson) { this.metadataJson = metadataJson; } + public long getCreatedAt() { return createdAt; } + public void setCreatedAt(long createdAt) { this.createdAt = createdAt; } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyTransactionEntryEntity.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyTransactionEntryEntity.java new file mode 100644 index 000000000..43abc075b --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/entity/EconomyTransactionEntryEntity.java @@ -0,0 +1,67 @@ +package nl.hauntedmc.serverfeatures.features.economy.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.math.BigDecimal; + +@Entity +@Table( + name = "system_economy_transaction_entry", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_economy_entry_role", + columnNames = {"transaction_id", "entry_role"} + ), + @UniqueConstraint( + name = "uq_economy_entry_account", + columnNames = {"transaction_id", "account_id"} + ) + }, + indexes = { + @Index(name = "idx_economy_entry_transaction", columnList = "transaction_id"), + @Index(name = "idx_economy_entry_account", columnList = "account_id,transaction_id") + } +) +public class EconomyTransactionEntryEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + @Column(name = "transaction_id", nullable = false) + private long transactionId; + @Column(name = "account_id", length = 192, nullable = false) + private String accountId; + @Column(name = "player_id", nullable = false) + private long playerId; + @Column(name = "entry_role", length = 24, nullable = false) + private String entryRole; + @Column(name = "delta", precision = 38, scale = 8, nullable = false) + private BigDecimal delta; + @Column(name = "balance_before", precision = 38, scale = 8, nullable = false) + private BigDecimal balanceBefore; + @Column(name = "balance_after", precision = 38, scale = 8, nullable = false) + private BigDecimal balanceAfter; + + public Long getId() { return id; } + public long getTransactionId() { return transactionId; } + public void setTransactionId(long transactionId) { this.transactionId = transactionId; } + public String getAccountId() { return accountId; } + public void setAccountId(String accountId) { this.accountId = accountId; } + public long getPlayerId() { return playerId; } + public void setPlayerId(long playerId) { this.playerId = playerId; } + public String getEntryRole() { return entryRole; } + public void setEntryRole(String entryRole) { this.entryRole = entryRole; } + public BigDecimal getDelta() { return delta; } + public void setDelta(BigDecimal delta) { this.delta = delta; } + public BigDecimal getBalanceBefore() { return balanceBefore; } + public void setBalanceBefore(BigDecimal balanceBefore) { this.balanceBefore = balanceBefore; } + public BigDecimal getBalanceAfter() { return balanceAfter; } + public void setBalanceAfter(BigDecimal balanceAfter) { this.balanceAfter = balanceAfter; } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/listener/EconomyPlayerListener.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/listener/EconomyPlayerListener.java new file mode 100644 index 000000000..e28a316ce --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/listener/EconomyPlayerListener.java @@ -0,0 +1,28 @@ +package nl.hauntedmc.serverfeatures.features.economy.listener; + +import nl.hauntedmc.serverfeatures.features.economy.Economy; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; + +import java.util.Objects; + +public final class EconomyPlayerListener implements Listener { + private final Economy feature; + + public EconomyPlayerListener(Economy feature) { + this.feature = Objects.requireNonNull(feature, "feature"); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onJoin(PlayerJoinEvent event) { + feature.service().preload(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent event) { + feature.evict(event.getPlayer().getUniqueId()); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/messaging/EconomyBalanceMessage.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/messaging/EconomyBalanceMessage.java new file mode 100644 index 000000000..ab889fb4e --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/messaging/EconomyBalanceMessage.java @@ -0,0 +1,60 @@ +package nl.hauntedmc.serverfeatures.features.economy.messaging; + +import nl.hauntedmc.dataprovider.database.messaging.api.AbstractEventMessage; + +/** Versioned invalidation hint. MySQL remains authoritative for every balance and setting. */ +public final class EconomyBalanceMessage extends AbstractEventMessage { + public static final String TYPE = "economy_account_invalidated"; + public static final int SCHEMA_VERSION = 2; + + private int schemaVersion; + private String publisherServer; + private String operationId; + private long playerId; + private String playerUuid; + private String currencyId; + private String scopeKey; + private long balanceVersion; + private long settingsVersion; + private long publishedAt; + + @SuppressWarnings("unused") + private EconomyBalanceMessage() { + super(TYPE); + } + + public EconomyBalanceMessage( + String publisherServer, + String operationId, + long playerId, + String playerUuid, + String currencyId, + String scopeKey, + long balanceVersion, + long settingsVersion, + long publishedAt + ) { + super(TYPE); + this.schemaVersion = SCHEMA_VERSION; + this.publisherServer = publisherServer; + this.operationId = operationId; + this.playerId = playerId; + this.playerUuid = playerUuid; + this.currencyId = currencyId; + this.scopeKey = scopeKey; + this.balanceVersion = balanceVersion; + this.settingsVersion = settingsVersion; + this.publishedAt = publishedAt; + } + + public int getSchemaVersion() { return schemaVersion; } + public String getPublisherServer() { return publisherServer; } + public String getOperationId() { return operationId; } + public long getPlayerId() { return playerId; } + public String getPlayerUuid() { return playerUuid; } + public String getCurrencyId() { return currencyId; } + public String getScopeKey() { return scopeKey; } + public long getBalanceVersion() { return balanceVersion; } + public long getSettingsVersion() { return settingsVersion; } + public long getPublishedAt() { return publishedAt; } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/messaging/EconomyMessaging.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/messaging/EconomyMessaging.java new file mode 100644 index 000000000..a7d5aa50e --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/messaging/EconomyMessaging.java @@ -0,0 +1,167 @@ +package nl.hauntedmc.serverfeatures.features.economy.messaging; + +import nl.hauntedmc.dataprovider.database.messaging.MessagingDataAccess; +import nl.hauntedmc.dataprovider.database.messaging.api.AbstractEventMessage; +import nl.hauntedmc.dataprovider.database.messaging.api.Subscription; +import nl.hauntedmc.serverfeatures.features.economy.Economy; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.Account; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.Identity; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Owns Economy Redis publish/subscribe lifecycle. Messages never mutate balances. */ +public final class EconomyMessaging { + private static final long UNSUBSCRIBE_TIMEOUT_SECONDS = 5L; + + private final Economy feature; + private final MessagingDataAccess messaging; + private final String channel; + private final AtomicBoolean closed = new AtomicBoolean(); + private final List subscriptions = new ArrayList<>(); + + public EconomyMessaging(Economy feature, MessagingDataAccess messaging, String channel) { + this.feature = Objects.requireNonNull(feature, "feature"); + this.messaging = Objects.requireNonNull(messaging, "messaging"); + this.channel = Objects.requireNonNull(channel, "channel"); + } + + public synchronized void start() { + if (closed.get()) { + throw new IllegalStateException("Economy messaging is closed"); + } + if (!subscriptions.isEmpty()) { + return; + } + List created = new ArrayList<>(); + try { + created.add(Objects.requireNonNull( + messaging.subscribe( + channel, + EconomyBalanceMessage.TYPE, + EconomyBalanceMessage.class, + message -> feature.service().applyRemoteBalance(message) + ), + "Redis balance subscription was not created" + )); + created.add(Objects.requireNonNull( + messaging.subscribe( + channel, + EconomyTransferMessage.TYPE, + EconomyTransferMessage.class, + message -> feature.service().applyRemoteTransfer(message) + ), + "Redis transfer subscription was not created" + )); + subscriptions.addAll(created); + } catch (RuntimeException failure) { + created.forEach(this::unsubscribe); + throw failure; + } + } + + public void publish(String operationId, Account account) { + if (closed.get() || account == null) { + return; + } + EconomyBalanceMessage message = new EconomyBalanceMessage( + feature.settings().serverKey(), + operationId, + account.identity().playerId(), + account.identity().playerUuid().toString(), + account.currencyId(), + account.scopeKey(), + account.version(), + account.settingsVersion(), + System.currentTimeMillis() + ); + publishMessage(message, "balance update"); + } + + public void publishTransfer( + String operationId, + Identity recipient, + String currencyId, + String scopeKey + ) { + if (closed.get() || operationId == null || operationId.isBlank()) { + return; + } + EconomyTransferMessage message = new EconomyTransferMessage( + feature.settings().serverKey(), + operationId, + recipient.playerId(), + recipient.playerUuid().toString(), + currencyId, + scopeKey, + System.currentTimeMillis() + ); + publishMessage(message, "transfer notification"); + } + + private void publishMessage(AbstractEventMessage message, String description) { + try { + CompletableFuture publication = messaging.publish(channel, message); + if (publication == null) { + feature.getLogger().warning("Economy messaging returned no future for " + description); + return; + } + publication.exceptionally(failure -> { + feature.getLogger().warning( + "Could not publish Economy " + description + ": " + rootMessage(failure) + ); + return null; + }); + } catch (RuntimeException failure) { + // This is strictly post-commit fan-out. A Redis failure must never turn a + // committed monetary transaction into an apparent failure for the caller. + feature.getLogger().warning( + "Could not publish Economy " + description + ": " + rootMessage(failure) + ); + } + } + + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + List current; + synchronized (this) { + current = List.copyOf(subscriptions); + subscriptions.clear(); + } + for (Subscription subscription : current) { + unsubscribe(subscription); + } + } + + private void unsubscribe(Subscription subscription) { + try { + CompletableFuture future = subscription.unsubscribe(); + if (future != null) { + future.orTimeout(UNSUBSCRIBE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .exceptionally(failure -> { + feature.getLogger().warning( + "Could not confirm Economy subscription shutdown: " + rootMessage(failure) + ); + return null; + }); + } + } catch (RuntimeException exception) { + feature.getLogger().warning("Could not close Economy subscription: " + rootMessage(exception)); + } + } + + private static String rootMessage(Throwable throwable) { + Throwable current = throwable; + while (current.getCause() != null && current.getCause() != current) { + current = current.getCause(); + } + String message = current.getMessage(); + return message == null || message.isBlank() ? current.getClass().getSimpleName() : message; + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/messaging/EconomyTransferMessage.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/messaging/EconomyTransferMessage.java new file mode 100644 index 000000000..905b959c2 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/messaging/EconomyTransferMessage.java @@ -0,0 +1,52 @@ +package nl.hauntedmc.serverfeatures.features.economy.messaging; + +import nl.hauntedmc.dataprovider.database.messaging.api.AbstractEventMessage; + +/** Post-commit transfer hint. Every displayed detail is reloaded from the authoritative MySQL journal. */ +public final class EconomyTransferMessage extends AbstractEventMessage { + public static final String TYPE = "economy_transfer_completed"; + public static final int SCHEMA_VERSION = 2; + + private int schemaVersion; + private String publisherServer; + private String operationId; + private long recipientPlayerId; + private String recipientPlayerUuid; + private String currencyId; + private String scopeKey; + private long publishedAt; + + @SuppressWarnings("unused") + private EconomyTransferMessage() { + super(TYPE); + } + + public EconomyTransferMessage( + String publisherServer, + String operationId, + long recipientPlayerId, + String recipientPlayerUuid, + String currencyId, + String scopeKey, + long publishedAt + ) { + super(TYPE); + this.schemaVersion = SCHEMA_VERSION; + this.publisherServer = publisherServer; + this.operationId = operationId; + this.recipientPlayerId = recipientPlayerId; + this.recipientPlayerUuid = recipientPlayerUuid; + this.currencyId = currencyId; + this.scopeKey = scopeKey; + this.publishedAt = publishedAt; + } + + public int getSchemaVersion() { return schemaVersion; } + public String getPublisherServer() { return publisherServer; } + public String getOperationId() { return operationId; } + public long getRecipientPlayerId() { return recipientPlayerId; } + public String getRecipientPlayerUuid() { return recipientPlayerUuid; } + public String getCurrencyId() { return currencyId; } + public String getScopeKey() { return scopeKey; } + public long getPublishedAt() { return publishedAt; } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/meta/Meta.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/meta/Meta.java new file mode 100644 index 000000000..7ce263f25 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/meta/Meta.java @@ -0,0 +1,22 @@ +package nl.hauntedmc.serverfeatures.features.economy.meta; + +import nl.hauntedmc.serverfeatures.api.feature.meta.BaseMeta; + +import java.util.List; + +public final class Meta implements BaseMeta { + @Override + public String getFeatureName() { + return "Economy"; + } + + @Override + public String getFeatureVersion() { + return "1.0.0"; + } + + @Override + public List getPluginDependencies() { + return List.of(DATA_PROVIDER, DATA_REGISTRY); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/model/EconomyModels.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/model/EconomyModels.java new file mode 100644 index 000000000..8504385f6 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/model/EconomyModels.java @@ -0,0 +1,171 @@ +package nl.hauntedmc.serverfeatures.features.economy.model; + +import nl.hauntedmc.serverfeatures.api.economy.EconomyResultStatus; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +public final class EconomyModels { + private EconomyModels() { + } + + public record Identity(long playerId, UUID playerUuid, String playerName) { + public Identity { + if (playerId <= 0L) { + throw new IllegalArgumentException("playerId must be positive"); + } + if (playerUuid == null) { + throw new IllegalArgumentException("playerUuid must not be null"); + } + playerName = playerName == null || playerName.isBlank() ? playerUuid.toString() : playerName.trim(); + } + } + + public record Account( + String accountId, + Identity identity, + String currencyId, + String scopeKey, + BigDecimal balance, + long version, + long settingsVersion, + boolean paymentsEnabled, + AccountStatus status + ) { + } + + public enum AccountStatus { + ACTIVE, + FROZEN + } + + public enum TransactionType { + ACCOUNT_CREATED, + DEPOSIT, + WITHDRAW, + SET, + TRANSFER, + ADMIN_ADD, + ADMIN_REMOVE, + ADMIN_SET, + LOTTERY_PURCHASE, + LOTTERY_DONATION, + LOTTERY_PAYOUT, + LOTTERY_REFUND, + VAULT_DEPOSIT, + VAULT_WITHDRAW, + PAYMENTS_ENABLED, + PAYMENTS_DISABLED, + ACCOUNT_FROZEN, + ACCOUNT_UNFROZEN + } + + public record MutationOutcome( + EconomyResultStatus status, + UUID operationId, + BigDecimal balance, + BigDecimal counterpartBalance, + String message, + Account account, + Account counterpart + ) { + public boolean successful() { + return status == EconomyResultStatus.SUCCESS || status == EconomyResultStatus.IDEMPOTENT_REPLAY; + } + } + + public record HistoryItem( + long transactionId, + UUID operationId, + String transactionType, + BigDecimal delta, + BigDecimal balanceAfter, + String actorName, + String reason, + long createdAt + ) { + } + + public static final class HistoryPage { + private final List entries; + private final int page; + private final boolean hasMore; + + public HistoryPage(List entries, int page, boolean hasMore) { + this.entries = List.copyOf(Objects.requireNonNull(entries, "entries")); + this.page = page; + this.hasMore = hasMore; + } + + public List entries() { + return List.copyOf(entries); + } + + public int page() { + return page; + } + + public boolean hasMore() { + return hasMore; + } + + @Override + public boolean equals(Object candidate) { + if (this == candidate) { + return true; + } + if (!(candidate instanceof HistoryPage other)) { + return false; + } + return page == other.page && hasMore == other.hasMore && entries.equals(other.entries); + } + + @Override + public int hashCode() { + return Objects.hash(entries, page, hasMore); + } + + @Override + public String toString() { + return "HistoryPage[entries=" + entries + ", page=" + page + ", hasMore=" + hasMore + "]"; + } + } + + public record TopEntry(long playerId, UUID playerUuid, String playerName, BigDecimal balance) { + } + + public record TransferReceipt( + UUID operationId, + Identity sender, + Identity recipient, + String currencyId, + String scopeKey, + BigDecimal amount, + BigDecimal recipientBalanceAfter + ) { + } + + public record VerificationReport( + long accountCount, + long transactionCount, + long invalidBalanceCount, + long invalidEntryCount, + long orphanSettingsCount, + long orphanEntryCount, + long identityMismatchCount, + long accountWithoutEntriesCount, + long transactionWithoutEntriesCount + ) { + public boolean healthy() { + return invalidBalanceCount == 0L + && invalidEntryCount == 0L + && orphanSettingsCount == 0L + && orphanEntryCount == 0L + && identityMismatchCount == 0L + && accountWithoutEntriesCount == 0L + && transactionWithoutEntriesCount == 0L; + } + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/persistence/EconomyRejectedException.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/persistence/EconomyRejectedException.java new file mode 100644 index 000000000..4a3b03bf7 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/persistence/EconomyRejectedException.java @@ -0,0 +1,19 @@ +package nl.hauntedmc.serverfeatures.features.economy.persistence; + +import nl.hauntedmc.serverfeatures.api.economy.EconomyResultStatus; + +/** Structured policy rejection that must be returned to API callers without being logged as an infrastructure failure. */ +public final class EconomyRejectedException extends RuntimeException { + private static final long serialVersionUID = 1L; + + private final EconomyResultStatus status; + + EconomyRejectedException(EconomyResultStatus status, String message) { + super(message); + this.status = status; + } + + public EconomyResultStatus status() { + return status; + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/persistence/EconomyRepository.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/persistence/EconomyRepository.java new file mode 100644 index 000000000..226ebe318 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/persistence/EconomyRepository.java @@ -0,0 +1,1598 @@ +package nl.hauntedmc.serverfeatures.features.economy.persistence; + +import com.google.gson.Gson; +import jakarta.persistence.LockModeType; +import nl.hauntedmc.dataprovider.api.orm.ORMContext; +import nl.hauntedmc.serverfeatures.api.economy.EconomyResultStatus; +import nl.hauntedmc.serverfeatures.features.economy.config.EconomySettings; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyBalanceEntity; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyCurrencyDefinitionEntity; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyCurrencyFamilyEntity; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyDailyUsageEntity; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyPlayerIdentityEntity; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyPlayerSettingsEntity; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyTransactionEntity; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyTransactionEntryEntity; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.Account; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.AccountStatus; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.HistoryItem; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.HistoryPage; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.Identity; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.MutationOutcome; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.TopEntry; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.TransferReceipt; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.TransactionType; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.VerificationReport; +import org.hibernate.Session; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.sql.SQLException; +import java.sql.SQLRecoverableException; +import java.sql.SQLTransientException; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.TreeMap; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Supplier; + +/** Transactional MySQL repository. All balance mutations are committed atomically with their audit rows. */ +public final class EconomyRepository { + private static final int MAX_RETRIES = 3; + private static final int DATABASE_SCALE = 8; + private static final Gson GSON = new Gson(); + + private final ORMContext orm; + + public EconomyRepository(ORMContext orm) { + this.orm = Objects.requireNonNull(orm, "orm"); + } + + public void validateDefinitions(EconomySettings settings, long now) { + executeWithRetry(() -> orm.runInTransaction(session -> { + List currencies = settings.currencies().values().stream() + .sorted(Comparator.comparing(EconomySettings.Currency::id)) + .toList(); + for (EconomySettings.Currency currency : currencies) { + validateCurrencyFamily(session, settings.networkKey(), currency, now); + String id = definitionId(currency.id(), currency.scope().key()); + String hash = definitionHash(currency); + EconomyCurrencyDefinitionEntity entity = session.find( + EconomyCurrencyDefinitionEntity.class, + id, + LockModeType.PESSIMISTIC_WRITE + ); + if (entity == null) { + entity = new EconomyCurrencyDefinitionEntity(); + entity.setId(id); + entity.setCurrencyId(currency.id()); + entity.setScopeKey(currency.scope().key()); + entity.setScopeType(currency.scope().type().name()); + entity.setFractionalDigits(currency.display().fractionalDigits()); + entity.setStartingBalance(databaseAmount(currency.balances().starting())); + entity.setMinimumBalance(databaseAmount(currency.balances().minimum())); + entity.setMaximumBalance(databaseAmount(currency.balances().maximum())); + entity.setAllowNegative(currency.balances().allowNegative()); + entity.setDefinitionHash(hash); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + session.persist(entity); + continue; + } + if (!hash.equals(entity.getDefinitionHash())) { + throw new IllegalStateException( + "Currency definition mismatch for " + currency.id() + " in scope " + currency.scope().key() + ); + } + entity.setUpdatedAt(now); + } + return null; + })); + } + + + private static void validateCurrencyFamily( + Session session, + String networkKey, + EconomySettings.Currency currency, + long now + ) { + String id = networkKey + ":" + currency.id(); + String globalScope = currency.scope().type() == nl.hauntedmc.serverfeatures.api.economy.EconomyScopeType.GLOBAL + ? currency.scope().key() + : null; + String familyHash = hash(String.join("|", + networkKey, + currency.id(), + currency.scope().type().name(), + Integer.toString(currency.display().fractionalDigits()), + globalScope == null ? "" : globalScope + )); + EconomyCurrencyFamilyEntity family = session.find( + EconomyCurrencyFamilyEntity.class, + id, + LockModeType.PESSIMISTIC_WRITE + ); + if (family == null) { + family = new EconomyCurrencyFamilyEntity(); + family.setId(id); + family.setNetworkKey(networkKey); + family.setCurrencyId(currency.id()); + family.setScopeType(currency.scope().type().name()); + family.setFractionalDigits(currency.display().fractionalDigits()); + family.setGlobalScopeKey(globalScope); + family.setFamilyHash(familyHash); + family.setCreatedAt(now); + family.setUpdatedAt(now); + session.persist(family); + return; + } + if (!familyHash.equals(family.getFamilyHash())) { + throw new IllegalStateException( + "Currency family mismatch for " + currency.id() + " in network " + networkKey + + ": scope type, precision, or global scope differs between servers" + ); + } + family.setUpdatedAt(now); + } + + + public Account balance(Identity identity, EconomySettings.Currency currency, long now) { + return executeWithRetry(() -> orm.runInTransaction(session -> { + EconomyBalanceEntity balance = ensureAccount(session, identity, currency, now, false); + EconomyPlayerSettingsEntity settings = ensureSettings(session, balance.getId(), currency, now, false); + return snapshot(balance, settings); + })); + } + + public List balances( + Identity identity, + Collection currencies, + long now + ) { + Objects.requireNonNull(identity, "identity"); + Objects.requireNonNull(currencies, "currencies"); + return executeWithRetry(() -> orm.runInTransaction(session -> { + List orderedCurrencies = currencies.stream() + .sorted(Comparator.comparing(EconomySettings.Currency::id)) + .toList(); + ensurePlayerIdentity(session, identity, now, false); + List accountIds = orderedCurrencies.stream() + .map(currency -> accountId(identity.playerId(), currency.id(), currency.scope().key())) + .toList(); + Map balances = new LinkedHashMap<>(); + for (EconomyBalanceEntity balance : session.createSelectionQuery( + "from EconomyBalanceEntity where id in :ids", + EconomyBalanceEntity.class + ) + .setParameter("ids", accountIds) + .getResultList()) { + balances.put(balance.getId(), balance); + } + Map settingsByAccount = new LinkedHashMap<>(); + for (EconomyPlayerSettingsEntity playerSettings : session.createSelectionQuery( + "from EconomyPlayerSettingsEntity where accountId in :ids", + EconomyPlayerSettingsEntity.class + ) + .setParameter("ids", accountIds) + .getResultList()) { + settingsByAccount.put(playerSettings.getAccountId(), playerSettings); + } + + List accounts = new ArrayList<>(orderedCurrencies.size()); + for (EconomySettings.Currency currency : orderedCurrencies) { + String accountId = accountId(identity.playerId(), currency.id(), currency.scope().key()); + EconomyBalanceEntity balance = balances.get(accountId); + if (balance == null) { + balance = createAccount(session, accountId, identity, currency, now); + } else { + validateAccountIdentity(balance, identity); + } + EconomyPlayerSettingsEntity playerSettings = settingsByAccount.get(accountId); + if (playerSettings == null) { + playerSettings = createSettings(session, accountId, currency, now); + } + accounts.add(snapshot(balance, playerSettings)); + } + return List.copyOf(accounts); + })); + } + + public boolean accountExists(Identity identity, EconomySettings.Currency currency) { + Objects.requireNonNull(identity, "identity"); + Objects.requireNonNull(currency, "currency"); + String id = accountId(identity.playerId(), currency.id(), currency.scope().key()); + return executeWithRetry(() -> orm.runInTransaction(session -> { + EconomyPlayerIdentityEntity canonical = session.find( + EconomyPlayerIdentityEntity.class, + identity.playerId() + ); + EconomyPlayerIdentityEntity uuidOwner = findIdentityByUuid(session, identity.playerUuid()); + if (canonical == null) { + if (uuidOwner != null && uuidOwner.getPlayerId() != identity.playerId()) { + throw new IllegalStateException( + "Economy UUID " + identity.playerUuid() + " is already owned by player ID " + + uuidOwner.getPlayerId() + ); + } + return false; + } + if (!Objects.equals(canonical.getPlayerUuid(), identity.playerUuid().toString())) { + throw new IllegalStateException( + "Economy player ID " + identity.playerId() + " is already owned by UUID " + + canonical.getPlayerUuid() + ); + } + return session.find(EconomyBalanceEntity.class, id) != null; + })); + } + + public Optional identityByUuid(UUID playerUuid) { + Objects.requireNonNull(playerUuid, "playerUuid"); + return executeWithRetry(() -> orm.runInTransaction(session -> { + EconomyPlayerIdentityEntity entity = findIdentityByUuid(session, playerUuid); + if (entity == null) { + return Optional.empty(); + } + return Optional.of(new Identity( + entity.getPlayerId(), + UUID.fromString(entity.getPlayerUuid()), + entity.getPlayerName() + )); + })); + } + + private static EconomyPlayerIdentityEntity findIdentityByUuid(Session session, UUID playerUuid) { + return session.createSelectionQuery( + "from EconomyPlayerIdentityEntity where playerUuid = :uuid", + EconomyPlayerIdentityEntity.class + ) + .setParameter("uuid", playerUuid.toString()) + .setMaxResults(1) + .getResultStream() + .findFirst() + .orElse(null); + } + + + public MutationOutcome mutate( + TransactionType operationType, + TransactionType journalType, + Identity identity, + EconomySettings.Currency currency, + BigDecimal rawAmount, + String source, + String idempotencyKey, + Long actorPlayerId, + String actorName, + String reason, + Map metadata, + boolean bypassFreeze, + long now + ) { + requireCompatibleMutationTypes(operationType, journalType); + BigDecimal amount = normalizeMutationAmount(operationType, rawAmount, currency); + String requestFingerprint = mutationFingerprint( + operationType, + journalType, + identity, + currency, + amount, + actorPlayerId, + actorName, + reason, + metadata, + bypassFreeze + ); + try { + return executeWithRetry(() -> orm.runInTransaction(session -> { + MutationOutcome replay = replay(session, source, idempotencyKey, requestFingerprint); + if (replay != null) { + return replay; + } + EconomyBalanceEntity balance = ensureAccount(session, identity, currency, now, true); + EconomyPlayerSettingsEntity playerSettings = ensureSettings( + session, + balance.getId(), + currency, + now, + true + ); + requireActive( + playerSettings, + bypassFreeze || journalType == TransactionType.LOTTERY_REFUND + ); + + BigDecimal before = balance.getBalance(); + BigDecimal after = switch (operationType) { + case DEPOSIT, ADMIN_ADD, LOTTERY_PAYOUT, LOTTERY_REFUND, VAULT_DEPOSIT -> before.add(amount); + case WITHDRAW, ADMIN_REMOVE, LOTTERY_PURCHASE, LOTTERY_DONATION, VAULT_WITHDRAW -> before.subtract(amount); + case SET, ADMIN_SET -> amount; + case TRANSFER -> throw new IllegalArgumentException("Use transfer() for transfers"); + case ACCOUNT_CREATED -> throw new IllegalArgumentException("Account creation is internal"); + case PAYMENTS_ENABLED, PAYMENTS_DISABLED, ACCOUNT_FROZEN, ACCOUNT_UNFROZEN -> + throw new IllegalArgumentException("Use the account-setting operation"); + }; + validateBalance(after, currency); + balance.setBalance(databaseAmount(after)); + balance.setPlayerName(trim(identity.playerName(), 32)); + balance.setPlayerUuid(identity.playerUuid().toString()); + balance.setUpdatedAt(now); + + EconomyTransactionEntity transaction = transaction( + journalType, + currency, + source, + idempotencyKey, + requestFingerprint, + actorPlayerId, + actorName, + reason, + metadata, + now + ); + session.persist(transaction); + session.flush(); + persistEntry( + session, + transaction.getId(), + balance, + "TARGET", + after.subtract(before), + before, + after + ); + session.flush(); + return outcome( + EconomyResultStatus.SUCCESS, + transaction.getOperationId(), + after, + null, + "", + snapshot(balance, playerSettings), + null + ); + })); + } catch (EconomyRejectedException rejected) { + return outcome(rejected.status(), null, null, null, rejected.getMessage(), null, null); + } + } + + public MutationOutcome transfer( + Identity senderIdentity, + Identity recipientIdentity, + EconomySettings.Currency currency, + BigDecimal rawAmount, + String source, + String idempotencyKey, + Long actorPlayerId, + String actorName, + String reason, + Map metadata, + boolean bypassPaymentsToggle, + boolean bypassFreeze, + long now + ) { + BigDecimal amount = normalizePositive(rawAmount, currency); + if (senderIdentity.playerId() == recipientIdentity.playerId()) { + return outcome(EconomyResultStatus.INVALID_AMOUNT, null, null, null, + "Sender and recipient must differ", null, null); + } + if (amount.compareTo(currency.payments().minimum()) < 0) { + return outcome(EconomyResultStatus.LIMIT_EXCEEDED, null, null, null, + "Amount is below the minimum payment", null, null); + } + if (currency.payments().maximum().signum() > 0 + && amount.compareTo(currency.payments().maximum()) > 0) { + return outcome(EconomyResultStatus.LIMIT_EXCEEDED, null, null, null, + "Amount exceeds the maximum payment", null, null); + } + String requestFingerprint = transferFingerprint( + senderIdentity, + recipientIdentity, + currency, + amount, + actorPlayerId, + actorName, + reason, + metadata, + bypassPaymentsToggle, + bypassFreeze + ); + + try { + return executeWithRetry(() -> orm.runInTransaction(session -> { + MutationOutcome replay = replay(session, source, idempotencyKey, requestFingerprint); + if (replay != null) { + return replay; + } + + List identities = new ArrayList<>(List.of(senderIdentity, recipientIdentity)); + identities.sort(Comparator.comparingLong(Identity::playerId)); + Map locked = new LinkedHashMap<>(); + Map settings = new LinkedHashMap<>(); + for (Identity identity : identities) { + EconomyBalanceEntity account = ensureAccount(session, identity, currency, now, true); + locked.put(identity.playerId(), account); + settings.put(identity.playerId(), ensureSettings(session, account.getId(), currency, now, true)); + } + + EconomyBalanceEntity sender = locked.get(senderIdentity.playerId()); + EconomyBalanceEntity recipient = locked.get(recipientIdentity.playerId()); + EconomyPlayerSettingsEntity senderSettings = settings.get(senderIdentity.playerId()); + EconomyPlayerSettingsEntity recipientSettings = settings.get(recipientIdentity.playerId()); + requireActive(senderSettings, bypassFreeze); + requireActive(recipientSettings, bypassFreeze); + if (!bypassPaymentsToggle && !recipientSettings.isPaymentsEnabled()) { + throw new EconomyRejectedException( + EconomyResultStatus.PAYMENTS_DISABLED, + "Recipient has disabled incoming payments" + ); + } + + BigDecimal senderBefore = sender.getBalance(); + BigDecimal recipientBefore = recipient.getBalance(); + BigDecimal senderAfter = senderBefore.subtract(amount); + BigDecimal recipientAfter = recipientBefore.add(amount); + validateBalance(senderAfter, currency); + validateBalance(recipientAfter, currency); + enforcePaymentCooldown(senderSettings, currency, now); + applyDailyLimits(session, sender, recipient, currency, amount, now); + + sender.setBalance(databaseAmount(senderAfter)); + sender.setPlayerName(trim(senderIdentity.playerName(), 32)); + sender.setUpdatedAt(now); + recipient.setBalance(databaseAmount(recipientAfter)); + recipient.setPlayerName(trim(recipientIdentity.playerName(), 32)); + recipient.setUpdatedAt(now); + + EconomyTransactionEntity transaction = transaction( + TransactionType.TRANSFER, + currency, + source, + idempotencyKey, + requestFingerprint, + actorPlayerId, + actorName, + reason, + metadata, + now + ); + session.persist(transaction); + session.flush(); + persistEntry(session, transaction.getId(), sender, "SENDER", amount.negate(), senderBefore, senderAfter); + persistEntry(session, transaction.getId(), recipient, "RECIPIENT", amount, recipientBefore, recipientAfter); + session.flush(); + return outcome( + EconomyResultStatus.SUCCESS, + transaction.getOperationId(), + senderAfter, + recipientAfter, + "", + snapshot(sender, senderSettings), + snapshot(recipient, recipientSettings) + ); + })); + } catch (EconomyRejectedException rejected) { + return outcome(rejected.status(), null, null, null, rejected.getMessage(), null, null); + } + } + + public Optional transferReceipt(UUID operationId) { + if (operationId == null) { + return Optional.empty(); + } + return executeWithRetry(() -> orm.runInTransaction(session -> { + EconomyTransactionEntity transaction = session.createSelectionQuery( + "from EconomyTransactionEntity where operationId = :operationId", + EconomyTransactionEntity.class + ) + .setParameter("operationId", operationId.toString()) + .setMaxResults(1) + .getResultStream() + .findFirst() + .orElse(null); + if (transaction == null || !TransactionType.TRANSFER.name().equals(transaction.getTransactionType())) { + return Optional.empty(); + } + List entries = session.createSelectionQuery( + "from EconomyTransactionEntryEntity where transactionId = :transactionId order by id asc", + EconomyTransactionEntryEntity.class + ) + .setParameter("transactionId", transaction.getId()) + .getResultList(); + EconomyTransactionEntryEntity senderEntry = entries.stream() + .filter(entry -> "SENDER".equals(entry.getEntryRole())) + .findFirst() + .orElse(null); + EconomyTransactionEntryEntity recipientEntry = entries.stream() + .filter(entry -> "RECIPIENT".equals(entry.getEntryRole())) + .findFirst() + .orElse(null); + if (entries.size() != 2 + || senderEntry == null + || recipientEntry == null + || senderEntry.getAccountId().equals(recipientEntry.getAccountId()) + || recipientEntry.getDelta().signum() <= 0 + || senderEntry.getDelta().negate().compareTo(recipientEntry.getDelta()) != 0 + || senderEntry.getBalanceBefore().add(senderEntry.getDelta()) + .compareTo(senderEntry.getBalanceAfter()) != 0 + || recipientEntry.getBalanceBefore().add(recipientEntry.getDelta()) + .compareTo(recipientEntry.getBalanceAfter()) != 0) { + throw new IllegalStateException( + "Economy transfer " + operationId + " has invalid journal entries" + ); + } + EconomyBalanceEntity sender = session.find(EconomyBalanceEntity.class, senderEntry.getAccountId()); + EconomyBalanceEntity recipient = session.find(EconomyBalanceEntity.class, recipientEntry.getAccountId()); + if (sender == null + || recipient == null + || sender.getPlayerId() != senderEntry.getPlayerId() + || recipient.getPlayerId() != recipientEntry.getPlayerId() + || !transaction.getCurrencyId().equals(sender.getCurrencyId()) + || !transaction.getCurrencyId().equals(recipient.getCurrencyId()) + || !transaction.getScopeKey().equals(sender.getScopeKey()) + || !transaction.getScopeKey().equals(recipient.getScopeKey())) { + throw new IllegalStateException( + "Economy transfer " + operationId + " references an inconsistent account" + ); + } + return Optional.of(new TransferReceipt( + operationId, + identity(sender), + identity(recipient), + transaction.getCurrencyId(), + transaction.getScopeKey(), + recipientEntry.getDelta(), + recipientEntry.getBalanceAfter() + )); + })); + } + + public MutationOutcome setPaymentsEnabled( + Identity identity, + EconomySettings.Currency currency, + boolean enabled, + String source, + String idempotencyKey, + Long actorPlayerId, + String actorName, + String reason, + Map metadata, + long now + ) { + TransactionType type = enabled ? TransactionType.PAYMENTS_ENABLED : TransactionType.PAYMENTS_DISABLED; + String requestFingerprint = accountSettingFingerprint( + type, identity, currency, actorPlayerId, actorName, reason, metadata + ); + return executeWithRetry(() -> orm.runInTransaction(session -> { + MutationOutcome replay = replay(session, source, idempotencyKey, requestFingerprint); + if (replay != null) { + return replay; + } + EconomyBalanceEntity balance = ensureAccount(session, identity, currency, now, true); + EconomyPlayerSettingsEntity settings = ensureSettings(session, balance.getId(), currency, now, true); + BigDecimal unchanged = balance.getBalance(); + settings.setPaymentsEnabled(enabled); + settings.setUpdatedAt(now); + EconomyTransactionEntity transaction = transaction( + type, + currency, + source, + idempotencyKey, + requestFingerprint, + actorPlayerId, + actorName, + reason, + metadata, + now + ); + session.persist(transaction); + session.flush(); + persistEntry(session, transaction.getId(), balance, "TARGET", BigDecimal.ZERO, unchanged, unchanged); + session.flush(); + return outcome( + EconomyResultStatus.SUCCESS, + transaction.getOperationId(), + unchanged, + null, + "", + snapshot(balance, settings), + null + ); + })); + } + + public MutationOutcome setFrozen( + Identity identity, + EconomySettings.Currency currency, + boolean frozen, + Long actorPlayerId, + String actorName, + String reason, + String source, + String idempotencyKey, + Map metadata, + long now + ) { + TransactionType type = frozen ? TransactionType.ACCOUNT_FROZEN : TransactionType.ACCOUNT_UNFROZEN; + String requestFingerprint = accountSettingFingerprint( + type, identity, currency, actorPlayerId, actorName, reason, metadata + ); + return executeWithRetry(() -> orm.runInTransaction(session -> { + MutationOutcome replay = replay(session, source, idempotencyKey, requestFingerprint); + if (replay != null) { + return replay; + } + EconomyBalanceEntity balance = ensureAccount(session, identity, currency, now, true); + EconomyPlayerSettingsEntity settings = ensureSettings(session, balance.getId(), currency, now, true); + BigDecimal unchanged = balance.getBalance(); + settings.setAccountStatus(frozen ? AccountStatus.FROZEN.name() : AccountStatus.ACTIVE.name()); + settings.setStatusActorPlayerId(actorPlayerId); + settings.setStatusReason(trim(reason, 255)); + settings.setUpdatedAt(now); + EconomyTransactionEntity transaction = transaction( + type, + currency, + source, + idempotencyKey, + requestFingerprint, + actorPlayerId, + actorName, + reason, + metadata, + now + ); + session.persist(transaction); + session.flush(); + persistEntry(session, transaction.getId(), balance, "TARGET", BigDecimal.ZERO, unchanged, unchanged); + session.flush(); + return outcome( + EconomyResultStatus.SUCCESS, + transaction.getOperationId(), + unchanged, + null, + "", + snapshot(balance, settings), + null + ); + })); + } + + public HistoryPage history( + Identity identity, + EconomySettings.Currency currency, + int page, + int pageSize, + long now + ) { + return executeWithRetry(() -> orm.runInTransaction(session -> { + EconomyBalanceEntity account = ensureAccount(session, identity, currency, now, false); + List entries = session.createSelectionQuery( + "from EconomyTransactionEntryEntity where accountId = :accountId order by transactionId desc", + EconomyTransactionEntryEntity.class + ) + .setParameter("accountId", account.getId()) + .setFirstResult((page - 1) * pageSize) + .setMaxResults(pageSize + 1) + .getResultList(); + boolean hasMore = entries.size() > pageSize; + if (hasMore) { + entries = new ArrayList<>(entries.subList(0, pageSize)); + } + List result = new ArrayList<>(); + for (EconomyTransactionEntryEntity entry : entries) { + EconomyTransactionEntity transaction = session.find(EconomyTransactionEntity.class, entry.getTransactionId()); + if (transaction != null) { + result.add(new HistoryItem( + transaction.getId(), + UUID.fromString(transaction.getOperationId()), + transaction.getTransactionType(), + entry.getDelta(), + entry.getBalanceAfter(), + transaction.getActorName(), + transaction.getReason(), + transaction.getCreatedAt() + )); + } + } + return new HistoryPage(result, page, hasMore); + })); + } + + public List top(EconomySettings.Currency currency, int offset, int limit) { + return executeWithRetry(() -> orm.runInTransaction(session -> session.createSelectionQuery( + "from EconomyBalanceEntity where currencyId = :currency and scopeKey = :scope " + + "order by balance desc, playerId asc", + EconomyBalanceEntity.class + ) + .setParameter("currency", currency.id()) + .setParameter("scope", currency.scope().key()) + .setFirstResult(offset) + .setMaxResults(limit) + .getResultList() + .stream() + .map(entity -> new TopEntry( + entity.getPlayerId(), + UUID.fromString(entity.getPlayerUuid()), + entity.getPlayerName(), + entity.getBalance() + )) + .toList())); + } + + public VerificationReport verify(EconomySettings settings) { + return executeWithRetry(() -> orm.runInTransaction(session -> { + long accounts = session.createSelectionQuery("select count(*) from EconomyBalanceEntity", Long.class) + .getSingleResult(); + long transactions = session.createSelectionQuery("select count(*) from EconomyTransactionEntity", Long.class) + .getSingleResult(); + long orphanSettings = session.createSelectionQuery( + "select count(*) from EconomyPlayerSettingsEntity s where not exists " + + "(select 1 from EconomyBalanceEntity b where b.id = s.accountId)", + Long.class + ) + .getSingleResult(); + long transactionsWithoutEntries = session.createSelectionQuery( + "select count(*) from EconomyTransactionEntity t where not exists " + + "(select 1 from EconomyTransactionEntryEntity e where e.transactionId = t.id)", + Long.class + ) + .getSingleResult(); + long invalidEntries = session.createSelectionQuery( + "select count(*) from EconomyTransactionEntryEntity e " + + "where e.balanceBefore + e.delta <> e.balanceAfter", + Long.class + ) + .getSingleResult(); + long orphanEntries = session.createSelectionQuery( + "select count(*) from EconomyTransactionEntryEntity e where not exists " + + "(select 1 from EconomyTransactionEntity t where t.id = e.transactionId) " + + "or not exists (select 1 from EconomyBalanceEntity b where b.id = e.accountId)", + Long.class + ) + .getSingleResult(); + long identityMismatches = session.createSelectionQuery( + "select count(*) from EconomyBalanceEntity b where not exists " + + "(select 1 from EconomyPlayerIdentityEntity i where i.playerId = b.playerId " + + "and i.playerUuid = b.playerUuid)", + Long.class + ) + .getSingleResult(); + long accountsWithoutEntries = session.createSelectionQuery( + "select count(*) from EconomyBalanceEntity b where not exists " + + "(select 1 from EconomyTransactionEntryEntity e where e.accountId = b.id)", + Long.class + ) + .getSingleResult(); + long invalidBalances = 0L; + for (EconomySettings.Currency currency : settings.currencies().values()) { + invalidBalances += session.createSelectionQuery( + "select count(*) from EconomyBalanceEntity where currencyId = :currency " + + "and scopeKey = :scope and (balance < :minimum or balance > :maximum)", + Long.class + ) + .setParameter("currency", currency.id()) + .setParameter("scope", currency.scope().key()) + .setParameter("minimum", databaseAmount(currency.balances().minimum())) + .setParameter("maximum", databaseAmount(currency.balances().maximum())) + .getSingleResult(); + } + return new VerificationReport( + accounts, + transactions, + invalidBalances, + invalidEntries, + orphanSettings, + orphanEntries, + identityMismatches, + accountsWithoutEntries, + transactionsWithoutEntries + ); + })); + } + + private EconomyBalanceEntity ensureAccount( + Session session, + Identity identity, + EconomySettings.Currency currency, + long now, + boolean lock + ) { + ensurePlayerIdentity(session, identity, now, lock); + String id = accountId(identity.playerId(), currency.id(), currency.scope().key()); + EconomyBalanceEntity account = lock + ? session.find(EconomyBalanceEntity.class, id, LockModeType.PESSIMISTIC_WRITE) + : session.find(EconomyBalanceEntity.class, id); + if (account == null) { + return createAccount(session, id, identity, currency, now); + } + validateAccountIdentity(account, identity); + if (lock) { + String playerName = trim(identity.playerName(), 32); + if (!Objects.equals(account.getPlayerName(), playerName)) { + account.setPlayerName(playerName); + account.setUpdatedAt(now); + } + } + return account; + } + + private EconomyBalanceEntity createAccount( + Session session, + String id, + Identity identity, + EconomySettings.Currency currency, + long now + ) { + EconomyBalanceEntity account = new EconomyBalanceEntity(); + account.setId(id); + account.setPlayerId(identity.playerId()); + account.setPlayerUuid(identity.playerUuid().toString()); + account.setPlayerName(trim(identity.playerName(), 32)); + account.setCurrencyId(currency.id()); + account.setScopeKey(currency.scope().key()); + BigDecimal startingBalance = databaseAmount(currency.balances().starting()); + account.setBalance(startingBalance); + account.setCreatedAt(now); + account.setUpdatedAt(now); + session.persist(account); + session.flush(); + persistAccountCreation(session, account, currency, startingBalance, now); + return account; + } + + private static void validateAccountIdentity(EconomyBalanceEntity account, Identity identity) { + if (account.getPlayerId() != identity.playerId() + || !Objects.equals(account.getPlayerUuid(), identity.playerUuid().toString())) { + throw new IllegalStateException( + "Economy account identity mismatch for player ID " + identity.playerId() + ); + } + } + + + private EconomyPlayerIdentityEntity ensurePlayerIdentity( + Session session, + Identity identity, + long now, + boolean lock + ) { + EconomyPlayerIdentityEntity canonical = lock + ? session.find(EconomyPlayerIdentityEntity.class, identity.playerId(), LockModeType.PESSIMISTIC_WRITE) + : session.find(EconomyPlayerIdentityEntity.class, identity.playerId()); + String playerUuid = identity.playerUuid().toString(); + String playerName = trim(identity.playerName(), 32); + if (canonical == null) { + EconomyPlayerIdentityEntity uuidOwner = session.createSelectionQuery( + "from EconomyPlayerIdentityEntity where playerUuid = :uuid", + EconomyPlayerIdentityEntity.class + ) + .setParameter("uuid", playerUuid) + .setMaxResults(1) + .getResultStream() + .findFirst() + .orElse(null); + if (uuidOwner != null && uuidOwner.getPlayerId() != identity.playerId()) { + throw new IllegalStateException( + "Economy UUID " + playerUuid + " is already owned by player ID " + uuidOwner.getPlayerId() + ); + } + canonical = new EconomyPlayerIdentityEntity(); + canonical.setPlayerId(identity.playerId()); + canonical.setPlayerUuid(playerUuid); + canonical.setPlayerName(playerName); + canonical.setCreatedAt(now); + canonical.setUpdatedAt(now); + session.persist(canonical); + session.flush(); + return canonical; + } + if (!Objects.equals(canonical.getPlayerUuid(), playerUuid)) { + throw new IllegalStateException( + "Economy player ID " + identity.playerId() + " is already owned by UUID " + + canonical.getPlayerUuid() + ); + } + if (lock && !Objects.equals(canonical.getPlayerName(), playerName)) { + canonical.setPlayerName(playerName); + canonical.setUpdatedAt(now); + } + return canonical; + } + + + private static void persistAccountCreation( + Session session, + EconomyBalanceEntity account, + EconomySettings.Currency currency, + BigDecimal startingBalance, + long now + ) { + String idempotencyKey = "account:" + account.getId(); + String requestFingerprint = fingerprint( + "account-creation-v1", + account.getId(), + currency.id(), + currency.scope().key(), + startingBalance.toPlainString() + ); + EconomyTransactionEntity transaction = transaction( + TransactionType.ACCOUNT_CREATED, + currency, + "economy-account", + idempotencyKey, + requestFingerprint, + null, + "system", + "Economy account created", + Map.of("account_id", account.getId()), + now + ); + session.persist(transaction); + session.flush(); + persistEntry( + session, + transaction.getId(), + account, + "TARGET", + startingBalance, + BigDecimal.ZERO, + startingBalance + ); + session.flush(); + } + + private EconomyPlayerSettingsEntity ensureSettings( + Session session, + String accountId, + EconomySettings.Currency currency, + long now, + boolean lock + ) { + EconomyPlayerSettingsEntity settings = lock + ? session.find(EconomyPlayerSettingsEntity.class, accountId, LockModeType.PESSIMISTIC_WRITE) + : session.find(EconomyPlayerSettingsEntity.class, accountId); + if (settings == null) { + return createSettings(session, accountId, currency, now); + } + return settings; + } + + private EconomyPlayerSettingsEntity createSettings( + Session session, + String accountId, + EconomySettings.Currency currency, + long now + ) { + EconomyPlayerSettingsEntity settings = new EconomyPlayerSettingsEntity(); + settings.setAccountId(accountId); + settings.setPaymentsEnabled(currency.payments().defaultEnabled()); + settings.setAccountStatus(AccountStatus.ACTIVE.name()); + settings.setCreatedAt(now); + settings.setUpdatedAt(now); + session.persist(settings); + session.flush(); + return settings; + } + + private void applyDailyLimits( + Session session, + EconomyBalanceEntity sender, + EconomyBalanceEntity recipient, + EconomySettings.Currency currency, + BigDecimal amount, + long now + ) { + BigDecimal sendLimit = currency.payments().dailySendLimit(); + BigDecimal receiveLimit = currency.payments().dailyReceiveLimit(); + if (sendLimit.signum() <= 0 && receiveLimit.signum() <= 0) { + return; + } + String date = Instant.ofEpochMilli(now).atZone(ZoneOffset.UTC).toLocalDate().toString(); + EconomyDailyUsageEntity senderUsage; + EconomyDailyUsageEntity recipientUsage; + if (sender.getId().compareTo(recipient.getId()) < 0) { + senderUsage = usage(session, sender, date, now); + recipientUsage = usage(session, recipient, date, now); + } else { + recipientUsage = usage(session, recipient, date, now); + senderUsage = usage(session, sender, date, now); + } + + BigDecimal sent = senderUsage.getSentAmount().add(amount); + if (sendLimit.signum() > 0 && sent.compareTo(sendLimit) > 0) { + throw new EconomyRejectedException(EconomyResultStatus.LIMIT_EXCEEDED, "Daily send limit exceeded"); + } + BigDecimal received = recipientUsage.getReceivedAmount().add(amount); + if (receiveLimit.signum() > 0 && received.compareTo(receiveLimit) > 0) { + throw new EconomyRejectedException(EconomyResultStatus.LIMIT_EXCEEDED, "Daily receive limit exceeded"); + } + + senderUsage.setSentAmount(databaseAmount(sent)); + senderUsage.setSentCount(Math.addExact(senderUsage.getSentCount(), 1)); + senderUsage.setUpdatedAt(now); + recipientUsage.setReceivedAmount(databaseAmount(received)); + recipientUsage.setUpdatedAt(now); + } + + private EconomyDailyUsageEntity usage( + Session session, + EconomyBalanceEntity account, + String date, + long now + ) { + String id = account.getId() + ":" + date; + EconomyDailyUsageEntity usage = session.find( + EconomyDailyUsageEntity.class, + id, + LockModeType.PESSIMISTIC_WRITE + ); + if (usage != null) { + return usage; + } + usage = new EconomyDailyUsageEntity(); + usage.setId(id); + usage.setAccountId(account.getId()); + usage.setUsageDate(date); + usage.setSentAmount(databaseAmount(BigDecimal.ZERO)); + usage.setReceivedAmount(databaseAmount(BigDecimal.ZERO)); + usage.setSentCount(0); + usage.setUpdatedAt(now); + session.persist(usage); + session.flush(); + return usage; + } + + private MutationOutcome replay( + Session session, + String source, + String idempotencyKey, + String requestFingerprint + ) { + String normalizedSource = bounded(source, 64, "source", true); + String normalizedKey = bounded(idempotencyKey, 160, "idempotencyKey", true); + EconomyTransactionEntity transaction = session.createSelectionQuery( + "from EconomyTransactionEntity where source = :source and idempotencyKeyHash = :keyHash", + EconomyTransactionEntity.class + ) + .setParameter("source", normalizedSource) + .setParameter("keyHash", hash(normalizedKey)) + .setMaxResults(1) + .getResultStream() + .findFirst() + .orElse(null); + if (transaction == null) { + return null; + } + if (!Objects.equals(transaction.getIdempotencyKey(), normalizedKey) + || !Objects.equals(transaction.getRequestFingerprint(), requestFingerprint)) { + throw new EconomyRejectedException( + EconomyResultStatus.IDEMPOTENCY_CONFLICT, + "Idempotency key was already used for a different economy request" + ); + } + List entries = session.createSelectionQuery( + "from EconomyTransactionEntryEntity where transactionId = :transactionId order by id asc", + EconomyTransactionEntryEntity.class + ) + .setParameter("transactionId", transaction.getId()) + .getResultList(); + if (entries.isEmpty()) { + throw new IllegalStateException( + "Economy transaction " + transaction.getOperationId() + " has no journal entries" + ); + } + BigDecimal balance = null; + BigDecimal counterpartBalance = null; + Account account = null; + Account counterpart = null; + for (EconomyTransactionEntryEntity entry : entries) { + EconomyBalanceEntity balanceEntity = session.find(EconomyBalanceEntity.class, entry.getAccountId()); + EconomyPlayerSettingsEntity settingsEntity = session.find( + EconomyPlayerSettingsEntity.class, + entry.getAccountId() + ); + if (balanceEntity == null || settingsEntity == null) { + throw new IllegalStateException( + "Economy transaction " + transaction.getOperationId() + " references a missing account" + ); + } + Account snapshot = snapshot(balanceEntity, settingsEntity); + if ("RECIPIENT".equals(entry.getEntryRole())) { + counterpartBalance = entry.getBalanceAfter(); + counterpart = snapshot; + } else { + balance = entry.getBalanceAfter(); + account = snapshot; + } + } + return outcome( + EconomyResultStatus.IDEMPOTENT_REPLAY, + transaction.getOperationId(), + balance, + counterpartBalance, + "", + account, + counterpart + ); + } + + private static EconomyTransactionEntity transaction( + TransactionType type, + EconomySettings.Currency currency, + String source, + String idempotencyKey, + String requestFingerprint, + Long actorPlayerId, + String actorName, + String reason, + Map metadata, + long now + ) { + EconomyTransactionEntity entity = new EconomyTransactionEntity(); + entity.setOperationId(UUID.randomUUID().toString()); + entity.setSource(bounded(source, 64, "source", true)); + String normalizedKey = bounded(idempotencyKey, 160, "idempotencyKey", true); + entity.setIdempotencyKey(normalizedKey); + entity.setIdempotencyKeyHash(hash(normalizedKey)); + entity.setRequestFingerprint(requestFingerprint); + entity.setTransactionType(type.name()); + entity.setCurrencyId(currency.id()); + entity.setScopeKey(currency.scope().key()); + entity.setActorPlayerId(actorPlayerId); + entity.setActorName(bounded(normalizedActor(actorName), 64, "actorName", true)); + entity.setReason(bounded(reason == null ? "" : reason, 255, "reason", false)); + String json = GSON.toJson(metadata == null ? Map.of() : metadata); + if (json.length() > 4096) { + throw new EconomyRejectedException( + EconomyResultStatus.INVALID_AMOUNT, + "Economy metadata exceeds 4096 serialized characters" + ); + } + entity.setMetadataJson(json); + entity.setCreatedAt(now); + return entity; + } + + private static void persistEntry( + Session session, + long transactionId, + EconomyBalanceEntity account, + String role, + BigDecimal delta, + BigDecimal before, + BigDecimal after + ) { + EconomyTransactionEntryEntity entry = new EconomyTransactionEntryEntity(); + entry.setTransactionId(transactionId); + entry.setAccountId(account.getId()); + entry.setPlayerId(account.getPlayerId()); + entry.setEntryRole(role); + entry.setDelta(databaseAmount(delta)); + entry.setBalanceBefore(databaseAmount(before)); + entry.setBalanceAfter(databaseAmount(after)); + session.persist(entry); + } + + private static Identity identity(EconomyBalanceEntity balance) { + return new Identity( + balance.getPlayerId(), + UUID.fromString(balance.getPlayerUuid()), + balance.getPlayerName() + ); + } + + private static Account snapshot(EconomyBalanceEntity balance, EconomyPlayerSettingsEntity settings) { + return new Account( + balance.getId(), + identity(balance), + balance.getCurrencyId(), + balance.getScopeKey(), + balance.getBalance(), + balance.getVersion(), + settings.getVersion(), + settings.isPaymentsEnabled(), + AccountStatus.valueOf(settings.getAccountStatus()) + ); + } + + private static void requireActive(EconomyPlayerSettingsEntity settings, boolean bypassFreeze) { + if (!bypassFreeze && AccountStatus.FROZEN.name().equals(settings.getAccountStatus())) { + throw new EconomyRejectedException(EconomyResultStatus.ACCOUNT_FROZEN, "Account is frozen"); + } + } + + private static void requireCompatibleMutationTypes( + TransactionType operationType, + TransactionType journalType + ) { + Objects.requireNonNull(operationType, "operationType"); + Objects.requireNonNull(journalType, "journalType"); + if (mutationDirection(operationType) != mutationDirection(journalType)) { + throw new IllegalArgumentException( + "Journal transaction type " + journalType + " is incompatible with " + operationType + ); + } + } + + private static int mutationDirection(TransactionType type) { + return switch (type) { + case DEPOSIT, ADMIN_ADD, LOTTERY_PAYOUT, LOTTERY_REFUND, VAULT_DEPOSIT -> 1; + case WITHDRAW, ADMIN_REMOVE, LOTTERY_PURCHASE, LOTTERY_DONATION, VAULT_WITHDRAW -> -1; + case SET, ADMIN_SET -> 0; + case TRANSFER -> 2; + case ACCOUNT_CREATED -> 4; + case PAYMENTS_ENABLED, PAYMENTS_DISABLED, ACCOUNT_FROZEN, ACCOUNT_UNFROZEN -> 3; + }; + } + + private static void enforcePaymentCooldown( + EconomyPlayerSettingsEntity senderSettings, + EconomySettings.Currency currency, + long now + ) { + long cooldownMillis = currency.payments().cooldown().toMillis(); + if (cooldownMillis <= 0L) { + return; + } + Long previous = senderSettings.getLastPaymentAt(); + if (previous != null) { + long elapsed = Math.max(0L, now - previous); + if (elapsed < cooldownMillis) { + long remaining = cooldownMillis - elapsed; + throw new EconomyRejectedException( + EconomyResultStatus.LIMIT_EXCEEDED, + "Payment cooldown active for " + remaining + " ms" + ); + } + } + senderSettings.setLastPaymentAt(now); + senderSettings.setUpdatedAt(now); + } + + static String mutationFingerprint( + TransactionType operationType, + TransactionType journalType, + Identity identity, + EconomySettings.Currency currency, + BigDecimal amount, + Long actorPlayerId, + String actorName, + String reason, + Map metadata, + boolean bypassFreeze + ) { + return fingerprint( + "mutation-v2", + operationType.name(), + journalType.name(), + accountId(identity.playerId(), currency.id(), currency.scope().key()), + identity.playerUuid().toString(), + currency.id(), + currency.scope().key(), + amount.toPlainString(), + actorPlayerId == null ? "" : actorPlayerId.toString(), + normalizedActor(actorName), + reason == null ? "" : reason.trim(), + canonicalMetadata(metadata), + Boolean.toString(bypassFreeze) + ); + } + + static String transferFingerprint( + Identity sender, + Identity recipient, + EconomySettings.Currency currency, + BigDecimal amount, + Long actorPlayerId, + String actorName, + String reason, + Map metadata, + boolean bypassPaymentsToggle, + boolean bypassFreeze + ) { + return fingerprint( + "transfer-v2", + accountId(sender.playerId(), currency.id(), currency.scope().key()), + sender.playerUuid().toString(), + accountId(recipient.playerId(), currency.id(), currency.scope().key()), + recipient.playerUuid().toString(), + currency.id(), + currency.scope().key(), + amount.toPlainString(), + actorPlayerId == null ? "" : actorPlayerId.toString(), + normalizedActor(actorName), + reason == null ? "" : reason.trim(), + canonicalMetadata(metadata), + Boolean.toString(bypassPaymentsToggle), + Boolean.toString(bypassFreeze) + ); + } + + static String accountSettingFingerprint( + TransactionType type, + Identity identity, + EconomySettings.Currency currency, + Long actorPlayerId, + String actorName, + String reason, + Map metadata + ) { + return fingerprint( + "account-setting-v1", + type.name(), + accountId(identity.playerId(), currency.id(), currency.scope().key()), + identity.playerUuid().toString(), + currency.id(), + currency.scope().key(), + actorPlayerId == null ? "" : actorPlayerId.toString(), + normalizedActor(actorName), + reason == null ? "" : reason.trim(), + canonicalMetadata(metadata) + ); + } + + private static String normalizedActor(String actorName) { + return actorName == null || actorName.isBlank() ? "system" : actorName.trim(); + } + + private static String canonicalMetadata(Map metadata) { + if (metadata == null || metadata.isEmpty()) { + return ""; + } + StringBuilder builder = new StringBuilder(); + new TreeMap<>(metadata).forEach((key, value) -> { + appendFingerprintPart(builder, key == null ? "" : key); + appendFingerprintPart(builder, value == null ? "" : value); + }); + return builder.toString(); + } + + private static String fingerprint(String... parts) { + StringBuilder builder = new StringBuilder(); + for (String part : parts) { + appendFingerprintPart(builder, part == null ? "" : part); + } + return hash(builder.toString()); + } + + private static void appendFingerprintPart(StringBuilder builder, String part) { + builder.append(part.length()).append(':').append(part).append(';'); + } + + private static BigDecimal normalizeMutationAmount( + TransactionType type, + BigDecimal amount, + EconomySettings.Currency currency + ) { + if (type == TransactionType.SET || type == TransactionType.ADMIN_SET) { + BigDecimal normalized = normalize(amount, currency); + validateBalance(normalized, currency); + return normalized; + } + if (type == TransactionType.ACCOUNT_CREATED + || type == TransactionType.PAYMENTS_ENABLED + || type == TransactionType.PAYMENTS_DISABLED + || type == TransactionType.ACCOUNT_FROZEN + || type == TransactionType.ACCOUNT_UNFROZEN) { + throw new IllegalArgumentException("Account-setting operations do not accept an amount"); + } + return normalizePositive(amount, currency); + } + + private static BigDecimal normalizePositive(BigDecimal amount, EconomySettings.Currency currency) { + BigDecimal normalized = normalize(amount, currency); + if (normalized.signum() <= 0) { + throw new EconomyRejectedException(EconomyResultStatus.INVALID_AMOUNT, "Amount must be positive"); + } + return normalized; + } + + private static BigDecimal normalize(BigDecimal amount, EconomySettings.Currency currency) { + if (amount == null) { + throw new EconomyRejectedException(EconomyResultStatus.INVALID_AMOUNT, "Amount is required"); + } + validateAmountShape(amount); + try { + return amount.setScale(currency.display().fractionalDigits(), currency.balances().rounding()); + } catch (ArithmeticException exception) { + throw new EconomyRejectedException(EconomyResultStatus.INVALID_AMOUNT, "Amount has invalid precision"); + } + } + + private static void validateAmountShape(BigDecimal amount) { + long integerDigits = (long) amount.precision() - amount.scale(); + if (amount.scale() > DATABASE_SCALE || amount.signum() != 0 && integerDigits > 30L) { + throw new EconomyRejectedException( + EconomyResultStatus.INVALID_AMOUNT, + "Amount exceeds DECIMAL(38,8) storage precision" + ); + } + } + + private static void validateBalance(BigDecimal balance, EconomySettings.Currency currency) { + if (!currency.balances().allowNegative() && balance.signum() < 0) { + throw new EconomyRejectedException(EconomyResultStatus.INSUFFICIENT_FUNDS, "Insufficient funds"); + } + if (balance.compareTo(currency.balances().minimum()) < 0) { + throw new EconomyRejectedException(EconomyResultStatus.INSUFFICIENT_FUNDS, "Balance would fall below minimum"); + } + if (balance.compareTo(currency.balances().maximum()) > 0) { + throw new EconomyRejectedException(EconomyResultStatus.LIMIT_EXCEEDED, "Balance would exceed maximum"); + } + } + + private T executeWithRetry(Supplier work) { + RuntimeException last = null; + for (int attempt = 0; attempt < MAX_RETRIES; attempt++) { + int attemptNumber = attempt + 1; + try { + return work.get(); + } catch (EconomyRejectedException rejected) { + throw rejected; + } catch (RuntimeException failure) { + last = failure; + if (!isTransient(failure) || attemptNumber == MAX_RETRIES) { + throw failure; + } + long baseDelayMillis = 5L << attempt; + long jitterMillis = ThreadLocalRandom.current().nextLong(baseDelayMillis + 1L); + try { + Thread.sleep(baseDelayMillis + jitterMillis); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw failure; + } + } + } + throw last == null ? new IllegalStateException("Economy operation did not execute") : last; + } + + static boolean isTransient(Throwable failure) { + Throwable current = failure; + while (current != null) { + if (current instanceof SQLTransientException || current instanceof SQLRecoverableException) { + return true; + } + if (current instanceof SQLException sqlException) { + String sqlState = sqlException.getSQLState(); + int errorCode = sqlException.getErrorCode(); + if (sqlState != null && (sqlState.startsWith("08") || sqlState.startsWith("40")) + || errorCode == 1062 + || errorCode == 1205 + || errorCode == 1213) { + return true; + } + } + String className = current.getClass().getName(); + if (className.endsWith("JDBCConnectionException") + || className.endsWith("LockAcquisitionException") + || className.endsWith("PessimisticLockException") + || className.endsWith("OptimisticLockException")) { + return true; + } + String message = current.getMessage(); + if (message != null) { + String normalized = message.toLowerCase(java.util.Locale.ROOT); + if (normalized.contains("deadlock") + || normalized.contains("lock wait timeout") + || normalized.contains("duplicate entry") + || normalized.contains("constraint") && normalized.contains("idempotency")) { + return true; + } + } + current = current.getCause(); + } + return false; + } + + private static MutationOutcome outcome( + EconomyResultStatus status, + String operationId, + BigDecimal balance, + BigDecimal counterpartBalance, + String message, + Account account, + Account counterpart + ) { + return new MutationOutcome( + status, + operationId == null ? null : UUID.fromString(operationId), + balance, + counterpartBalance, + message == null ? "" : message, + account, + counterpart + ); + } + + private static String accountId(long playerId, String currencyId, String scopeKey) { + return playerId + ":" + currencyId + ":" + hash(scopeKey); + } + + private static String definitionId(String currencyId, String scopeKey) { + return currencyId + ":" + hash(scopeKey); + } + + private static String definitionHash(EconomySettings.Currency currency) { + return hash(String.join("|", + currency.id(), + currency.scope().type().name(), + currency.scope().key(), + Integer.toString(currency.display().fractionalDigits()), + currency.balances().starting().toPlainString(), + currency.balances().minimum().toPlainString(), + currency.balances().maximum().toPlainString(), + Boolean.toString(currency.balances().allowNegative()), + currency.balances().rounding().name(), + Boolean.toString(currency.payments().defaultEnabled()), + currency.payments().minimum().toPlainString(), + currency.payments().maximum().toPlainString(), + currency.payments().confirmationThreshold().toPlainString(), + currency.payments().dailySendLimit().toPlainString(), + currency.payments().dailyReceiveLimit().toPlainString(), + Long.toString(currency.payments().cooldown().toMillis()) + )); + } + + private static String hash(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + + private static BigDecimal databaseAmount(BigDecimal value) { + BigDecimal normalized; + try { + normalized = value.setScale(DATABASE_SCALE, RoundingMode.UNNECESSARY); + } catch (ArithmeticException exception) { + throw new EconomyRejectedException( + EconomyResultStatus.INVALID_AMOUNT, + "Amount exceeds supported decimal precision" + ); + } + if (normalized.precision() > 38) { + throw new EconomyRejectedException( + EconomyResultStatus.INVALID_AMOUNT, + "Amount exceeds DECIMAL(38,8) storage precision" + ); + } + return normalized; + } + + private static String bounded(String value, int maximum, String field, boolean required) { + String normalized = value == null ? "" : value.trim(); + if (required && normalized.isBlank()) { + throw new EconomyRejectedException(EconomyResultStatus.INVALID_AMOUNT, field + " must not be blank"); + } + if (normalized.length() > maximum) { + throw new EconomyRejectedException( + EconomyResultStatus.INVALID_AMOUNT, + field + " exceeds " + maximum + " characters" + ); + } + return normalized; + } + + private static String trim(String value, int maximum) { + String normalized = value == null ? "" : value.trim(); + return normalized.length() <= maximum ? normalized : normalized.substring(0, maximum); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/placeholder/EconomyPlaceholder.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/placeholder/EconomyPlaceholder.java new file mode 100644 index 000000000..c80d99ee8 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/placeholder/EconomyPlaceholder.java @@ -0,0 +1,85 @@ +package nl.hauntedmc.serverfeatures.features.economy.placeholder; + +import me.clip.placeholderapi.expansion.PlaceholderExpansion; +import nl.hauntedmc.serverfeatures.api.economy.EconomyAccountRef; +import nl.hauntedmc.serverfeatures.api.economy.EconomyBalance; +import nl.hauntedmc.serverfeatures.features.economy.Economy; +import nl.hauntedmc.serverfeatures.features.economy.config.EconomySettings; +import org.bukkit.OfflinePlayer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Locale; +import java.util.Optional; + +/** Cache-only PlaceholderAPI expansion. */ +public final class EconomyPlaceholder extends PlaceholderExpansion { + private final Economy feature; + + public EconomyPlaceholder(Economy feature) { + this.feature = feature; + } + + @Override + public @NotNull String getIdentifier() { + return "economy"; + } + + @Override + public @NotNull String getAuthor() { + return "HauntedMC"; + } + + @Override + public @NotNull String getVersion() { + return "1.1.0"; + } + + @Override + public boolean persist() { + return true; + } + + @Override + public @Nullable String onRequest(OfflinePlayer player, @NotNull String params) { + if (feature.service() == null || player == null) { + return "0"; + } + String key = params.trim().toLowerCase(Locale.ROOT); + String currencyId; + String suffix; + if (key.startsWith("primary_")) { + currencyId = feature.settings().vault().primaryCurrency(); + suffix = key.substring("primary_".length()); + } else { + int separator = key.lastIndexOf('_'); + if (separator <= 0) { + return null; + } + currencyId = key.substring(0, separator); + suffix = key.substring(separator + 1); + } + EconomySettings.Currency currency = feature.settings().currencies().get(currencyId); + if (currency == null) { + return null; + } + EconomyAccountRef account = new EconomyAccountRef( + null, + player.getUniqueId(), + player.getName(), + currency.id(), + currency.scope().key() + ); + Optional balance = feature.service().cachedBalance(account); + return switch (suffix) { + case "balance" -> balance.map(value -> feature.service().format(currency.id(), value.balance())).orElse("0"); + case "raw" -> balance.map(value -> value.balance().toPlainString()).orElse("0"); + case "scope" -> currency.scope().key(); + case "payments" -> feature.service().cachedAccount(player.getUniqueId(), currency.id()) + .map(value -> Boolean.toString(value.paymentsEnabled())) + .orElse(Boolean.toString(currency.payments().defaultEnabled())); + case "ready" -> Boolean.toString(balance.isPresent()); + default -> null; + }; + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/service/EconomyService.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/service/EconomyService.java new file mode 100644 index 000000000..eccb31698 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/service/EconomyService.java @@ -0,0 +1,1152 @@ +package nl.hauntedmc.serverfeatures.features.economy.service; + +import nl.hauntedmc.dataregistry.api.player.PlayerIdentity; +import nl.hauntedmc.serverfeatures.api.economy.EconomyAccountRef; +import nl.hauntedmc.serverfeatures.api.economy.EconomyApi; +import nl.hauntedmc.serverfeatures.api.economy.EconomyBalance; +import nl.hauntedmc.serverfeatures.api.economy.EconomyCurrency; +import nl.hauntedmc.serverfeatures.api.economy.EconomyMutationRequest; +import nl.hauntedmc.serverfeatures.api.economy.EconomyResult; +import nl.hauntedmc.serverfeatures.api.economy.EconomyResultStatus; +import nl.hauntedmc.serverfeatures.api.economy.EconomyTransferRequest; +import nl.hauntedmc.serverfeatures.api.util.BukkitTime; +import nl.hauntedmc.serverfeatures.features.economy.Economy; +import nl.hauntedmc.serverfeatures.features.economy.config.EconomySettings; +import nl.hauntedmc.serverfeatures.features.economy.messaging.EconomyBalanceMessage; +import nl.hauntedmc.serverfeatures.features.economy.messaging.EconomyMessaging; +import nl.hauntedmc.serverfeatures.features.economy.messaging.EconomyTransferMessage; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.Account; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.HistoryPage; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.Identity; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.MutationOutcome; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.TopEntry; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.TransferReceipt; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.TransactionType; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.VerificationReport; +import nl.hauntedmc.serverfeatures.features.economy.persistence.EconomyRejectedException; +import nl.hauntedmc.serverfeatures.features.economy.persistence.EconomyRepository; +import nl.hauntedmc.serverfeatures.framework.persistence.PlayerIdentityResolver; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; + +import java.math.BigDecimal; +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Native asynchronous Economy API and feature service. */ +public final class EconomyService implements EconomyApi, AutoCloseable { + private static final long NOTIFICATION_DEDUP_MILLIS = 10 * 60 * 1_000L; + private static final int MAX_NOTIFICATION_DEDUP_ENTRIES = 10_000; + private static final long SYNCHRONOUS_IDENTITY_TIMEOUT_MILLIS = 1_000L; + + private final Economy feature; + private final EconomySettings settings; + private final EconomyRepository repository; + private final PlayerIdentityResolver identityResolver; + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> refreshes = new ConcurrentHashMap<>(); + private final ConcurrentHashMap>> batchRefreshes = new ConcurrentHashMap<>(); + private final Set onlinePlayers = ConcurrentHashMap.newKeySet(); + private final ConcurrentHashMap notifiedTransfers = new ConcurrentHashMap<>(); + private final AtomicBoolean closed = new AtomicBoolean(); + private volatile EconomyMessaging messaging; + + public EconomyService(Economy feature, EconomySettings settings, EconomyRepository repository) { + this.feature = Objects.requireNonNull(feature, "feature"); + this.settings = Objects.requireNonNull(settings, "settings"); + this.repository = Objects.requireNonNull(repository, "repository"); + this.identityResolver = new PlayerIdentityResolver( + feature.getPlugin().getDataRegistry().orElseThrow(() -> new IllegalStateException( + "Economy requires DataRegistry" + )) + ); + } + + public void start() { + refreshOnlinePlayers(); + BukkitTime refreshPeriod = BukkitTime.milliseconds( + settings.cache().authoritativeRefreshInterval().toMillis() + ); + feature.getLifecycleManager().getTaskManager().scheduleRepeatingTask( + this::refreshOnlinePlayers, + refreshPeriod, + refreshPeriod + ); + } + + public void setMessaging(EconomyMessaging messaging) { + this.messaging = messaging; + } + + public EconomySettings settings() { + return settings; + } + + public void preload(Player player) { + if (player == null || closed.get()) { + return; + } + UUID playerUuid = player.getUniqueId(); + String playerName = player.getName(); + onlinePlayers.add(playerUuid); + identityResolver.whenReady(playerUuid).whenComplete((resolved, failure) -> { + if (failure != null || resolved == null || resolved.isEmpty() || closed.get()) { + return; + } + Identity identity = identity(resolved.get()); + CompletableFuture> refresh = batchRefreshes.computeIfAbsent( + identity.playerUuid(), + ignored -> { + CompletableFuture> created = submit(() -> repository.balances( + identity, + settings.currencies().values(), + now() + )); + created.whenComplete((accounts, error) -> batchRefreshes.remove( + identity.playerUuid(), + created + )); + return created; + } + ); + refresh.thenAccept(accounts -> { + if (!closed.get() && onlinePlayers.contains(identity.playerUuid())) { + accounts.forEach(this::cache); + } + }).exceptionally(error -> { + feature.getLogger().warning( + "Could not refresh Economy accounts for " + playerName + + ": " + rootMessage(error) + ); + return null; + }); + }); + } + + public void evict(UUID playerUuid) { + if (playerUuid == null) { + return; + } + onlinePlayers.remove(playerUuid); + cache.entrySet().removeIf(entry -> entry.getValue().identity().playerUuid().equals(playerUuid)); + batchRefreshes.remove(playerUuid); + } + + @Override + public CompletionStage balance(EconomyAccountRef account) { + return resolve(account).thenCompose(identity -> { + EconomySettings.Currency currency = requireCurrency(account.currencyId(), account.scopeKey()); + return submit(() -> repository.balance(identity, currency, now())) + .thenApply(this::cache) + .thenApply(this::apiBalance); + }); + } + + @Override + public Optional cachedBalance(EconomyAccountRef account) { + if (account == null || account.playerUuid() == null) { + return Optional.empty(); + } + EconomySettings.Currency currency; + try { + currency = requireCurrency(account.currencyId(), account.scopeKey()); + } catch (RuntimeException ignored) { + return Optional.empty(); + } + Account cached = cache.get(cacheKey(account.playerUuid(), currency.id(), currency.scope().key())); + return Optional.ofNullable(cached).map(this::apiBalance); + } + + @Override + public CompletionStage deposit(EconomyMutationRequest request) { + return mutate(request, TransactionType.DEPOSIT, false); + } + + @Override + public CompletionStage withdraw(EconomyMutationRequest request) { + return mutate(request, TransactionType.WITHDRAW, false); + } + + @Override + public CompletionStage setBalance(EconomyMutationRequest request) { + return mutate(request, TransactionType.SET, false); + } + + public CompletionStage mutate( + EconomyMutationRequest request, + TransactionType type, + boolean bypassFreeze + ) { + Objects.requireNonNull(request, "request"); + return resolve(request.account()).thenCompose(identity -> { + EconomySettings.Currency currency = requireCurrency( + request.account().currencyId(), + request.account().scopeKey() + ); + TransactionType journalType = requestedJournalType(type, request.metadata()); + return submit(() -> repository.mutate( + type, + journalType, + identity, + currency, + request.amount(), + request.source(), + request.idempotencyKey(), + request.actorPlayerId(), + request.actorName(), + request.reason(), + request.metadata(), + bypassFreeze, + now() + )) + .thenApply(outcome -> publishAndConvert(outcome)); + }).exceptionally(this::failureResult); + } + + @Override + public CompletionStage transfer(EconomyTransferRequest request) { + Objects.requireNonNull(request, "request"); + CompletableFuture sender = resolve(request.sender()).toCompletableFuture(); + CompletableFuture recipient = resolve(request.recipient()).toCompletableFuture(); + return sender.thenCombine(recipient, ResolvedTransfer::new).thenCompose(resolved -> { + EconomySettings.Currency currency = requireCurrency( + request.sender().currencyId(), + request.sender().scopeKey() + ); + EconomySettings.Currency recipientCurrency = requireCurrency( + request.recipient().currencyId(), + request.recipient().scopeKey() + ); + if (!currency.id().equals(recipientCurrency.id()) + || !currency.scope().key().equals(recipientCurrency.scope().key())) { + return CompletableFuture.completedFuture(new EconomyResult( + EconomyResultStatus.INVALID_AMOUNT, + null, + null, + null, + "Transfers require the same currency and scope" + )); + } + return submit(() -> repository.transfer( + resolved.sender(), + resolved.recipient(), + currency, + request.amount(), + request.source(), + request.idempotencyKey(), + request.actorPlayerId(), + request.actorName(), + request.reason(), + request.metadata(), + request.bypassPaymentsToggle(), + false, + now() + )) + .thenApply(outcome -> publishTransferAndConvert( + outcome, + resolved.sender(), + resolved.recipient(), + currency, + request.amount() + )); + }).exceptionally(this::failureResult); + } + + public CompletionStage resolveIdentifier(String identifier) { + if (identifier == null || identifier.isBlank()) { + return CompletableFuture.failedFuture(new IllegalArgumentException("Player identifier must not be blank")); + } + return identityResolver.findByIdentifier(identifier.trim()).thenApply(optional -> optional + .map(EconomyService::identity) + .orElseThrow(() -> new UnknownPlayerException("Unknown player: " + identifier))); + } + + public EconomyAccountRef account(Identity identity, String currencyId) { + EconomySettings.Currency currency = requireCurrency(currencyId, null); + return new EconomyAccountRef( + identity.playerId(), + identity.playerUuid(), + identity.playerName(), + currency.id(), + currency.scope().key() + ); + } + + public EconomyAccountRef account(Player player, String currencyId) { + Optional active = identityResolver.findActiveByUuid(player.getUniqueId()); + if (active.isEmpty()) { + throw new IllegalStateException("Player identity is not ready"); + } + return account(identity(active.get()), currencyId); + } + + public Optional cachedAccount(UUID playerUuid, String currencyId) { + EconomySettings.Currency currency = requireCurrency(currencyId, null); + return Optional.ofNullable(cache.get(cacheKey(playerUuid, currency.id(), currency.scope().key()))); + } + + public CompletionStage accountState(EconomyAccountRef account) { + return resolve(account).thenCompose(identity -> { + EconomySettings.Currency currency = requireCurrency(account.currencyId(), account.scopeKey()); + return submit(() -> repository.balance(identity, currency, now())).thenApply(this::cache); + }); + } + + public CompletionStage setPaymentsEnabled( + EconomyAccountRef account, + boolean enabled, + Long actorPlayerId, + String actorName, + String reason, + String source + ) { + return resolve(account).thenCompose(identity -> { + EconomySettings.Currency currency = requireCurrency(account.currencyId(), account.scopeKey()); + String idempotencyKey = UUID.randomUUID().toString(); + return submit(() -> repository.setPaymentsEnabled( + identity, + currency, + enabled, + source, + idempotencyKey, + actorPlayerId, + actorName, + reason, + Map.of(), + now() + )) + .thenApply(this::publishAccountMutation); + }); + } + + public CompletionStage setFrozen( + EconomyAccountRef account, + boolean frozen, + Long actorPlayerId, + String actorName, + String reason + ) { + return resolve(account).thenCompose(identity -> { + EconomySettings.Currency currency = requireCurrency(account.currencyId(), account.scopeKey()); + String idempotencyKey = UUID.randomUUID().toString(); + return submit(() -> repository.setFrozen( + identity, + currency, + frozen, + actorPlayerId, + actorName, + reason, + "admin-command", + idempotencyKey, + Map.of(), + now() + )) + .thenApply(this::publishAccountMutation); + }); + } + + public CompletionStage history(EconomyAccountRef account, int page, int pageSize) { + return resolve(account).thenCompose(identity -> { + EconomySettings.Currency currency = requireCurrency(account.currencyId(), account.scopeKey()); + return submit(() -> repository.history(identity, currency, page, pageSize, now())); + }); + } + + public CompletionStage> top(String currencyId, int page, int pageSize) { + EconomySettings.Currency currency = requireCurrency(currencyId, null); + return submit(() -> repository.top(currency, Math.max(0, page - 1) * pageSize, pageSize)); + } + + public CompletionStage verify() { + return submit(() -> repository.verify(settings)); + } + + @Override + public Optional currency(String currencyId) { + if (currencyId == null) { + return Optional.empty(); + } + EconomySettings.Currency currency = settings.currencies().get(currencyId.trim().toLowerCase(Locale.ROOT)); + return Optional.ofNullable(currency).map(this::apiCurrency); + } + + @Override + public Collection currencies() { + return settings.currencies().values().stream().map(this::apiCurrency).toList(); + } + + @Override + public String format(String currencyId, BigDecimal amount) { + EconomySettings.Currency currency = requireCurrency(currencyId, null); + BigDecimal normalized = amount.setScale( + currency.display().fractionalDigits(), + currency.balances().rounding() + ); + DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(Locale.US); + DecimalFormat formatter = new DecimalFormat(); + formatter.setDecimalFormatSymbols(symbols); + formatter.setGroupingUsed(currency.display().grouping()); + formatter.setMinimumFractionDigits(currency.display().fractionalDigits()); + formatter.setMaximumFractionDigits(currency.display().fractionalDigits()); + String number = formatter.format(normalized); + String noun = normalized.abs().compareTo(BigDecimal.ONE) == 0 + ? currency.display().singular() + : currency.display().plural(); + return currency.display().format() + .replace("{symbol}", currency.display().symbol()) + .replace("{amount}", number) + .replace("{singular}", currency.display().singular()) + .replace("{plural}", noun); + } + + public Optional resolveSync(OfflinePlayer player) { + if (player == null || closed.get()) { + return Optional.empty(); + } + UUID playerUuid = player.getUniqueId(); + Optional active = identityResolver.findActiveByUuid(playerUuid); + if (active.isPresent()) { + return active.map(EconomyService::identity); + } + // The durable UUID binding is immutable and safe to reuse for an existing Economy identity. + // This avoids blocking common synchronous Vault calls on a remote registry lookup. + Optional persisted = repository.identityByUuid(playerUuid); + if (persisted.isPresent()) { + return persisted; + } + // DataRegistry remains canonical before an Economy identity exists. + return awaitIdentity(identityResolver.findByUuid(playerUuid), playerUuid.toString()); + } + + public Optional resolveSync(String playerName) { + if (playerName == null || playerName.isBlank() || closed.get()) { + return Optional.empty(); + } + String normalized = playerName.trim(); + Optional active = identityResolver.findActiveByUsername(normalized); + if (active.isPresent()) { + return active.map(EconomyService::identity); + } + // Player names are mutable and may be reassigned. Never trust the denormalized + // name stored on an Economy account for a monetary lookup. + return awaitIdentity(identityResolver.findByUsername(normalized), normalized); + } + + public Optional balanceSync(Identity identity, String currencyId) { + if (identity == null) { + return Optional.empty(); + } + EconomySettings.Currency currency = requireCurrency(currencyId, null); + return Optional.of(cache(repository.balance(identity, currency, now()))); + } + + public Optional balanceSync(OfflinePlayer player, String currencyId) { + return resolveSync(player).flatMap(identity -> balanceSync(identity, currencyId)); + } + + public boolean hasBalanceSync(Identity identity, String currencyId, BigDecimal amount) { + if (identity == null || amount == null || amount.signum() < 0) { + return false; + } + EconomySettings.Currency currency = requireCurrency(currencyId, null); + BigDecimal normalized = amount.setScale( + currency.display().fractionalDigits(), + currency.balances().rounding() + ); + if (amount.signum() > 0 && normalized.signum() == 0) { + return false; + } + return balanceSync(identity, currencyId) + .map(Account::balance) + .map(balance -> balance.subtract(normalized).compareTo(currency.balances().minimum()) >= 0) + .orElse(false); + } + + public MutationOutcome mutateSync( + Identity identity, + String currencyId, + BigDecimal amount, + TransactionType type, + String source, + String idempotencyKey + ) { + EconomySettings.Currency currency = requireCurrency(currencyId, null); + if (identity == null) { + return new MutationOutcome( + EconomyResultStatus.UNKNOWN_PLAYER, null, null, null, + "Player identity is unavailable", null, null + ); + } + MutationOutcome outcome = repository.mutate( + type, type, identity, currency, amount, source, idempotencyKey, null, + source, source + " economy operation", Map.of(), false, now() + ); + publish(outcome); + return outcome; + } + + public MutationOutcome mutateSync( + OfflinePlayer player, + String currencyId, + BigDecimal amount, + TransactionType type, + String idempotencyKey + ) { + return mutateSync( + resolveSync(player).orElse(null), + currencyId, + amount, + type, + "vault", + idempotencyKey + ); + } + + public boolean hasAccountSync(OfflinePlayer player, String currencyId) { + return resolveSync(player).map(identity -> hasAccountSync(identity, currencyId)).orElse(false); + } + + public boolean hasAccountSync(Identity identity, String currencyId) { + if (identity == null) { + return false; + } + EconomySettings.Currency currency = requireCurrency(currencyId, null); + return repository.accountExists(identity, currency); + } + + public void applyRemoteBalance(EconomyBalanceMessage message) { + if (message == null + || message.getSchemaVersion() != EconomyBalanceMessage.SCHEMA_VERSION + || message.getPlayerId() <= 0L + || message.getPlayerUuid() == null + || message.getCurrencyId() == null + || message.getScopeKey() == null + || message.getBalanceVersion() < 0L + || message.getSettingsVersion() < 0L) { + return; + } + EconomySettings.Currency currency = settings.currencies().get(message.getCurrencyId()); + if (currency == null || !currency.scope().key().equals(message.getScopeKey())) { + return; + } + UUID uuid; + try { + uuid = UUID.fromString(message.getPlayerUuid()); + } catch (RuntimeException exception) { + return; + } + String key = cacheKey(uuid, currency.id(), currency.scope().key()); + main(() -> { + Account current = cache.get(key); + if (current != null + && current.version() >= message.getBalanceVersion() + && current.settingsVersion() >= message.getSettingsVersion()) { + return; + } + if (Bukkit.getPlayer(uuid) == null && current == null) { + return; + } + refreshCanonicalAtLeast( + uuid, + message.getPlayerId(), + currency, + message.getBalanceVersion(), + message.getSettingsVersion() + ).exceptionally(error -> { + feature.getLogger().warning( + "Could not refresh remote Economy invalidation for " + uuid + ": " + rootMessage(error) + ); + return null; + }); + }); + } + + public void applyRemoteTransfer(EconomyTransferMessage message) { + if (message == null + || message.getSchemaVersion() != EconomyTransferMessage.SCHEMA_VERSION + || message.getOperationId() == null + || message.getRecipientPlayerId() <= 0L + || message.getRecipientPlayerUuid() == null + || message.getCurrencyId() == null + || message.getScopeKey() == null) { + return; + } + EconomySettings.Currency currency = settings.currencies().get(message.getCurrencyId()); + if (currency == null || !currency.scope().key().equals(message.getScopeKey())) { + return; + } + UUID operationId; + UUID recipientUuid; + try { + operationId = UUID.fromString(message.getOperationId()); + recipientUuid = UUID.fromString(message.getRecipientPlayerUuid()); + } catch (RuntimeException exception) { + return; + } + main(() -> { + if (Bukkit.getPlayer(recipientUuid) == null) { + return; + } + submit(() -> repository.transferReceipt(operationId)).thenCompose(optional -> { + TransferReceipt receipt = optional.orElseThrow(() -> new IllegalArgumentException( + "Unknown Economy transfer: " + operationId + )); + if (receipt.recipient().playerId() != message.getRecipientPlayerId() + || !receipt.recipient().playerUuid().equals(recipientUuid) + || !receipt.currencyId().equals(currency.id()) + || !receipt.scopeKey().equals(currency.scope().key())) { + return CompletableFuture.failedFuture(new IllegalArgumentException( + "Economy transfer notification does not match the committed transaction" + )); + } + return refreshCanonicalFresh(recipientUuid, message.getRecipientPlayerId(), currency) + .thenApply(account -> new VerifiedTransfer(receipt, account)); + }).whenComplete((verified, failure) -> main(() -> { + Player recipient = Bukkit.getPlayer(recipientUuid); + if (recipient == null) { + return; + } + if (failure != null) { + feature.getLogger().warning( + "Could not verify Economy transfer for " + recipientUuid + + ": " + rootMessage(failure) + ); + return; + } + if (!markTransferNotification(operationId)) { + return; + } + feature.send(recipient, "economy.pay.received", Map.of( + "player", verified.receipt().sender().playerName(), + "amount", format(currency.id(), verified.receipt().amount()), + "balance", format(currency.id(), verified.account().balance()) + )); + })); + }); + } + + public EconomySettings.Currency requireCurrency(String currencyId) { + return requireCurrency(currencyId, null); + } + + public EconomySettings.Currency primaryCurrency() { + return requireCurrency(settings.vault().primaryCurrency(), null); + } + + public void main(Runnable runnable) { + if (closed.get()) { + return; + } + if (Bukkit.isPrimaryThread()) { + runnable.run(); + } else { + feature.getLifecycleManager().getTaskManager().scheduleOneTimeTask(() -> { + if (!closed.get()) { + runnable.run(); + } + }); + } + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + cache.clear(); + refreshes.clear(); + batchRefreshes.clear(); + onlinePlayers.clear(); + notifiedTransfers.clear(); + } + + private EconomySettings.Currency requireCurrency(String currencyId, String requestedScope) { + EconomySettings.Currency currency; + try { + currency = settings.requireCurrency(currencyId); + } catch (IllegalArgumentException exception) { + throw new UnknownCurrencyException(exception.getMessage(), exception); + } + if (requestedScope != null && !requestedScope.isBlank() + && !currency.scope().key().equals(requestedScope.trim())) { + throw new UnknownCurrencyException( + "Currency " + currency.id() + " is not configured for scope " + requestedScope + ); + } + return currency; + } + + private CompletionStage resolve(EconomyAccountRef account) { + Objects.requireNonNull(account, "account"); + return identityResolver.findByUuid(account.playerUuid()).thenApply(optional -> optional + .map(EconomyService::identity) + .map(resolved -> { + if (account.playerId() != null + && account.playerId() > 0L + && account.playerId() != resolved.playerId()) { + throw new IllegalArgumentException( + "Player ID does not match UUID: " + account.playerUuid() + ); + } + return resolved; + }) + .orElseThrow(() -> new UnknownPlayerException("Unknown player: " + account.playerUuid()))); + } + + private Optional awaitIdentity( + CompletionStage> lookup, + String identifier + ) { + try { + return lookup.toCompletableFuture() + .get(SYNCHRONOUS_IDENTITY_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) + .map(EconomyService::identity); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while resolving player identity " + identifier, exception); + } catch (ExecutionException exception) { + throw new IllegalStateException("Could not resolve player identity " + identifier, unwrap(exception)); + } catch (TimeoutException exception) { + throw new IllegalStateException("Timed out resolving player identity " + identifier, exception); + } + } + + private CompletableFuture submit(java.util.function.Supplier work) { + if (closed.get()) { + return CompletableFuture.failedFuture(new IllegalStateException("Economy is closed")); + } + return feature.getLifecycleManager().getTaskManager().supplyAsync(work); + } + + private Account cache(Account account) { + if (account == null || closed.get()) { + return account; + } + // The cache only serves online-player placeholders and UI. Keeping offline accounts + // indefinitely would turn normal Vault/admin traffic into an unbounded memory cache. + if (!onlinePlayers.contains(account.identity().playerUuid())) { + return account; + } + return cache.merge( + cacheKey(account.identity().playerUuid(), account.currencyId(), account.scopeKey()), + account, + EconomyService::mergeAccount + ); + } + + private void refreshOnlinePlayers() { + if (closed.get()) { + return; + } + for (Player player : Bukkit.getOnlinePlayers()) { + preload(player); + } + } + + private CompletableFuture refreshCanonicalAtLeast( + UUID playerUuid, + long expectedPlayerId, + EconomySettings.Currency currency, + long minimumBalanceVersion, + long minimumSettingsVersion + ) { + return resolveCanonical(playerUuid, expectedPlayerId).thenCompose(identity -> + refreshAccount(identity, currency).thenCompose(account -> { + if (account.version() >= minimumBalanceVersion + && account.settingsVersion() >= minimumSettingsVersion) { + return CompletableFuture.completedFuture(account); + } + // A periodic read may have started before the remote commit and can therefore + // complete with an older snapshot. Force one post-invalidation read instead of + // reusing that stale in-flight future. + return refreshAccountFresh(identity, currency); + }) + ).toCompletableFuture(); + } + + private CompletableFuture refreshCanonicalFresh( + UUID playerUuid, + long expectedPlayerId, + EconomySettings.Currency currency + ) { + return resolveCanonical(playerUuid, expectedPlayerId) + .thenCompose(identity -> refreshAccountFresh(identity, currency)) + .toCompletableFuture(); + } + + private CompletionStage resolveCanonical(UUID playerUuid, long expectedPlayerId) { + return identityResolver.findByUuid(playerUuid).thenCompose(resolved -> { + if (resolved == null || resolved.isEmpty() || closed.get()) { + return CompletableFuture.failedFuture(new IllegalArgumentException( + "Unknown player identity: " + playerUuid + )); + } + Identity identity = identity(resolved.get()); + if (identity.playerId() != expectedPlayerId) { + return CompletableFuture.failedFuture(new IllegalArgumentException( + "Player identity mismatch for " + playerUuid + )); + } + return CompletableFuture.completedFuture(identity); + }); + } + + private CompletableFuture refreshAccount(Identity identity, EconomySettings.Currency currency) { + String key = cacheKey(identity.playerUuid(), currency.id(), currency.scope().key()); + return refreshes.computeIfAbsent(key, ignored -> { + CompletableFuture refresh = submit(() -> repository.balance(identity, currency, now())) + .thenApply(account -> onlinePlayers.contains(identity.playerUuid()) ? cache(account) : account); + refresh.whenComplete((account, failure) -> refreshes.remove(key, refresh)); + return refresh; + }); + } + + private CompletableFuture refreshAccountFresh( + Identity identity, + EconomySettings.Currency currency + ) { + return submit(() -> repository.balance(identity, currency, now())) + .thenApply(account -> onlinePlayers.contains(identity.playerUuid()) ? cache(account) : account); + } + + private Account publishAccountMutation(MutationOutcome outcome) { + if (outcome == null || !outcome.successful() || outcome.account() == null) { + String message = outcome == null || outcome.message().isBlank() + ? "Economy account-setting operation failed" + : outcome.message(); + throw new IllegalStateException(message); + } + publish(outcome); + return outcome.account(); + } + + private EconomyResult publishTransferAndConvert( + MutationOutcome outcome, + Identity sender, + Identity recipient, + EconomySettings.Currency currency, + BigDecimal requestedAmount + ) { + publish(outcome); + if (outcome != null + && outcome.status() == EconomyResultStatus.SUCCESS + && outcome.operationId() != null + && outcome.counterpartBalance() != null) { + BigDecimal amount = requestedAmount.setScale( + currency.display().fractionalDigits(), + currency.balances().rounding() + ); + try { + notifyLocalTransfer( + outcome.operationId(), + sender, + recipient, + currency, + amount + ); + EconomyMessaging current = messaging; + if (current != null) { + current.publishTransfer( + outcome.operationId().toString(), + recipient, + currency.id(), + currency.scope().key() + ); + } + } catch (RuntimeException failure) { + feature.getLogger().warning( + "Could not fan out committed Economy transfer " + outcome.operationId() + + ": " + rootMessage(failure) + ); + } + } + return result(outcome); + } + + private void notifyLocalTransfer( + UUID operationId, + Identity sender, + Identity recipient, + EconomySettings.Currency currency, + BigDecimal amount + ) { + main(() -> { + if (Bukkit.getPlayer(recipient.playerUuid()) == null) { + return; + } + refreshAccount(recipient, currency).whenComplete((account, failure) -> main(() -> { + Player online = Bukkit.getPlayer(recipient.playerUuid()); + if (online == null) { + return; + } + if (failure != null) { + feature.getLogger().warning( + "Could not refresh local Economy transfer for " + recipient.playerUuid() + + ": " + rootMessage(failure) + ); + return; + } + if (!markTransferNotification(operationId)) { + return; + } + feature.send(online, "economy.pay.received", Map.of( + "player", sender.playerName(), + "amount", format(currency.id(), amount), + "balance", format(currency.id(), account.balance()) + )); + })); + }); + } + + private boolean markTransferNotification(UUID operationId) { + long currentTime = now(); + if (notifiedTransfers.putIfAbsent(operationId, currentTime) != null) { + return false; + } + if (notifiedTransfers.size() > MAX_NOTIFICATION_DEDUP_ENTRIES) { + long cutoff = currentTime - NOTIFICATION_DEDUP_MILLIS; + notifiedTransfers.entrySet().removeIf(entry -> entry.getValue() < cutoff); + int excess = notifiedTransfers.size() - MAX_NOTIFICATION_DEDUP_ENTRIES; + if (excess > 0) { + notifiedTransfers.entrySet().stream() + .sorted(Map.Entry.comparingByValue()) + .limit(excess) + .map(Map.Entry::getKey) + .toList() + .forEach(notifiedTransfers::remove); + } + } + return true; + } + + static Account mergeAccount(Account current, Account update) { + Account balanceSource = update.version() >= current.version() ? update : current; + Account settingsSource = update.settingsVersion() >= current.settingsVersion() ? update : current; + return new Account( + balanceSource.accountId(), + balanceSource.identity(), + balanceSource.currencyId(), + balanceSource.scopeKey(), + balanceSource.balance(), + balanceSource.version(), + settingsSource.settingsVersion(), + settingsSource.paymentsEnabled(), + settingsSource.status() + ); + } + + private EconomyResult publishAndConvert(MutationOutcome outcome) { + publish(outcome); + return result(outcome); + } + + private void publish(MutationOutcome outcome) { + if (outcome == null || !outcome.successful()) { + return; + } + String operationId = outcome.operationId() == null ? "" : outcome.operationId().toString(); + publishAccountSnapshot(operationId, outcome.account()); + publishAccountSnapshot(operationId, outcome.counterpart()); + } + + private void publishAccountSnapshot(String operationId, Account account) { + if (account == null) { + return; + } + try { + cache(account); + EconomyMessaging current = messaging; + if (current != null) { + current.publish(operationId, account); + } + } catch (RuntimeException failure) { + // The database commit already succeeded. Cache or Redis fan-out must never + // convert a committed economy operation into an apparent caller failure. + feature.getLogger().warning( + "Could not fan out committed Economy operation " + operationId + ": " + rootMessage(failure) + ); + } + } + + public String userFacingFailure(Throwable failure) { + Throwable root = unwrap(failure); + if (root instanceof EconomyRejectedException + || root instanceof UnknownPlayerException + || root instanceof UnknownCurrencyException + || root instanceof IllegalArgumentException) { + return rootMessage(root); + } + return "De economie is tijdelijk niet beschikbaar. Probeer het later opnieuw."; + } + + private EconomyResult failureResult(Throwable failure) { + Throwable root = unwrap(failure); + if (root instanceof EconomyRejectedException rejected) { + return new EconomyResult(rejected.status(), null, null, null, rootMessage(rejected)); + } + if (root instanceof UnknownPlayerException) { + return new EconomyResult(EconomyResultStatus.UNKNOWN_PLAYER, null, null, null, rootMessage(root)); + } + if (root instanceof UnknownCurrencyException) { + return new EconomyResult(EconomyResultStatus.UNKNOWN_CURRENCY, null, null, null, rootMessage(root)); + } + if (root instanceof IllegalArgumentException) { + return new EconomyResult(EconomyResultStatus.INVALID_AMOUNT, null, null, null, rootMessage(root)); + } + feature.getLogger().log(java.util.logging.Level.WARNING, "Economy operation failed", root); + return new EconomyResult( + EconomyResultStatus.TEMPORARY_FAILURE, + null, + null, + null, + "Economy is temporarily unavailable" + ); + } + + private static EconomyResult result(MutationOutcome outcome) { + return new EconomyResult( + outcome.status(), + outcome.operationId(), + outcome.balance(), + outcome.counterpartBalance(), + outcome.message() + ); + } + + private EconomyBalance apiBalance(Account account) { + return new EconomyBalance( + new EconomyAccountRef( + account.identity().playerId(), + account.identity().playerUuid(), + account.identity().playerName(), + account.currencyId(), + account.scopeKey() + ), + account.balance(), + account.version() + ); + } + + private EconomyCurrency apiCurrency(EconomySettings.Currency currency) { + return new EconomyCurrency( + currency.id(), + currency.display().singular(), + currency.display().plural(), + currency.display().symbol(), + currency.display().fractionalDigits(), + currency.scope(), + currency.balances().minimum(), + currency.balances().maximum(), + currency.commands().pay() + ); + } + + private static Identity identity(PlayerIdentity identity) { + return new Identity(identity.playerId(), identity.uuid(), identity.username()); + } + + private static String cacheKey(UUID playerUuid, String currencyId, String scopeKey) { + return playerUuid + "|" + currencyId + "|" + scopeKey; + } + + static TransactionType requestedJournalType( + TransactionType operationType, + Map metadata + ) { + if (metadata == null) { + return operationType; + } + String requested = metadata.get("transaction_type"); + if (requested == null || requested.isBlank()) { + return operationType; + } + try { + TransactionType candidate = TransactionType.valueOf(requested.trim().toUpperCase(Locale.ROOT)); + return sameMutationDirection(operationType, candidate) ? candidate : operationType; + } catch (IllegalArgumentException ignored) { + return operationType; + } + } + + private static boolean sameMutationDirection(TransactionType left, TransactionType right) { + return switch (left) { + case DEPOSIT, ADMIN_ADD, LOTTERY_PAYOUT, LOTTERY_REFUND, VAULT_DEPOSIT -> switch (right) { + case DEPOSIT, ADMIN_ADD, LOTTERY_PAYOUT, LOTTERY_REFUND, VAULT_DEPOSIT -> true; + default -> false; + }; + case WITHDRAW, ADMIN_REMOVE, LOTTERY_PURCHASE, LOTTERY_DONATION, VAULT_WITHDRAW -> switch (right) { + case WITHDRAW, ADMIN_REMOVE, LOTTERY_PURCHASE, LOTTERY_DONATION, VAULT_WITHDRAW -> true; + default -> false; + }; + case SET, ADMIN_SET -> right == TransactionType.SET || right == TransactionType.ADMIN_SET; + case TRANSFER -> right == TransactionType.TRANSFER; + case ACCOUNT_CREATED -> right == TransactionType.ACCOUNT_CREATED; + case PAYMENTS_ENABLED, PAYMENTS_DISABLED, ACCOUNT_FROZEN, ACCOUNT_UNFROZEN -> left == right; + }; + } + + private long now() { + return System.currentTimeMillis(); + } + + private static Throwable unwrap(Throwable failure) { + Throwable current = failure; + while ((current instanceof java.util.concurrent.CompletionException + || current instanceof java.util.concurrent.ExecutionException) + && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + private static String rootMessage(Throwable throwable) { + Throwable current = unwrap(throwable); + while (current.getCause() != null && current.getCause() != current) { + current = current.getCause(); + } + String message = current.getMessage(); + return message == null || message.isBlank() ? current.getClass().getSimpleName() : message; + } + + private record VerifiedTransfer(TransferReceipt receipt, Account account) { + } + + private record ResolvedTransfer(Identity sender, Identity recipient) { + } + + private static final class UnknownPlayerException extends IllegalArgumentException { + private static final long serialVersionUID = 1L; + + private UnknownPlayerException(String message) { + super(message); + } + } + + private static final class UnknownCurrencyException extends IllegalArgumentException { + private static final long serialVersionUID = 1L; + + private UnknownCurrencyException(String message) { + super(message); + } + + private UnknownCurrencyException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/vault/VaultEconomyProvider.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/vault/VaultEconomyProvider.java new file mode 100644 index 000000000..20acc4448 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/vault/VaultEconomyProvider.java @@ -0,0 +1,390 @@ +package nl.hauntedmc.serverfeatures.features.economy.vault; + +import net.milkbowl.vault.economy.Economy; +import net.milkbowl.vault.economy.EconomyResponse; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.Account; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.Identity; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.MutationOutcome; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.TransactionType; +import nl.hauntedmc.serverfeatures.features.economy.service.EconomyService; +import org.bukkit.OfflinePlayer; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** Complete Vault compatibility adapter for the configured primary currency. */ +@SuppressWarnings("deprecation") +public final class VaultEconomyProvider implements Economy { + private final EconomyService service; + private final String currencyId; + private volatile boolean enabled = true; + + public VaultEconomyProvider(EconomyService service, String currencyId) { + this.service = service; + this.currencyId = currencyId; + } + + public void disable() { + enabled = false; + } + + @Override + public boolean isEnabled() { + return enabled; + } + + @Override + public String getName() { + return "ServerFeatures Economy"; + } + + @Override + public boolean hasBankSupport() { + return false; + } + + @Override + public int fractionalDigits() { + return service.requireCurrency(currencyId).display().fractionalDigits(); + } + + @Override + public String format(double amount) { + return service.format(currencyId, decimal(amount)); + } + + @Override + public String currencyNamePlural() { + return service.requireCurrency(currencyId).display().plural(); + } + + @Override + public String currencyNameSingular() { + return service.requireCurrency(currencyId).display().singular(); + } + + @Override + public boolean hasAccount(String playerName) { + try { + return service.resolveSync(playerName) + .map(identity -> service.hasAccountSync(identity, currencyId)) + .orElse(false); + } catch (RuntimeException exception) { + return false; + } + } + + @Override + public boolean hasAccount(OfflinePlayer player) { + try { + return service.hasAccountSync(player, currencyId); + } catch (RuntimeException exception) { + return false; + } + } + + @Override + public boolean hasAccount(String playerName, String worldName) { + return hasAccount(playerName); + } + + @Override + public boolean hasAccount(OfflinePlayer player, String worldName) { + return hasAccount(player); + } + + @Override + public double getBalance(String playerName) { + try { + return service.resolveSync(playerName) + .flatMap(identity -> service.balanceSync(identity, currencyId)) + .map(Account::balance).map(BigDecimal::doubleValue).orElse(0.0D); + } catch (RuntimeException exception) { + return 0.0D; + } + } + + @Override + public double getBalance(OfflinePlayer player) { + try { + return service.balanceSync(player, currencyId) + .map(Account::balance).map(BigDecimal::doubleValue).orElse(0.0D); + } catch (RuntimeException exception) { + return 0.0D; + } + } + + @Override + public double getBalance(String playerName, String world) { + return getBalance(playerName); + } + + @Override + public double getBalance(OfflinePlayer player, String world) { + return getBalance(player); + } + + @Override + public boolean has(String playerName, double amount) { + if (!valid(amount) || amount < 0.0D) { + return false; + } + try { + return service.resolveSync(playerName) + .map(identity -> service.hasBalanceSync(identity, currencyId, decimal(amount))) + .orElse(false); + } catch (RuntimeException exception) { + return false; + } + } + + @Override + public boolean has(OfflinePlayer player, double amount) { + if (!valid(amount) || amount < 0.0D) { + return false; + } + try { + return service.resolveSync(player) + .map(identity -> service.hasBalanceSync(identity, currencyId, decimal(amount))) + .orElse(false); + } catch (RuntimeException exception) { + return false; + } + } + + @Override + public boolean has(String playerName, String worldName, double amount) { + return has(playerName, amount); + } + + @Override + public boolean has(OfflinePlayer player, String worldName, double amount) { + return has(player, amount); + } + + @Override + public EconomyResponse withdrawPlayer(String playerName, double amount) { + return mutateResolved(() -> service.resolveSync(playerName), amount, TransactionType.VAULT_WITHDRAW); + } + + @Override + public EconomyResponse withdrawPlayer(OfflinePlayer player, double amount) { + return mutateResolved(() -> service.resolveSync(player), amount, TransactionType.VAULT_WITHDRAW); + } + + @Override + public EconomyResponse withdrawPlayer(String playerName, String worldName, double amount) { + return withdrawPlayer(playerName, amount); + } + + @Override + public EconomyResponse withdrawPlayer(OfflinePlayer player, String worldName, double amount) { + return withdrawPlayer(player, amount); + } + + @Override + public EconomyResponse depositPlayer(String playerName, double amount) { + return mutateResolved(() -> service.resolveSync(playerName), amount, TransactionType.VAULT_DEPOSIT); + } + + @Override + public EconomyResponse depositPlayer(OfflinePlayer player, double amount) { + return mutateResolved(() -> service.resolveSync(player), amount, TransactionType.VAULT_DEPOSIT); + } + + @Override + public EconomyResponse depositPlayer(String playerName, String worldName, double amount) { + return depositPlayer(playerName, amount); + } + + @Override + public EconomyResponse depositPlayer(OfflinePlayer player, String worldName, double amount) { + return depositPlayer(player, amount); + } + + @Override + public EconomyResponse createBank(String name, String player) { + return notImplemented(); + } + + @Override + public EconomyResponse createBank(String name, OfflinePlayer player) { + return notImplemented(); + } + + @Override + public EconomyResponse deleteBank(String name) { + return notImplemented(); + } + + @Override + public EconomyResponse bankBalance(String name) { + return notImplemented(); + } + + @Override + public EconomyResponse bankHas(String name, double amount) { + return notImplemented(); + } + + @Override + public EconomyResponse bankWithdraw(String name, double amount) { + return notImplemented(); + } + + @Override + public EconomyResponse bankDeposit(String name, double amount) { + return notImplemented(); + } + + @Override + public EconomyResponse isBankOwner(String name, String playerName) { + return notImplemented(); + } + + @Override + public EconomyResponse isBankOwner(String name, OfflinePlayer player) { + return notImplemented(); + } + + @Override + public EconomyResponse isBankMember(String name, String playerName) { + return notImplemented(); + } + + @Override + public EconomyResponse isBankMember(String name, OfflinePlayer player) { + return notImplemented(); + } + + @Override + public List getBanks() { + return List.of(); + } + + @Override + public boolean createPlayerAccount(String playerName) { + try { + return service.resolveSync(playerName) + .flatMap(identity -> service.balanceSync(identity, currencyId)) + .isPresent(); + } catch (RuntimeException exception) { + return false; + } + } + + @Override + public boolean createPlayerAccount(OfflinePlayer player) { + try { + return service.balanceSync(player, currencyId).isPresent(); + } catch (RuntimeException exception) { + return false; + } + } + + @Override + public boolean createPlayerAccount(String playerName, String worldName) { + return createPlayerAccount(playerName); + } + + @Override + public boolean createPlayerAccount(OfflinePlayer player, String worldName) { + return createPlayerAccount(player); + } + + private EconomyResponse mutate(Identity identity, double rawAmount, TransactionType type) { + if (!enabled) { + return failure(rawAmount, 0.0D, "Economy provider is disabled"); + } + if (!valid(rawAmount) || rawAmount < 0.0D) { + return failure(rawAmount, safeCurrent(identity), "Amount must be finite and non-negative"); + } + if (rawAmount == 0.0D) { + return new EconomyResponse( + 0.0D, + safeCurrent(identity), + EconomyResponse.ResponseType.SUCCESS, + "" + ); + } + MutationOutcome outcome; + try { + outcome = service.mutateSync( + identity, + currencyId, + decimal(rawAmount), + type, + "vault", + UUID.randomUUID().toString() + ); + } catch (RuntimeException exception) { + return failure(rawAmount, safeCurrent(identity), rootMessage(exception)); + } + if (!outcome.successful()) { + return failure(rawAmount, outcome.balance() == null ? safeCurrent(identity) : outcome.balance().doubleValue(), + outcome.message().isBlank() ? outcome.status().name() : outcome.message()); + } + return new EconomyResponse( + rawAmount, + outcome.balance().doubleValue(), + EconomyResponse.ResponseType.SUCCESS, + "" + ); + } + + private EconomyResponse mutateResolved( + java.util.function.Supplier> resolver, + double amount, + TransactionType type + ) { + try { + return resolver.get() + .map(identity -> mutate(identity, amount, type)) + .orElseGet(() -> failure(amount, 0.0D, "Unknown player")); + } catch (RuntimeException exception) { + return failure(amount, 0.0D, rootMessage(exception)); + } + } + + private double safeCurrent(Identity identity) { + try { + return service.balanceSync(identity, currencyId) + .map(Account::balance) + .map(BigDecimal::doubleValue) + .orElse(0.0D); + } catch (RuntimeException exception) { + return 0.0D; + } + } + + private static EconomyResponse failure(double amount, double balance, String message) { + return new EconomyResponse(amount, balance, EconomyResponse.ResponseType.FAILURE, message); + } + + private static EconomyResponse notImplemented() { + return new EconomyResponse(0.0D, 0.0D, EconomyResponse.ResponseType.NOT_IMPLEMENTED, + "Bank accounts are not supported"); + } + + private static BigDecimal decimal(double amount) { + if (!valid(amount)) { + throw new IllegalArgumentException("Amount must be finite"); + } + return BigDecimal.valueOf(amount); + } + + private static boolean valid(double amount) { + return Double.isFinite(amount); + } + + private static String rootMessage(Throwable throwable) { + Throwable current = throwable; + while (current.getCause() != null && current.getCause() != current) { + current = current.getCause(); + } + String message = current.getMessage(); + return message == null || message.isBlank() ? current.getClass().getSimpleName() : message; + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/vault/VaultProviderRegistration.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/vault/VaultProviderRegistration.java new file mode 100644 index 000000000..54da6c8f9 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/economy/vault/VaultProviderRegistration.java @@ -0,0 +1,156 @@ +package nl.hauntedmc.serverfeatures.features.economy.vault; + +import nl.hauntedmc.serverfeatures.features.economy.Economy; +import nl.hauntedmc.serverfeatures.features.economy.EconomyVaultIntegration; +import nl.hauntedmc.serverfeatures.features.economy.config.EconomySettings.VaultConflictPolicy; +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.RegisteredServiceProvider; +import org.bukkit.plugin.ServicePriority; +import org.bukkit.plugin.ServicesManager; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** Registers and cleanly unregisters the optional Vault provider. */ +public final class VaultProviderRegistration implements EconomyVaultIntegration { + private final Economy feature; + private final List displacedProviders = new ArrayList<>(); + private VaultEconomyProvider provider; + private String status = "disabled"; + + public VaultProviderRegistration(Economy feature) { + this.feature = Objects.requireNonNull(feature, "feature"); + } + + @Override + public void register() { + if (!feature.settings().vault().enabled()) { + status = "disabled"; + return; + } + if (!Bukkit.getPluginManager().isPluginEnabled("Vault")) { + status = "vault-missing"; + feature.getLogger().warning("Vault integration is enabled, but Vault is not installed."); + return; + } + + ServicesManager services = Bukkit.getServicesManager(); + RegisteredServiceProvider existing = + services.getRegistration(net.milkbowl.vault.economy.Economy.class); + VaultConflictPolicy policy = feature.settings().vault().conflictPolicy(); + if (existing != null && existing.getProvider() != null) { + if (policy == VaultConflictPolicy.FAIL) { + throw new IllegalStateException( + "Another Vault economy provider is already active: " + existing.getProvider().getName() + ); + } + if (policy == VaultConflictPolicy.SKIP) { + status = "skipped:" + existing.getProvider().getName(); + feature.getLogger().warning( + "Keeping existing Vault economy provider: " + existing.getProvider().getName() + ); + return; + } + displaceExistingProviders(services); + } + + VaultEconomyProvider candidate = new VaultEconomyProvider( + feature.service(), + feature.settings().vault().primaryCurrency() + ); + services.register( + net.milkbowl.vault.economy.Economy.class, + candidate, + feature.getPlugin(), + ServicePriority.Highest + ); + provider = candidate; + RegisteredServiceProvider selected = + services.getRegistration(net.milkbowl.vault.economy.Economy.class); + if (selected == null || selected.getProvider() != candidate) { + rollbackRegistration(services); + throw new IllegalStateException("ServerFeatures Economy did not become the active Vault provider"); + } + status = "registered:" + feature.settings().vault().primaryCurrency(); + if (!displacedProviders.isEmpty()) { + status += ":replaced=" + displacedProviders.size(); + } + } + + private void displaceExistingProviders(ServicesManager services) { + List> registrations = + List.copyOf(services.getRegistrations(net.milkbowl.vault.economy.Economy.class)); + for (RegisteredServiceProvider registration : registrations) { + net.milkbowl.vault.economy.Economy existingProvider = registration.getProvider(); + if (existingProvider == null) { + continue; + } + displacedProviders.add(new DisplacedProvider( + existingProvider, + registration.getPlugin(), + registration.getPriority() + )); + services.unregister(net.milkbowl.vault.economy.Economy.class, existingProvider); + feature.getLogger().warning("Replaced Vault economy provider: " + existingProvider.getName()); + } + } + + private void rollbackRegistration(ServicesManager services) { + VaultEconomyProvider current = provider; + provider = null; + if (current != null) { + current.disable(); + services.unregister(net.milkbowl.vault.economy.Economy.class, current); + } + restoreDisplacedProviders(services); + } + + private void restoreDisplacedProviders(ServicesManager services) { + List> current = + List.copyOf(services.getRegistrations(net.milkbowl.vault.economy.Economy.class)); + for (DisplacedProvider displaced : List.copyOf(displacedProviders)) { + Plugin plugin = displaced.plugin(); + if (!plugin.isEnabled()) { + continue; + } + boolean alreadyRegistered = current.stream() + .anyMatch(registration -> registration.getProvider() == displaced.provider()); + if (!alreadyRegistered) { + services.register( + net.milkbowl.vault.economy.Economy.class, + displaced.provider(), + plugin, + displaced.priority() + ); + } + } + displacedProviders.clear(); + } + + @Override + public String status() { + return status; + } + + @Override + public void close() { + ServicesManager services = Bukkit.getServicesManager(); + VaultEconomyProvider current = provider; + provider = null; + if (current != null) { + current.disable(); + services.unregister(net.milkbowl.vault.economy.Economy.class, current); + } + restoreDisplacedProviders(services); + status = "disabled"; + } + + private record DisplacedProvider( + net.milkbowl.vault.economy.Economy provider, + Plugin plugin, + ServicePriority priority + ) { + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/Lottery.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/Lottery.java index 8893637ed..51133510d 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/Lottery.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/Lottery.java @@ -11,7 +11,9 @@ import nl.hauntedmc.serverfeatures.features.lottery.command.LotteryCommand; import nl.hauntedmc.serverfeatures.features.lottery.config.LotterySettings; import nl.hauntedmc.serverfeatures.features.lottery.draw.LotteryDrawEngine; -import nl.hauntedmc.serverfeatures.features.lottery.economy.LotteryEconomy; +import nl.hauntedmc.serverfeatures.api.economy.EconomyApi; +import nl.hauntedmc.serverfeatures.features.lottery.economy.BuiltinLotteryEconomy; +import nl.hauntedmc.serverfeatures.features.lottery.economy.LotteryEconomyGateway; import nl.hauntedmc.serverfeatures.features.lottery.entity.LotteryEntryEntity; import nl.hauntedmc.serverfeatures.features.lottery.entity.LotteryPayoutEntity; import nl.hauntedmc.serverfeatures.features.lottery.entity.LotteryPlayerStatsEntity; @@ -24,6 +26,8 @@ import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.util.List; import java.util.Map; @@ -56,6 +60,8 @@ public ConfigMap getDefaultConfig() { ConfigMap defaults = new ConfigMap(); defaults.put("enabled", false); defaults.put("lottery_key", "$server"); + defaults.put("economy.backend", "VAULT"); + defaults.put("economy.builtin.currency", "money"); defaults.put("schedule.mode", "INTERVAL"); defaults.put("schedule.interval", "12h"); defaults.put("schedule.timezone", "Europe/Amsterdam"); @@ -170,7 +176,8 @@ public void initialize() { "Lottery requires MYSQL/system_data_rw and could not create its ORM context." )); - LotteryEconomy economy = LotteryEconomy.discover(); + LotteryEconomyGateway economy = createEconomyGateway(); + getLogger().info("Lottery economy backend: " + economy.backendName()); service = new LotteryService( this, settings, @@ -189,6 +196,45 @@ public void initialize() { service.start(); } + private LotteryEconomyGateway createEconomyGateway() { + return switch (settings.economy().backend()) { + case VAULT -> createVaultEconomyGateway(); + case BUILTIN -> { + EconomyApi api = getLifecycleManager().getApiManager().findService(EconomyApi.class) + .orElseThrow(() -> new IllegalStateException( + "Lottery BUILTIN backend requires the Economy feature to be enabled" + )); + yield new BuiltinLotteryEconomy(api, settings.economy().builtinCurrency()); + } + }; + } + + private LotteryEconomyGateway createVaultEconomyGateway() { + if (!Bukkit.getPluginManager().isPluginEnabled("Vault")) { + throw new IllegalStateException("Lottery VAULT backend requires Vault to be installed and enabled"); + } + try { + Class implementation = Class.forName( + "nl.hauntedmc.serverfeatures.features.lottery.economy.LotteryEconomy", + true, + getClass().getClassLoader() + ); + Method discover = implementation.getMethod("discover"); + return (LotteryEconomyGateway) discover.invoke(null); + } catch (InvocationTargetException exception) { + Throwable cause = exception.getCause(); + if (cause instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (cause instanceof Error error) { + throw error; + } + throw new IllegalStateException("Could not initialize Lottery Vault backend", cause); + } catch (ReflectiveOperationException | LinkageError exception) { + throw new IllegalStateException("Could not initialize Lottery Vault backend", exception); + } + } + @Override public void disable() { if (placeholder != null) { diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/config/LotterySettings.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/config/LotterySettings.java index 08d2bf812..7b301c1d0 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/config/LotterySettings.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/config/LotterySettings.java @@ -19,6 +19,7 @@ /** Immutable and strictly validated Lottery configuration. */ public record LotterySettings( String lotteryKey, + Economy economy, Schedule schedule, Tickets tickets, Pot pot, @@ -33,6 +34,7 @@ public record LotterySettings( if (lotteryKey == null || !lotteryKey.matches("[a-z0-9][a-z0-9_.-]{0,63}")) { throw new IllegalArgumentException("lottery_key must match [a-z0-9][a-z0-9_.-]{0,63}"); } + Objects.requireNonNull(economy, "economy"); Objects.requireNonNull(schedule, "schedule"); Objects.requireNonNull(tickets, "tickets"); Objects.requireNonNull(pot, "pot"); @@ -43,12 +45,32 @@ public record LotterySettings( Objects.requireNonNull(history, "history"); } + public LotterySettings( + String lotteryKey, + Schedule schedule, + Tickets tickets, + Pot pot, + Prizes prizes, + AntiSnipe antiSnipe, + Broadcasts broadcasts, + Payouts payouts, + History history + ) { + this(lotteryKey, new Economy(EconomyBackend.VAULT, "money"), schedule, tickets, pot, prizes, + antiSnipe, broadcasts, payouts, history); + } + public static LotterySettings load(FeatureConfigHandler config, String serverName) { String configuredKey = text(config, "lottery_key", "$server"); String lotteryKey = "$server".equalsIgnoreCase(configuredKey) ? normalizeKey(serverName) : normalizeKey(configuredKey); + Economy economy = new Economy( + enumValue(EconomyBackend.class, text(config, "economy.backend", "VAULT"), "economy.backend"), + normalizeKey(text(config, "economy.builtin.currency", "money")) + ); + ScheduleMode scheduleMode = enumValue( ScheduleMode.class, text(config, "schedule.mode", "INTERVAL"), @@ -130,6 +152,7 @@ public static LotterySettings load(FeatureConfigHandler config, String serverNam return new LotterySettings( lotteryKey, + economy, new Schedule(scheduleMode, interval, timezone, fixedTimes), new Tickets(ticketPrice, maximumPerPlayer, maximumPerRound, maximumPerCommand), new Pot(basePot, payoutPercentage, donationsEnabled, minimumDonation), @@ -151,6 +174,18 @@ public long nextCloseAt(long now) { return schedule.nextCloseAt(now); } + public record Economy(EconomyBackend backend, String builtinCurrency) { + public Economy { + Objects.requireNonNull(backend, "backend"); + builtinCurrency = normalizeKey(builtinCurrency); + } + } + + public enum EconomyBackend { + BUILTIN, + VAULT + } + public record Schedule(ScheduleMode mode, Duration interval, ZoneId timezone, List fixedTimes) { public Schedule { Objects.requireNonNull(mode, "mode"); diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/economy/BuiltinLotteryEconomy.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/economy/BuiltinLotteryEconomy.java new file mode 100644 index 000000000..6a11b3986 --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/economy/BuiltinLotteryEconomy.java @@ -0,0 +1,149 @@ +package nl.hauntedmc.serverfeatures.features.lottery.economy; + +import nl.hauntedmc.serverfeatures.api.economy.EconomyAccountRef; +import nl.hauntedmc.serverfeatures.api.economy.EconomyApi; +import nl.hauntedmc.serverfeatures.api.economy.EconomyMutationRequest; +import nl.hauntedmc.serverfeatures.api.economy.EconomyResultStatus; +import nl.hauntedmc.serverfeatures.features.lottery.model.Money; +import org.bukkit.OfflinePlayer; + +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.Supplier; + +/** Native ServerFeatures Economy backend for Lottery. */ +public final class BuiltinLotteryEconomy implements LotteryEconomyGateway { + private final EconomyApi economy; + private final String currencyId; + + public BuiltinLotteryEconomy(EconomyApi economy, String currencyId) { + this.economy = Objects.requireNonNull(economy, "economy"); + this.currencyId = Objects.requireNonNull(currencyId, "currencyId"); + var currency = economy.currency(currencyId).orElseThrow(() -> new IllegalStateException( + "Lottery built-in currency is unavailable: " + currencyId + )); + if (currency.fractionalDigits() != Money.SCALE) { + throw new IllegalStateException( + "Lottery requires a built-in currency with exactly " + Money.SCALE + + " fractional digits: " + currencyId + ); + } + } + + @Override + public Optional cachedBalance(OfflinePlayer player) { + return economy.cachedBalance(account(player)).map(balance -> Money.of(balance.balance())); + } + + @Override + public CompletionStage withdraw(OfflinePlayer player, Money amount, String idempotencyKey) { + String type = idempotencyKey.startsWith("donation:") ? "LOTTERY_DONATION" : "LOTTERY_PURCHASE"; + EconomyMutationRequest request = request(player, amount, idempotencyKey, type); + return executeIdempotently(() -> economy.withdraw(request), 1) + .thenApply(BuiltinLotteryEconomy::result); + } + + @Override + public CompletionStage deposit(OfflinePlayer player, Money amount, String idempotencyKey) { + String type = idempotencyKey.startsWith("payout:") ? "LOTTERY_PAYOUT" : "LOTTERY_REFUND"; + EconomyMutationRequest request = request(player, amount, idempotencyKey, type); + return executeIdempotently(() -> economy.deposit(request), 1) + .thenApply(BuiltinLotteryEconomy::result); + } + + @Override + public String format(Money amount) { + return economy.format(currencyId, amount.amount()); + } + + @Override + public String backendName() { + return "Builtin:" + currencyId; + } + + private EconomyMutationRequest request( + OfflinePlayer player, + Money amount, + String idempotencyKey, + String transactionType + ) { + return new EconomyMutationRequest( + "lottery", + idempotencyKey, + account(player), + amount.amount(), + null, + "Lottery", + transactionType, + Map.of("transaction_type", transactionType) + ); + } + + private EconomyAccountRef account(OfflinePlayer player) { + var currency = economy.currency(currencyId).orElseThrow(); + return new EconomyAccountRef( + null, + player.getUniqueId(), + player.getName(), + currency.id(), + currency.scope().key() + ); + } + + private static CompletionStage executeIdempotently( + Supplier> operation, + int retriesRemaining + ) { + CompletableFuture completion = + new CompletableFuture<>(); + executeAttempt(operation, retriesRemaining, completion); + return completion; + } + + private static void executeAttempt( + Supplier> operation, + int retriesRemaining, + CompletableFuture completion + ) { + CompletionStage attempt; + try { + attempt = Objects.requireNonNull(operation.get(), "Economy API returned no completion stage"); + } catch (RuntimeException failure) { + if (retriesRemaining > 0) { + executeAttempt(operation, retriesRemaining - 1, completion); + } else { + completion.completeExceptionally(failure); + } + return; + } + attempt.whenComplete((result, failure) -> { + boolean retryable = failure != null + || result != null && result.status() == EconomyResultStatus.TEMPORARY_FAILURE; + if (retryable && retriesRemaining > 0) { + executeAttempt(operation, retriesRemaining - 1, completion); + } else if (failure != null) { + completion.completeExceptionally(failure); + } else if (result == null) { + completion.completeExceptionally(new IllegalStateException("Economy API returned no result")); + } else { + completion.complete(result); + } + }); + } + + private static EconomyResult result(nl.hauntedmc.serverfeatures.api.economy.EconomyResult result) { + if (result.successful()) { + return EconomyResult.success( + result.message(), + result.operationId() == null ? "" : result.operationId().toString() + ); + } + if (result.status() == EconomyResultStatus.TEMPORARY_FAILURE) { + return EconomyResult.uncertain(result.message()); + } + return EconomyResult.failure(result.message().isBlank() ? result.status().name() : result.message()); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/economy/LotteryEconomy.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/economy/LotteryEconomy.java index 331700146..8efa2e92c 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/economy/LotteryEconomy.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/economy/LotteryEconomy.java @@ -8,10 +8,12 @@ import org.bukkit.plugin.RegisteredServiceProvider; import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; -/** Main-thread-only Vault economy boundary. */ -public final class LotteryEconomy { - +/** Legacy Vault Lottery backend. */ +public final class LotteryEconomy implements LotteryEconomyGateway { private final Economy economy; private LotteryEconomy(Economy economy) { @@ -21,43 +23,47 @@ private LotteryEconomy(Economy economy) { public static LotteryEconomy discover() { RegisteredServiceProvider registration = Bukkit.getServicesManager().getRegistration(Economy.class); if (registration == null || registration.getProvider() == null) { - throw new IllegalStateException("Lottery requires Vault with an enabled economy provider"); + throw new IllegalStateException("Lottery VAULT backend requires Vault with an enabled economy provider"); } return new LotteryEconomy(registration.getProvider()); } - public Money balance(OfflinePlayer player) { + @Override + public Optional cachedBalance(OfflinePlayer player) { requireMainThread(); - return Money.fromVault(economy.getBalance(player)); + return Optional.of(Money.fromVault(economy.getBalance(player))); } - public EconomyResult withdraw(OfflinePlayer player, Money amount) { + @Override + public CompletionStage withdraw(OfflinePlayer player, Money amount, String idempotencyKey) { requireMainThread(); if (!amount.isPositive()) { - return EconomyResult.failure("Amount must be positive"); + return CompletableFuture.completedFuture(EconomyResult.failure("Amount must be positive")); } if (!economy.has(player, amount.toVault())) { - return EconomyResult.failure("Insufficient funds"); + return CompletableFuture.completedFuture(EconomyResult.failure("Insufficient funds")); } try { - return result(economy.withdrawPlayer(player, amount.toVault())); + return CompletableFuture.completedFuture(result(economy.withdrawPlayer(player, amount.toVault()))); } catch (RuntimeException | LinkageError exception) { - return EconomyResult.uncertain(rootMessage(exception)); + return CompletableFuture.completedFuture(EconomyResult.uncertain(rootMessage(exception))); } } - public EconomyResult deposit(OfflinePlayer player, Money amount) { + @Override + public CompletionStage deposit(OfflinePlayer player, Money amount, String idempotencyKey) { requireMainThread(); if (!amount.isPositive()) { - return EconomyResult.failure("Amount must be positive"); + return CompletableFuture.completedFuture(EconomyResult.failure("Amount must be positive")); } try { - return result(economy.depositPlayer(player, amount.toVault())); + return CompletableFuture.completedFuture(result(economy.depositPlayer(player, amount.toVault()))); } catch (RuntimeException | LinkageError exception) { - return EconomyResult.uncertain(rootMessage(exception)); + return CompletableFuture.completedFuture(EconomyResult.uncertain(rootMessage(exception))); } } + @Override public String format(Money amount) { requireMainThread(); try { @@ -67,13 +73,18 @@ public String format(Money amount) { } } + @Override + public String backendName() { + return "Vault:" + economy.getName(); + } + private static EconomyResult result(EconomyResponse response) { if (response == null) { return EconomyResult.uncertain("Vault returned no EconomyResponse"); } String message = response.errorMessage == null ? "" : response.errorMessage; if (response.transactionSuccess()) { - return EconomyResult.success(message); + return EconomyResult.success(message, ""); } return EconomyResult.failure(message.isBlank() ? response.type.name() : message); } @@ -92,22 +103,4 @@ private static String rootMessage(Throwable throwable) { String message = current.getMessage(); return message == null || message.isBlank() ? current.getClass().getSimpleName() : message; } - - public record EconomyResult(boolean successful, boolean uncertain, String message) { - public EconomyResult { - message = message == null ? "" : message; - } - - public static EconomyResult success(String message) { - return new EconomyResult(true, false, message); - } - - public static EconomyResult failure(String message) { - return new EconomyResult(false, false, message); - } - - public static EconomyResult uncertain(String message) { - return new EconomyResult(false, true, message); - } - } } diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/economy/LotteryEconomyGateway.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/economy/LotteryEconomyGateway.java new file mode 100644 index 000000000..63744db2b --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/economy/LotteryEconomyGateway.java @@ -0,0 +1,39 @@ +package nl.hauntedmc.serverfeatures.features.lottery.economy; + +import nl.hauntedmc.serverfeatures.features.lottery.model.Money; +import org.bukkit.OfflinePlayer; + +import java.util.Optional; +import java.util.concurrent.CompletionStage; + +/** Selected monetary backend for one Lottery instance. */ +public interface LotteryEconomyGateway { + Optional cachedBalance(OfflinePlayer player); + + CompletionStage withdraw(OfflinePlayer player, Money amount, String idempotencyKey); + + CompletionStage deposit(OfflinePlayer player, Money amount, String idempotencyKey); + + String format(Money amount); + + String backendName(); + + record EconomyResult(boolean successful, boolean uncertain, String message, String operationId) { + public EconomyResult { + message = message == null ? "" : message; + operationId = operationId == null ? "" : operationId; + } + + public static EconomyResult success(String message, String operationId) { + return new EconomyResult(true, false, message, operationId); + } + + public static EconomyResult failure(String message) { + return new EconomyResult(false, false, message, ""); + } + + public static EconomyResult uncertain(String message) { + return new EconomyResult(false, true, message, ""); + } + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/meta/Meta.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/meta/Meta.java index 6ded33fb3..90ac097cb 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/meta/Meta.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/meta/Meta.java @@ -16,8 +16,13 @@ public String getFeatureVersion() { return "1.0.0"; } + @Override + public List getOptionalDependencies() { + return List.of("Economy"); + } + @Override public List getPluginDependencies() { - return List.of(DATA_PROVIDER, DATA_REGISTRY, "Vault"); + return List.of(DATA_PROVIDER, DATA_REGISTRY); } } diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/service/LotteryService.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/service/LotteryService.java index 00afe14f8..d9d09be07 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/service/LotteryService.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/lottery/service/LotteryService.java @@ -4,8 +4,8 @@ import nl.hauntedmc.serverfeatures.features.lottery.Lottery; import nl.hauntedmc.serverfeatures.features.lottery.config.LotterySettings; import nl.hauntedmc.serverfeatures.features.lottery.draw.LotteryDrawEngine; -import nl.hauntedmc.serverfeatures.features.lottery.economy.LotteryEconomy; -import nl.hauntedmc.serverfeatures.features.lottery.economy.LotteryEconomy.EconomyResult; +import nl.hauntedmc.serverfeatures.features.lottery.economy.LotteryEconomyGateway; +import nl.hauntedmc.serverfeatures.features.lottery.economy.LotteryEconomyGateway.EconomyResult; import nl.hauntedmc.serverfeatures.features.lottery.model.LotteryModels.PendingPayout; import nl.hauntedmc.serverfeatures.features.lottery.model.LotteryModels.PayoutStatus; import nl.hauntedmc.serverfeatures.features.lottery.model.LotteryModels.PlayerSummary; @@ -35,7 +35,7 @@ public final class LotteryService { private final Lottery feature; private final LotterySettings settings; private final LotteryRepository repository; - private final LotteryEconomy economy; + private final LotteryEconomyGateway economy; private final LotteryDrawEngine drawEngine; private final PlayerIdentityResolver identityResolver; private final AtomicReference snapshot = new AtomicReference<>(); @@ -50,7 +50,7 @@ public LotteryService( Lottery feature, LotterySettings settings, LotteryRepository repository, - LotteryEconomy economy, + LotteryEconomyGateway economy, LotteryDrawEngine drawEngine ) { this.feature = feature; @@ -107,7 +107,7 @@ public String format(Money amount) { } public Money balance(OfflinePlayer player) { - return economy.balance(player); + return economy.cachedBalance(player).orElse(Money.ZERO); } public int maximumAffordable(Player player) { @@ -115,10 +115,12 @@ public int maximumAffordable(Player player) { if (!isReady() || round == null || !round.acceptsEntries(now())) { return 0; } - int affordable = balance(player).amount() - .divide(round.ticketPrice().amount(), 0, RoundingMode.DOWN) - .min(BigDecimal.valueOf(settings.tickets().maximumPerCommand())) - .intValue(); + int affordable = economy.cachedBalance(player) + .map(balance -> balance.amount() + .divide(round.ticketPrice().amount(), 0, RoundingMode.DOWN) + .min(BigDecimal.valueOf(settings.tickets().maximumPerCommand())) + .intValue()) + .orElse(0); PlayerSummary summary = cachedSummary(player.getUniqueId()); if (settings.tickets().maximumPerPlayer() > 0) { affordable = Math.min( @@ -208,12 +210,12 @@ public void purchase(Player player, int ticketCount) { UUID playerUuid = player.getUniqueId(); String playerName = player.getName(); Money cost = round.ticketPrice().multiply(ticketCount); - Money currentBalance = balance(player); - if (currentBalance.compareTo(cost) < 0) { + Optional currentBalance = economy.cachedBalance(player); + if (currentBalance.isPresent() && currentBalance.get().compareTo(cost) < 0) { endPlayerOperation(playerUuid); feature.send(player, "lottery.buy.insufficient", Map.of( "cost", format(cost), - "balance", format(currentBalance) + "balance", format(currentBalance.get()) )); return; } @@ -248,11 +250,11 @@ public void donate(Player player, Money amount) { feature.send(player, round.paused() ? "lottery.paused" : "lottery.closed"); return; } - Money currentBalance = balance(player); - if (currentBalance.compareTo(amount) < 0) { + Optional currentBalance = economy.cachedBalance(player); + if (currentBalance.isPresent() && currentBalance.get().compareTo(amount) < 0) { feature.send(player, "lottery.donate.insufficient", Map.of( "amount", format(amount), - "balance", format(currentBalance) + "balance", format(currentBalance.get()) )); return; } @@ -411,39 +413,40 @@ private void withdrawAndStorePurchase( } return; } - EconomyResult withdrawal = economy.withdraw(player, cost); - if (!withdrawal.successful()) { - endPlayerOperation(playerUuid); - feature.send(player, withdrawal.uncertain() ? "lottery.transaction.uncertain" : "lottery.buy.withdraw_failed", Map.of( - "reason", withdrawal.message() - )); - return; - } - submit(() -> repository.purchase( - settings, - roundId, - playerUuid, - playerId, - playerName, - ticketCount, - cost, - now() - )) - .whenComplete((receipt, failure) -> main(() -> { - endPlayerOperation(playerUuid); - if (failure != null) { - refund(player, cost, "purchase", failure); - return; - } - rounds.refreshRound(); - refreshPlayerSummary(playerUuid); - feature.send(player, "lottery.buy.success", Map.of( - "tickets", Integer.toString(receipt.purchasedTickets()), - "cost", format(receipt.charged()), - "player_tickets", Integer.toString(receipt.playerTickets()), - "pot", format(receipt.pot()) - )); - })); + String economyKey = "purchase:" + roundId + ":" + playerUuid + ":" + UUID.randomUUID(); + economy.withdraw(player, cost, economyKey).whenComplete((withdrawal, economyFailure) -> main(() -> { + if (economyFailure != null) { + endPlayerOperation(playerUuid); + feature.send(player, "lottery.transaction.uncertain"); + log("Lottery purchase withdrawal failed", economyFailure); + return; + } + if (!withdrawal.successful()) { + endPlayerOperation(playerUuid); + feature.send(player, withdrawal.uncertain() + ? "lottery.transaction.uncertain" + : "lottery.buy.withdraw_failed", Map.of("reason", withdrawal.message())); + return; + } + submit(() -> repository.purchase( + settings, roundId, playerUuid, playerId, playerName, ticketCount, cost, now() + )) + .whenComplete((receipt, failure) -> main(() -> { + endPlayerOperation(playerUuid); + if (failure != null) { + refund(player, cost, "purchase", economyKey, failure); + return; + } + rounds.refreshRound(); + refreshPlayerSummary(playerUuid); + feature.send(player, "lottery.buy.success", Map.of( + "tickets", Integer.toString(receipt.purchasedTickets()), + "cost", format(receipt.charged()), + "player_tickets", Integer.toString(receipt.playerTickets()), + "pot", format(receipt.pot()) + )); + })); + })); } private void withdrawAndStoreDonation( @@ -467,46 +470,57 @@ private void withdrawAndStoreDonation( } return; } - EconomyResult withdrawal = economy.withdraw(player, amount); - if (!withdrawal.successful()) { - endPlayerOperation(playerUuid); - feature.send(player, withdrawal.uncertain() - ? "lottery.transaction.uncertain" - : "lottery.donate.withdraw_failed", Map.of("reason", withdrawal.message())); - return; - } - submit(() -> repository.donate( - settings, - roundId, - playerUuid, - playerId, - playerName, - amount, - now() - )) - .whenComplete((receipt, failure) -> main(() -> { - endPlayerOperation(playerUuid); - if (failure != null) { - refund(player, amount, "donation", failure); - return; - } - rounds.refreshRound(); - refreshPlayerSummary(playerUuid); - feature.send(player, "lottery.donate.success", Map.of( - "amount", format(receipt.amount()), - "pot", format(receipt.pot()) - )); - })); + String economyKey = "donation:" + roundId + ":" + playerUuid + ":" + UUID.randomUUID(); + economy.withdraw(player, amount, economyKey).whenComplete((withdrawal, economyFailure) -> main(() -> { + if (economyFailure != null) { + endPlayerOperation(playerUuid); + feature.send(player, "lottery.transaction.uncertain"); + log("Lottery donation withdrawal failed", economyFailure); + return; + } + if (!withdrawal.successful()) { + endPlayerOperation(playerUuid); + feature.send(player, withdrawal.uncertain() + ? "lottery.transaction.uncertain" + : "lottery.donate.withdraw_failed", Map.of("reason", withdrawal.message())); + return; + } + submit(() -> repository.donate( + settings, roundId, playerUuid, playerId, playerName, amount, now() + )) + .whenComplete((receipt, failure) -> main(() -> { + endPlayerOperation(playerUuid); + if (failure != null) { + refund(player, amount, "donation", economyKey, failure); + return; + } + rounds.refreshRound(); + refreshPlayerSummary(playerUuid); + feature.send(player, "lottery.donate.success", Map.of( + "amount", format(receipt.amount()), + "pot", format(receipt.pot()) + )); + })); + })); } - private void refund(Player player, Money amount, String transaction, Throwable failure) { - EconomyResult refund = economy.deposit(player, amount); - if (refund.successful()) { - feature.send(player, "lottery.transaction.refunded", Map.of("amount", format(amount))); - } else { - feature.send(player, "lottery.transaction.uncertain"); - } - log("Lottery " + transaction + " could not be stored; refund result: " + refund.message(), failure); + private void refund( + Player player, + Money amount, + String transaction, + String originalEconomyKey, + Throwable failure + ) { + economy.deposit(player, amount, "refund:" + originalEconomyKey).whenComplete((refund, refundFailure) -> main(() -> { + if (refundFailure == null && refund != null && refund.successful()) { + feature.send(player, "lottery.transaction.refunded", Map.of("amount", format(amount))); + } else { + feature.send(player, "lottery.transaction.uncertain"); + } + String result = refundFailure != null ? rootMessage(refundFailure) + : refund == null ? "no result" : refund.message(); + log("Lottery " + transaction + " could not be stored; refund result: " + result, failure); + })); } private void claimNext(UUID playerUuid, boolean automatic, Money paid) { @@ -545,41 +559,44 @@ private void claimNext(UUID playerUuid, boolean automatic, Money paid) { } private void deliverPayout(Player player, PendingPayout payout, boolean automatic, Money paid) { - EconomyResult result = economy.deposit(player, payout.amount()); - PayoutStatus status = result.successful() - ? PayoutStatus.PAID - : result.uncertain() ? PayoutStatus.FAILED : PayoutStatus.PENDING; - submit(() -> { - boolean updated = repository.finishPayout( - settings.lotteryKey(), - payout.payoutId(), - status, - result.successful() ? null : result.message(), - now() - ); - if (!updated) { - throw new IllegalStateException("Payout is no longer reserved for delivery"); - } - return null; - }).whenComplete((ignored, failure) -> main(() -> { - if (failure != null) { - endPlayerOperation(player.getUniqueId()); - feature.send(player, "lottery.transaction.uncertain"); - log("Could not save Lottery payout state " + payout.payoutId(), failure); - return; - } - if (!result.successful()) { - endPlayerOperation(player.getUniqueId()); - if (!automatic) { - feature.send(player, result.uncertain() - ? "lottery.transaction.uncertain" - : "lottery.claim.payout_failed", Map.of("reason", result.message())); - } - refreshPlayerSummary(player.getUniqueId()); - return; - } - claimNext(player.getUniqueId(), automatic, paid.add(payout.amount())); - })); + economy.deposit(player, payout.amount(), "payout:" + payout.payoutId()) + .whenComplete((result, economyFailure) -> main(() -> { + EconomyResult effective = economyFailure == null && result != null + ? result + : EconomyResult.uncertain(economyFailure == null + ? "No economy result" : rootMessage(economyFailure)); + PayoutStatus status = effective.successful() + ? PayoutStatus.PAID + : effective.uncertain() ? PayoutStatus.FAILED : PayoutStatus.PENDING; + submit(() -> { + boolean updated = repository.finishPayout( + settings.lotteryKey(), payout.payoutId(), status, + effective.successful() ? null : effective.message(), now() + ); + if (!updated) { + throw new IllegalStateException("Payout is no longer reserved for delivery"); + } + return null; + }).whenComplete((ignored, failure) -> main(() -> { + if (failure != null) { + endPlayerOperation(player.getUniqueId()); + feature.send(player, "lottery.transaction.uncertain"); + log("Could not save Lottery payout state " + payout.payoutId(), failure); + return; + } + if (!effective.successful()) { + endPlayerOperation(player.getUniqueId()); + if (!automatic) { + feature.send(player, effective.uncertain() + ? "lottery.transaction.uncertain" + : "lottery.claim.payout_failed", Map.of("reason", effective.message())); + } + refreshPlayerSummary(player.getUniqueId()); + return; + } + claimNext(player.getUniqueId(), automatic, paid.add(payout.amount())); + })); + })); } private void releasePayout(PendingPayout payout, UUID playerUuid) { diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/loader/FeatureDescriptor.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/loader/FeatureDescriptor.java index 50711497c..0e18f4e86 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/loader/FeatureDescriptor.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/loader/FeatureDescriptor.java @@ -14,6 +14,7 @@ public record FeatureDescriptor( String featureName, String featureVersion, Set featureDependencies, + Set optionalFeatureDependencies, Set pluginDependencies ) { public FeatureDescriptor( @@ -24,40 +25,53 @@ public FeatureDescriptor( Set featureDependencies, Set pluginDependencies ) { - this(registryName, featureClassName, (Class) null, featureName, - featureVersion, featureDependencies, pluginDependencies); + this(registryName, featureClassName, null, featureName, featureVersion, + featureDependencies, Set.of(), pluginDependencies); } public FeatureDescriptor( String registryName, String featureClassName, - BaseMeta meta, String featureName, String featureVersion, Set featureDependencies, + Set optionalFeatureDependencies, Set pluginDependencies ) { - this(registryName, featureClassName, - meta == null ? null : meta.getClass().asSubclass(BaseMeta.class), - featureName, featureVersion, featureDependencies, pluginDependencies); + this(registryName, featureClassName, null, featureName, featureVersion, + featureDependencies, optionalFeatureDependencies, pluginDependencies); } public FeatureDescriptor( String registryName, String featureClassName, - Class metaClass, + BaseMeta meta, String featureName, String featureVersion, Set featureDependencies, Set pluginDependencies ) { - this.registryName = registryName; - this.featureClassName = featureClassName; - this.metaClass = metaClass; - this.featureName = featureName == null ? "" : featureName; - this.featureVersion = featureVersion == null ? "" : featureVersion; - this.featureDependencies = normalizeDependencies(featureDependencies, registryName); - this.pluginDependencies = normalizeDependencies(pluginDependencies, null); + this(registryName, featureClassName, + meta == null ? null : meta.getClass().asSubclass(BaseMeta.class), + featureName, featureVersion, featureDependencies, + meta == null ? Set.of() : new LinkedHashSet<>(meta.getOptionalDependencies()), + pluginDependencies); + } + + public FeatureDescriptor { + featureName = featureName == null ? "" : featureName; + featureVersion = featureVersion == null ? "" : featureVersion; + featureDependencies = normalizeDependencies(featureDependencies, registryName); + optionalFeatureDependencies = normalizeDependencies(optionalFeatureDependencies, registryName); + if (!featureDependencies.isEmpty() && !optionalFeatureDependencies.isEmpty()) { + Set requiredDependencies = featureDependencies; + LinkedHashSet optional = new LinkedHashSet<>(optionalFeatureDependencies); + optional.removeIf(candidate -> requiredDependencies.stream().anyMatch(candidate::equalsIgnoreCase)); + optionalFeatureDependencies = optional.isEmpty() + ? Set.of() + : Collections.unmodifiableSet(optional); + } + pluginDependencies = normalizeDependencies(pluginDependencies, null); } public BaseMeta createMeta() { @@ -72,6 +86,7 @@ public BaseMeta createMeta() { featureName, featureVersion, List.copyOf(featureDependencies), + List.copyOf(optionalFeatureDependencies), List.copyOf(pluginDependencies) ); } @@ -98,6 +113,7 @@ private record StaticMeta( String featureName, String featureVersion, List dependencies, + List optionalDependencies, List pluginDependencies ) implements BaseMeta { @Override @@ -115,6 +131,11 @@ public List getDependencies() { return dependencies; } + @Override + public List getOptionalDependencies() { + return optionalDependencies; + } + @Override public List getPluginDependencies() { return pluginDependencies; diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/loader/FeatureLoadManager.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/loader/FeatureLoadManager.java index 900be5136..d0fd2d743 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/loader/FeatureLoadManager.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/loader/FeatureLoadManager.java @@ -136,6 +136,14 @@ private Optional buildDescriptor(String registryName, String if (featureDependencies == null) { return Optional.empty(); } + Set optionalDependencies = normalizeFeatureDependencies( + featureClassName, + featureKey, + meta.getOptionalDependencies() + ); + if (optionalDependencies == null) { + return Optional.empty(); + } Set pluginDependencies = meta.getPluginDependencies() == null ? Set.of() : new LinkedHashSet<>(meta.getPluginDependencies()); @@ -143,10 +151,11 @@ private Optional buildDescriptor(String registryName, String return Optional.of(new FeatureDescriptor( featureKey, featureClassName, - meta, + meta.getClass().asSubclass(BaseMeta.class), featureName, featureVersion, featureDependencies, + optionalDependencies, pluginDependencies )); } diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/loader/FeatureLoadOrderResolver.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/loader/FeatureLoadOrderResolver.java index db7968be9..b74f99684 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/loader/FeatureLoadOrderResolver.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/loader/FeatureLoadOrderResolver.java @@ -115,6 +115,22 @@ private static boolean resolveFeatureLoadOrder( } } + for (String dependency : descriptor.optionalFeatureDependencies()) { + String dependencyKey = featureKeyResolver.apply(dependency); + if (dependencyKey == null || states.get(dependencyKey) == LoadOrderState.VISITING) { + continue; + } + resolveFeatureLoadOrder( + dependencyKey, + descriptorProvider, + featureKeyResolver, + logger, + states, + path, + loadOrder + ); + } + states.put(featureName, LoadOrderState.VISITED); loadOrder.add(featureName); return true; diff --git a/serverfeatures-platform-paper/src/main/resources/plugin.yml b/serverfeatures-platform-paper/src/main/resources/plugin.yml index 160be3aec..aac8f7a89 100644 --- a/serverfeatures-platform-paper/src/main/resources/plugin.yml +++ b/serverfeatures-platform-paper/src/main/resources/plugin.yml @@ -5,7 +5,7 @@ description: HauntedMC Feature Framework (Platform=Paper) author: HauntedMC website: https://hauntedmc.nl api-version: 1.21 -softdepend: [ DataRegistry, DataProvider, packetevents, PlaceholderAPI, Essentials ] +softdepend: [ DataRegistry, DataProvider, packetevents, PlaceholderAPI, Essentials, Vault ] commands: serverfeatures: @@ -45,3 +45,48 @@ permissions: serverfeatures.feature.invtools.command.enderchest.clear: description: Clear online and offline player ender chests with /inv enderchest clear. default: false + serverfeatures.feature.economy.balance: + description: View your own configured economy balances. + default: true + serverfeatures.feature.economy.balance.others: + description: View another player's economy balance. + default: op + serverfeatures.feature.economy.pay: + description: Transfer currencies that allow player payments. + default: true + serverfeatures.feature.economy.paytoggle: + description: Toggle incoming player payments. + default: true + serverfeatures.feature.economy.history: + description: View your economy transaction history. + default: true + serverfeatures.feature.economy.top: + description: View enabled economy leaderboards. + default: true + serverfeatures.feature.economy.admin.status: + description: View Economy operational status. + default: op + serverfeatures.feature.economy.admin.balance: + description: Inspect player economy balances. + default: op + serverfeatures.feature.economy.admin.add: + description: Add currency to a player account. + default: op + serverfeatures.feature.economy.admin.remove: + description: Remove currency from a player account. + default: op + serverfeatures.feature.economy.admin.set: + description: Set a player economy balance. + default: op + serverfeatures.feature.economy.admin.payments: + description: Change a player's payment setting. + default: op + serverfeatures.feature.economy.admin.freeze: + description: Freeze or unfreeze economy accounts. + default: op + serverfeatures.feature.economy.admin.history: + description: Inspect player transaction history. + default: op + serverfeatures.feature.economy.admin.verify: + description: Run read-only Economy integrity checks. + default: op diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/EconomyApiValidationTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/EconomyApiValidationTest.java new file mode 100644 index 000000000..45e69cb58 --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/EconomyApiValidationTest.java @@ -0,0 +1,143 @@ +package nl.hauntedmc.serverfeatures.features.economy; + +import nl.hauntedmc.serverfeatures.api.economy.EconomyAccountRef; +import nl.hauntedmc.serverfeatures.api.economy.EconomyMutationRequest; +import nl.hauntedmc.serverfeatures.api.economy.EconomyScope; +import nl.hauntedmc.serverfeatures.api.economy.EconomyScopeType; +import nl.hauntedmc.serverfeatures.api.economy.EconomyTransferRequest; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class EconomyApiValidationTest { + + private static final EconomyAccountRef ACCOUNT = new EconomyAccountRef( + 1L, + UUID.fromString("00000000-0000-0000-0000-000000000001"), + "Player", + "money", + "hauntedmc/global" + ); + + @Test + void acceptsPersistentIdentifiersAtTheirExactLimits() { + assertDoesNotThrow(() -> new EconomyScope(EconomyScopeType.GLOBAL, "x".repeat(128))); + assertDoesNotThrow(() -> new EconomyMutationRequest( + "x".repeat(64), + "x".repeat(160), + ACCOUNT, + BigDecimal.ONE, + null, + "system", + "test", + Map.of() + )); + } + + @Test + void rejectsOversizedPersistentIdentifiers() { + assertThrows(IllegalArgumentException.class, () -> new EconomyScope( + EconomyScopeType.GLOBAL, + "x".repeat(129) + )); + assertThrows(IllegalArgumentException.class, () -> new EconomyAccountRef( + 1L, + ACCOUNT.playerUuid(), + "Player", + "x".repeat(65), + "hauntedmc/global" + )); + assertThrows(IllegalArgumentException.class, () -> new EconomyAccountRef( + 1L, + ACCOUNT.playerUuid(), + "Player", + "money", + "x".repeat(129) + )); + } + + @Test + void rejectsIdentifiersThatWouldBeTruncatedInTheJournal() { + assertThrows(IllegalArgumentException.class, () -> new EconomyMutationRequest( + "x".repeat(65), + "operation", + ACCOUNT, + BigDecimal.ONE, + null, + "system", + "test", + Map.of() + )); + assertThrows(IllegalArgumentException.class, () -> new EconomyTransferRequest( + "transfer", + "x".repeat(161), + ACCOUNT, + new EconomyAccountRef( + 2L, + UUID.fromString("00000000-0000-0000-0000-000000000002"), + "Other", + "money", + "hauntedmc/global" + ), + BigDecimal.ONE, + null, + "system", + "test", + Map.of(), + false + )); + } + @Test + void rejectsInvalidIdentityAndUnboundedMetadata() { + assertThrows(IllegalArgumentException.class, () -> new EconomyAccountRef( + 0L, + ACCOUNT.playerUuid(), + "Player", + "money", + "hauntedmc/global" + )); + assertThrows(IllegalArgumentException.class, () -> new EconomyAccountRef( + 1L, + ACCOUNT.playerUuid(), + "x".repeat(33), + "money", + "hauntedmc/global" + )); + assertThrows(IllegalArgumentException.class, () -> new EconomyMutationRequest( + "Lottery With Spaces", + "operation", + ACCOUNT, + BigDecimal.ONE, + null, + "system", + "test", + Map.of() + )); + assertThrows(IllegalArgumentException.class, () -> new EconomyMutationRequest( + "lottery", + "operation", + ACCOUNT, + BigDecimal.ONE, + -1L, + "system", + "test", + Map.of() + )); + assertThrows(IllegalArgumentException.class, () -> new EconomyMutationRequest( + "lottery", + "operation", + ACCOUNT, + BigDecimal.ONE, + null, + "system", + "test", + Map.of("key", "x".repeat(513)) + )); + } + +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/VaultOptionalClassloadingTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/VaultOptionalClassloadingTest.java new file mode 100644 index 000000000..2529f0ba9 --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/VaultOptionalClassloadingTest.java @@ -0,0 +1,32 @@ +package nl.hauntedmc.serverfeatures.features.economy; + +import nl.hauntedmc.serverfeatures.features.lottery.Lottery; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class VaultOptionalClassloadingTest { + + @Test + void nativeFeatureEntryPointsDoNotLinkVaultApiClasses() throws IOException { + assertNoVaultApiReference(Economy.class); + assertNoVaultApiReference(Lottery.class); + } + + private static void assertNoVaultApiReference(Class type) throws IOException { + String resourceName = type.getSimpleName() + ".class"; + try (InputStream stream = type.getResourceAsStream(resourceName)) { + assertNotNull(stream, () -> "Missing class resource for " + type.getName()); + String constantPool = new String(stream.readAllBytes(), StandardCharsets.ISO_8859_1); + assertFalse( + constantPool.contains("net/milkbowl/vault"), + () -> type.getName() + " directly links Vault API classes" + ); + } + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/config/EconomySettingsTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/config/EconomySettingsTest.java new file mode 100644 index 000000000..699cdbff6 --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/config/EconomySettingsTest.java @@ -0,0 +1,291 @@ +package nl.hauntedmc.serverfeatures.features.economy.config; + +import nl.hauntedmc.serverfeatures.api.economy.EconomyScope; +import nl.hauntedmc.serverfeatures.api.economy.EconomyScopeType; +import nl.hauntedmc.serverfeatures.api.io.config.ConfigNode; +import nl.hauntedmc.serverfeatures.framework.config.FeatureConfigHandler; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class EconomySettingsTest { + + @Test + void acceptsServerGroupAndGlobalScopes() { + EconomySettings.Currency server = currency("money", new EconomyScope( + EconomyScopeType.SERVER, "hauntedmc/server/survival" + ), "money"); + EconomySettings.Currency group = currency("shards", new EconomyScope( + EconomyScopeType.GROUP, "hauntedmc/group/survival-network" + ), "shards"); + EconomySettings.Currency global = currency("points", new EconomyScope( + EconomyScopeType.GLOBAL, "hauntedmc/global" + ), "points"); + + EconomySettings settings = settings( + "survival", + Map.of("money", server, "shards", group, "points", global), + true + ); + + assertEquals(EconomyScopeType.SERVER, settings.requireCurrency("money").scope().type()); + assertEquals(EconomyScopeType.GROUP, settings.requireCurrency("shards").scope().type()); + assertEquals(EconomyScopeType.GLOBAL, settings.requireCurrency("points").scope().type()); + } + + @Test + void modelsHauntedNetworkGlobalAndGamemodeLocalCurrencies() { + EconomySettings survival = networkSettings("survival"); + EconomySettings skyblock = networkSettings("skyblock"); + + for (String global : List.of("crowns", "credits")) { + assertEquals( + survival.requireCurrency(global).scope(), + skyblock.requireCurrency(global).scope(), + global + " must use one network-wide account" + ); + } + for (String local : List.of("essence", "relics", "soulstones", "money")) { + assertEquals(EconomyScopeType.SERVER, survival.requireCurrency(local).scope().type()); + assertNotEquals( + survival.requireCurrency(local).scope().key(), + skyblock.requireCurrency(local).scope().key(), + local + " must have a separate balance per gamemode" + ); + } + assertEquals("money", survival.vault().primaryCurrency()); + assertEquals("money", skyblock.vault().primaryCurrency()); + } + + @Test + void parsesHauntedNetworkTopologyFromConfiguration() { + EconomySettings survival = load("survival", false); + EconomySettings skyblock = load("skyblock", false); + + for (String global : List.of("crowns", "credits")) { + assertEquals("hauntedmc/global", survival.requireCurrency(global).scope().key()); + assertEquals( + survival.requireCurrency(global).scope(), + skyblock.requireCurrency(global).scope() + ); + } + for (String local : List.of("essence", "relics", "soulstones", "money")) { + assertEquals( + "hauntedmc/server/survival", + survival.requireCurrency(local).scope().key() + ); + assertEquals( + "hauntedmc/server/skyblock", + skyblock.requireCurrency(local).scope().key() + ); + } + assertEquals("money", survival.vault().primaryCurrency()); + } + + @Test + void supportsPerCurrencyLogicalLocalScopeOverridesForReplicas() { + EconomySettings replicaOne = load("survival-1", true); + EconomySettings replicaTwo = load("survival-2", true); + + assertEquals( + replicaOne.requireCurrency("money").scope(), + replicaTwo.requireCurrency("money").scope() + ); + assertEquals("hauntedmc/server/survival", replicaOne.requireCurrency("money").scope().key()); + } + + @Test + void rejectsDisablingPaymentsToKnownOfflineNetworkPlayers() { + Map root = configuration("survival", false); + @SuppressWarnings("unchecked") + Map currencies = (Map) root.get("currencies"); + @SuppressWarnings("unchecked") + Map crowns = (Map) currencies.get("crowns"); + crowns.put("payments", Map.of("allow_offline_recipient", false)); + + assertThrows(IllegalArgumentException.class, () -> load(root)); + } + + + + @Test + void rejectsDuplicateNormalizedCurrencyIds() { + Map root = configuration("survival", false); + @SuppressWarnings("unchecked") + Map currencies = (Map) root.get("currencies"); + currencies.put("CROWNS", new LinkedHashMap<>(Map.of( + "scope", Map.of("type", "GLOBAL") + ))); + + assertThrows(IllegalArgumentException.class, () -> load(root)); + } + + @Test + void rejectsAmountsOutsideDecimalStorageShape() { + Map root = configuration("survival", false); + @SuppressWarnings("unchecked") + Map currencies = (Map) root.get("currencies"); + @SuppressWarnings("unchecked") + Map crowns = (Map) currencies.get("crowns"); + crowns.put("balances", Map.of("maximum", "1000000000000000000000000000000")); + + assertThrows(IllegalArgumentException.class, () -> load(root)); + } + + @Test + void rejectsDuplicateCommandLabelsAcrossCurrencies() { + EconomySettings.Currency money = currency("money", new EconomyScope( + EconomyScopeType.SERVER, "hauntedmc/server/survival" + ), "currency"); + EconomySettings.Currency points = currency("points", new EconomyScope( + EconomyScopeType.GLOBAL, "hauntedmc/global" + ), "currency"); + + assertThrows(IllegalArgumentException.class, () -> settings( + "survival", + Map.of("money", money, "points", points), + false + )); + } + + @Test + void rejectsAdminCommandAsCurrencyRootOrAlias() { + EconomySettings.Currency money = currency( + "money", + new EconomyScope(EconomyScopeType.SERVER, "hauntedmc/server/survival"), + "economy" + ); + + assertThrows(IllegalArgumentException.class, () -> settings( + "survival", + Map.of("money", money), + true + )); + } + + @Test + void requiresEnabledVaultPrimaryCurrency() { + EconomySettings.Currency points = currency("points", new EconomyScope( + EconomyScopeType.GLOBAL, "hauntedmc/global" + ), "points"); + + assertThrows(IllegalArgumentException.class, () -> settings( + "survival", + Map.of("points", points), + true + )); + } + + private static EconomySettings load(String gamemode, boolean sharedReplicaScope) { + return load(configuration(gamemode, sharedReplicaScope)); + } + + private static EconomySettings load(Map values) { + FeatureConfigHandler config = mock(FeatureConfigHandler.class); + ConfigNode root = ConfigNode.ofRaw(values, "economy"); + when(config.node()).thenReturn(root); + when(config.node("currencies")).thenReturn(root.getAt("currencies")); + return EconomySettings.load(config, "physical-server"); + } + + private static Map configuration(String gamemode, boolean sharedReplicaScope) { + Map currencies = new LinkedHashMap<>(); + for (String global : List.of("crowns", "credits")) { + currencies.put(global, new LinkedHashMap<>(Map.of( + "scope", Map.of("type", "GLOBAL") + ))); + } + for (String local : List.of("essence", "relics", "soulstones", "money")) { + Map scope = new LinkedHashMap<>(); + scope.put("type", "SERVER"); + if (sharedReplicaScope) { + scope.put("local_key", "survival"); + } + currencies.put(local, new LinkedHashMap<>(Map.of("scope", scope))); + } + Map root = new LinkedHashMap<>(); + root.put("network_key", "hauntedmc"); + root.put("server_key", gamemode); + root.put("vault", Map.of( + "enabled", true, + "primary_currency", "money", + "conflict_policy", "FAIL" + )); + root.put("currencies", currencies); + return root; + } + + private static EconomySettings networkSettings(String gamemode) { + Map currencies = new LinkedHashMap<>(); + currencies.put("crowns", currency( + "crowns", new EconomyScope(EconomyScopeType.GLOBAL, "hauntedmc/global"), "crowns" + )); + currencies.put("credits", currency( + "credits", new EconomyScope(EconomyScopeType.GLOBAL, "hauntedmc/global"), "credits" + )); + for (String local : List.of("essence", "relics", "soulstones", "money")) { + currencies.put(local, currency( + local, + new EconomyScope(EconomyScopeType.SERVER, "hauntedmc/server/" + gamemode), + local + )); + } + return settings(gamemode, currencies, true); + } + + private static EconomySettings settings( + String gamemode, + Map currencies, + boolean vaultEnabled + ) { + return new EconomySettings( + "hauntedmc", + gamemode, + "system_data_rw", + new EconomySettings.Vault( + vaultEnabled, + "money", + EconomySettings.VaultConflictPolicy.FAIL + ), + new EconomySettings.Messaging(true, "hauntedmc", "serverfeatures.economy.balance"), + new EconomySettings.Cache(Duration.ofSeconds(10)), + currencies + ); + } + + private static EconomySettings.Currency currency(String id, EconomyScope scope, String command) { + return new EconomySettings.Currency( + id, + scope, + new EconomySettings.Display(id, id, "", "{amount}", 2, true), + new EconomySettings.Balances( + BigDecimal.ZERO.setScale(2), + BigDecimal.ZERO.setScale(2), + new BigDecimal("1000000.00"), + false, + RoundingMode.HALF_UP + ), + new EconomySettings.Commands(command, List.of(), true, true, true, true, true, false), + new EconomySettings.Payments( + true, + new BigDecimal("0.01"), + BigDecimal.ZERO.setScale(2), + BigDecimal.ZERO.setScale(2), + BigDecimal.ZERO.setScale(2), + BigDecimal.ZERO.setScale(2), + Duration.ofSeconds(1) + ) + ); + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/messaging/EconomyMessagingContractTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/messaging/EconomyMessagingContractTest.java new file mode 100644 index 000000000..1fbc71c46 --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/messaging/EconomyMessagingContractTest.java @@ -0,0 +1,76 @@ +package nl.hauntedmc.serverfeatures.features.economy.messaging; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class EconomyMessagingContractTest { + + @Test + void redisMessagesNeverCarryAnAuthoritativeBalance() { + assertFalse(Arrays.stream(EconomyBalanceMessage.class.getDeclaredFields()) + .anyMatch(field -> field.getName().equals("balance") || field.getType() == BigDecimal.class)); + assertFalse(Arrays.stream(EconomyTransferMessage.class.getDeclaredFields()) + .anyMatch(field -> field.getType() == BigDecimal.class)); + } + + @Test + void invalidationMessageCarriesOnlyAuthoritativeReloadCoordinates() { + String operationId = UUID.randomUUID().toString(); + EconomyBalanceMessage message = new EconomyBalanceMessage( + "survival", + operationId, + 42L, + "00000000-0000-0000-0000-000000000042", + "crowns", + "hauntedmc/global", + 7L, + 3L, + 123456789L + ); + + assertAll( + () -> assertEquals(EconomyBalanceMessage.SCHEMA_VERSION, message.getSchemaVersion()), + () -> assertEquals("survival", message.getPublisherServer()), + () -> assertEquals(operationId, message.getOperationId()), + () -> assertEquals(42L, message.getPlayerId()), + () -> assertEquals("00000000-0000-0000-0000-000000000042", message.getPlayerUuid()), + () -> assertEquals("crowns", message.getCurrencyId()), + () -> assertEquals("hauntedmc/global", message.getScopeKey()), + () -> assertEquals(7L, message.getBalanceVersion()), + () -> assertEquals(3L, message.getSettingsVersion()), + () -> assertEquals(123456789L, message.getPublishedAt()) + ); + } + + @Test + void transferMessageCarriesOnlyJournalVerificationCoordinates() { + String operationId = UUID.randomUUID().toString(); + EconomyTransferMessage message = new EconomyTransferMessage( + "skyblock", + operationId, + 84L, + "00000000-0000-0000-0000-000000000084", + "credits", + "hauntedmc/global", + 987654321L + ); + + assertAll( + () -> assertEquals(EconomyTransferMessage.SCHEMA_VERSION, message.getSchemaVersion()), + () -> assertEquals("skyblock", message.getPublisherServer()), + () -> assertEquals(operationId, message.getOperationId()), + () -> assertEquals(84L, message.getRecipientPlayerId()), + () -> assertEquals("00000000-0000-0000-0000-000000000084", message.getRecipientPlayerUuid()), + () -> assertEquals("credits", message.getCurrencyId()), + () -> assertEquals("hauntedmc/global", message.getScopeKey()), + () -> assertEquals(987654321L, message.getPublishedAt()) + ); + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/model/EconomyModelsTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/model/EconomyModelsTest.java new file mode 100644 index 000000000..9b4990048 --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/model/EconomyModelsTest.java @@ -0,0 +1,110 @@ +package nl.hauntedmc.serverfeatures.features.economy.model; + +import nl.hauntedmc.serverfeatures.api.economy.EconomyResultStatus; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.HistoryItem; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.HistoryPage; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.Identity; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.MutationOutcome; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.VerificationReport; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EconomyModelsTest { + + private static final UUID PLAYER_UUID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + + @Test + void identityRequiresImmutableCanonicalIdentifiers() { + Identity trimmed = new Identity(1L, PLAYER_UUID, " Player "); + Identity fallback = new Identity(1L, PLAYER_UUID, " "); + + assertAll( + () -> assertEquals("Player", trimmed.playerName()), + () -> assertEquals(PLAYER_UUID.toString(), fallback.playerName()), + () -> assertThrows(IllegalArgumentException.class, () -> new Identity(0L, PLAYER_UUID, "Player")), + () -> assertThrows(IllegalArgumentException.class, () -> new Identity(1L, null, "Player")) + ); + } + + @Test + void mutationOutcomeOnlyTreatsCommittedOrReplayResultsAsSuccessful() { + assertTrue(outcome(EconomyResultStatus.SUCCESS).successful()); + assertTrue(outcome(EconomyResultStatus.IDEMPOTENT_REPLAY).successful()); + assertFalse(outcome(EconomyResultStatus.IDEMPOTENCY_CONFLICT).successful()); + assertFalse(outcome(EconomyResultStatus.TEMPORARY_FAILURE).successful()); + } + + @Test + void historyPageDefensivelyCopiesEntries() { + List mutable = new ArrayList<>(); + mutable.add(historyItem()); + HistoryPage page = new HistoryPage(mutable, 1, false); + mutable.clear(); + + assertEquals(1, page.entries().size()); + assertThrows(UnsupportedOperationException.class, () -> page.entries().clear()); + } + + @Test + void verificationHealthFailsForEveryIntegrityViolation() { + assertTrue(report(0, 0, 0, 0, 0, 0, 0).healthy()); + assertAll( + () -> assertFalse(report(1, 0, 0, 0, 0, 0, 0).healthy()), + () -> assertFalse(report(0, 1, 0, 0, 0, 0, 0).healthy()), + () -> assertFalse(report(0, 0, 1, 0, 0, 0, 0).healthy()), + () -> assertFalse(report(0, 0, 0, 1, 0, 0, 0).healthy()), + () -> assertFalse(report(0, 0, 0, 0, 1, 0, 0).healthy()), + () -> assertFalse(report(0, 0, 0, 0, 0, 1, 0).healthy()), + () -> assertFalse(report(0, 0, 0, 0, 0, 0, 1).healthy()) + ); + } + + private static MutationOutcome outcome(EconomyResultStatus status) { + return new MutationOutcome(status, null, null, null, "", null, null); + } + + private static HistoryItem historyItem() { + return new HistoryItem( + 1L, + UUID.fromString("00000000-0000-0000-0000-000000000002"), + "TRANSFER", + new BigDecimal("5.00"), + new BigDecimal("15.00"), + "Player", + "Payment", + 123L + ); + } + + private static VerificationReport report( + long invalidBalances, + long invalidEntries, + long orphanSettings, + long orphanEntries, + long identityMismatches, + long accountsWithoutEntries, + long transactionsWithoutEntries + ) { + return new VerificationReport( + 10L, + 20L, + invalidBalances, + invalidEntries, + orphanSettings, + orphanEntries, + identityMismatches, + accountsWithoutEntries, + transactionsWithoutEntries + ); + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/persistence/EconomyEntitySchemaTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/persistence/EconomyEntitySchemaTest.java new file mode 100644 index 000000000..8e1902849 --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/persistence/EconomyEntitySchemaTest.java @@ -0,0 +1,76 @@ +package nl.hauntedmc.serverfeatures.features.economy.persistence; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyBalanceEntity; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyCurrencyDefinitionEntity; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyCurrencyFamilyEntity; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyDailyUsageEntity; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyPlayerIdentityEntity; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyPlayerSettingsEntity; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyTransactionEntity; +import nl.hauntedmc.serverfeatures.features.economy.entity.EconomyTransactionEntryEntity; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EconomyEntitySchemaTest { + + @Test + void registersNormalOrmEntitiesAndAccountUniqueness() throws NoSuchFieldException { + List> entities = List.of( + EconomyCurrencyFamilyEntity.class, + EconomyCurrencyDefinitionEntity.class, + EconomyPlayerIdentityEntity.class, + EconomyBalanceEntity.class, + EconomyPlayerSettingsEntity.class, + EconomyTransactionEntity.class, + EconomyTransactionEntryEntity.class, + EconomyDailyUsageEntity.class + ); + for (Class entity : entities) { + assertNotNull(entity.getAnnotation(Entity.class)); + assertNotNull(entity.getAnnotation(Table.class)); + } + Table family = EconomyCurrencyFamilyEntity.class.getAnnotation(Table.class); + assertTrue(List.of(family.uniqueConstraints()).stream().anyMatch(constraint -> + List.of(constraint.columnNames()).containsAll(List.of("network_key", "currency_id")) + )); + Table identity = EconomyPlayerIdentityEntity.class.getAnnotation(Table.class); + assertTrue(List.of(identity.uniqueConstraints()).stream().anyMatch(constraint -> + List.of(constraint.columnNames()).contains("player_uuid") + )); + Table balance = EconomyBalanceEntity.class.getAnnotation(Table.class); + assertTrue(List.of(balance.uniqueConstraints()).stream().anyMatch(constraint -> + List.of(constraint.columnNames()).containsAll(List.of("player_id", "currency_id", "scope_key")) + )); + assertTrue(List.of(balance.uniqueConstraints()).stream().anyMatch(constraint -> + List.of(constraint.columnNames()).containsAll(List.of("player_uuid", "currency_id", "scope_key")) + )); + Table definition = EconomyCurrencyDefinitionEntity.class.getAnnotation(Table.class); + assertTrue(List.of(definition.uniqueConstraints()).stream().anyMatch(constraint -> + List.of(constraint.columnNames()).containsAll(List.of("currency_id", "scope_key")) + )); + Table transaction = EconomyTransactionEntity.class.getAnnotation(Table.class); + assertTrue(List.of(transaction.uniqueConstraints()).stream().anyMatch(constraint -> + List.of(constraint.columnNames()).containsAll(List.of("source", "idempotency_key_hash")) + )); + Table entry = EconomyTransactionEntryEntity.class.getAnnotation(Table.class); + assertTrue(List.of(entry.uniqueConstraints()).stream().anyMatch(constraint -> + List.of(constraint.columnNames()).containsAll(List.of("transaction_id", "entry_role")) + )); + assertTrue(List.of(entry.uniqueConstraints()).stream().anyMatch(constraint -> + List.of(constraint.columnNames()).containsAll(List.of("transaction_id", "account_id")) + )); + assertNotNull(EconomyPlayerIdentityEntity.class.getDeclaredField("version").getAnnotation(Version.class)); + assertNotNull(EconomyBalanceEntity.class.getDeclaredField("version").getAnnotation(Version.class)); + assertNotNull(EconomyPlayerSettingsEntity.class.getDeclaredField("version").getAnnotation(Version.class)); + assertNotNull(EconomyPlayerSettingsEntity.class.getDeclaredField("lastPaymentAt")); + assertNotNull(EconomyTransactionEntity.class.getDeclaredField("idempotencyKeyHash")); + assertNotNull(EconomyTransactionEntity.class.getDeclaredField("requestFingerprint")); + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/persistence/EconomyRequestFingerprintTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/persistence/EconomyRequestFingerprintTest.java new file mode 100644 index 000000000..d631a3f8f --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/persistence/EconomyRequestFingerprintTest.java @@ -0,0 +1,213 @@ +package nl.hauntedmc.serverfeatures.features.economy.persistence; + +import nl.hauntedmc.serverfeatures.api.economy.EconomyScope; +import nl.hauntedmc.serverfeatures.api.economy.EconomyScopeType; +import nl.hauntedmc.serverfeatures.features.economy.config.EconomySettings; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.Identity; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.TransactionType; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +class EconomyRequestFingerprintTest { + private static final EconomySettings.Currency CURRENCY = currency(); + private static final Identity SENDER = new Identity( + 1L, + UUID.fromString("00000000-0000-0000-0000-000000000001"), + "Sender" + ); + private static final Identity RECIPIENT = new Identity( + 2L, + UUID.fromString("00000000-0000-0000-0000-000000000002"), + "Recipient" + ); + + @Test + void canonicalizesMetadataOrderButBindsAuditFields() { + Map leftMetadata = new LinkedHashMap<>(); + leftMetadata.put("round", "12"); + leftMetadata.put("type", "purchase"); + Map rightMetadata = new LinkedHashMap<>(); + rightMetadata.put("type", "purchase"); + rightMetadata.put("round", "12"); + + String left = EconomyRepository.mutationFingerprint( + TransactionType.WITHDRAW, + TransactionType.LOTTERY_PURCHASE, + SENDER, + CURRENCY, + new BigDecimal("10.00"), + 1L, + "Sender", + "Lottery purchase", + leftMetadata, + false + ); + String right = EconomyRepository.mutationFingerprint( + TransactionType.WITHDRAW, + TransactionType.LOTTERY_PURCHASE, + SENDER, + CURRENCY, + new BigDecimal("10.00"), + 1L, + "Sender", + "Lottery purchase", + rightMetadata, + false + ); + + assertEquals(left, right); + assertNotEquals(left, EconomyRepository.mutationFingerprint( + TransactionType.WITHDRAW, + TransactionType.LOTTERY_PURCHASE, + SENDER, + CURRENCY, + new BigDecimal("11.00"), + 1L, + "Sender", + "Lottery purchase", + rightMetadata, + false + )); + assertNotEquals(left, EconomyRepository.mutationFingerprint( + TransactionType.WITHDRAW, + TransactionType.LOTTERY_PURCHASE, + SENDER, + CURRENCY, + new BigDecimal("10.00"), + 1L, + "Sender", + "Changed reason", + rightMetadata, + false + )); + } + + @Test + void transferFingerprintBindsRecipientAndBypassPolicy() { + String normal = EconomyRepository.transferFingerprint( + SENDER, + RECIPIENT, + CURRENCY, + new BigDecimal("25.00"), + 1L, + "Sender", + "Player payment", + Map.of(), + false, + false + ); + Identity otherRecipient = new Identity( + 3L, + UUID.fromString("00000000-0000-0000-0000-000000000003"), + "Other" + ); + + assertNotEquals(normal, EconomyRepository.transferFingerprint( + SENDER, + otherRecipient, + CURRENCY, + new BigDecimal("25.00"), + 1L, + "Sender", + "Player payment", + Map.of(), + false, + false + )); + assertNotEquals(normal, EconomyRepository.transferFingerprint( + SENDER, + RECIPIENT, + CURRENCY, + new BigDecimal("25.00"), + 1L, + "Sender", + "Player payment", + Map.of(), + true, + false + )); + } + + @Test + void metadataFramingCannotCollideAndIdentityUuidIsBound() { + String metadataKeyContainsEquals = EconomyRepository.mutationFingerprint( + TransactionType.WITHDRAW, + TransactionType.WITHDRAW, + SENDER, + CURRENCY, + new BigDecimal("1.00"), + 1L, + "Sender", + "test", + Map.of("a=b", "c"), + false + ); + String metadataValueContainsEquals = EconomyRepository.mutationFingerprint( + TransactionType.WITHDRAW, + TransactionType.WITHDRAW, + SENDER, + CURRENCY, + new BigDecimal("1.00"), + 1L, + "Sender", + "test", + Map.of("a", "b=c"), + false + ); + Identity samePlayerIdDifferentUuid = new Identity( + SENDER.playerId(), + UUID.fromString("00000000-0000-0000-0000-000000000099"), + SENDER.playerName() + ); + String differentUuid = EconomyRepository.mutationFingerprint( + TransactionType.WITHDRAW, + TransactionType.WITHDRAW, + samePlayerIdDifferentUuid, + CURRENCY, + new BigDecimal("1.00"), + 1L, + "Sender", + "test", + Map.of("a=b", "c"), + false + ); + + assertNotEquals(metadataKeyContainsEquals, metadataValueContainsEquals); + assertNotEquals(metadataKeyContainsEquals, differentUuid); + } + + private static EconomySettings.Currency currency() { + return new EconomySettings.Currency( + "crowns", + new EconomyScope(EconomyScopeType.GLOBAL, "hauntedmc/global"), + new EconomySettings.Display("crown", "crowns", "", "{amount}", 2, true), + new EconomySettings.Balances( + new BigDecimal("0.00"), + new BigDecimal("0.00"), + new BigDecimal("1000000.00"), + false, + RoundingMode.HALF_UP + ), + new EconomySettings.Commands("crowns", List.of(), true, true, true, true, true, true), + new EconomySettings.Payments( + true, + new BigDecimal("0.01"), + new BigDecimal("10000.00"), + new BigDecimal("1000.00"), + BigDecimal.ZERO.setScale(2), + BigDecimal.ZERO.setScale(2), + Duration.ofSeconds(1) + ) + ); + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/persistence/EconomyTransientFailureTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/persistence/EconomyTransientFailureTest.java new file mode 100644 index 000000000..1d49644d4 --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/persistence/EconomyTransientFailureTest.java @@ -0,0 +1,34 @@ +package nl.hauntedmc.serverfeatures.features.economy.persistence; + +import org.junit.jupiter.api.Test; + +import java.sql.SQLException; +import java.sql.SQLRecoverableException; +import java.sql.SQLTransientException; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EconomyTransientFailureTest { + + @Test + void recognizesSqlTransientAndRecoverableFailures() { + assertTrue(EconomyRepository.isTransient(new SQLTransientException("temporary"))); + assertTrue(EconomyRepository.isTransient(new SQLRecoverableException("connection reset"))); + } + + @Test + void recognizesConnectionRollbackAndMysqlConcurrencyCodes() { + assertTrue(EconomyRepository.isTransient(new SQLException("connection", "08006", 0))); + assertTrue(EconomyRepository.isTransient(new SQLException("rollback", "40001", 0))); + assertTrue(EconomyRepository.isTransient(new SQLException("duplicate", "23000", 1062))); + assertTrue(EconomyRepository.isTransient(new SQLException("lock wait", "HY000", 1205))); + assertTrue(EconomyRepository.isTransient(new SQLException("deadlock", "40001", 1213))); + } + + @Test + void rejectsPermanentSqlAndValidationFailures() { + assertFalse(EconomyRepository.isTransient(new SQLException("syntax", "42000", 1064))); + assertFalse(EconomyRepository.isTransient(new IllegalArgumentException("invalid request"))); + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/service/EconomyServiceTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/service/EconomyServiceTest.java new file mode 100644 index 000000000..8db3f1127 --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/economy/service/EconomyServiceTest.java @@ -0,0 +1,90 @@ +package nl.hauntedmc.serverfeatures.features.economy.service; + +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.Account; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.AccountStatus; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.Identity; +import nl.hauntedmc.serverfeatures.features.economy.model.EconomyModels.TransactionType; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +class EconomyServiceTest { + + @Test + void acceptsCompatibleLotteryJournalClassification() { + assertEquals( + TransactionType.LOTTERY_DONATION, + EconomyService.requestedJournalType( + TransactionType.WITHDRAW, + Map.of("transaction_type", "LOTTERY_DONATION") + ) + ); + } + + @Test + void rejectsJournalClassificationThatWouldChangeMutationDirection() { + assertEquals( + TransactionType.WITHDRAW, + EconomyService.requestedJournalType( + TransactionType.WITHDRAW, + Map.of("transaction_type", "LOTTERY_PAYOUT") + ) + ); + } + + @Test + void ignoresUnknownJournalClassification() { + assertEquals( + TransactionType.DEPOSIT, + EconomyService.requestedJournalType( + TransactionType.DEPOSIT, + Map.of("transaction_type", "not-a-transaction-type") + ) + ); + } + + @Test + void mergesBalanceAndSettingsUsingIndependentVersions() { + Identity identity = new Identity( + 1L, + UUID.fromString("00000000-0000-0000-0000-000000000001"), + "Player" + ); + Account newerBalance = new Account( + "account", + identity, + "crowns", + "hauntedmc/global", + new BigDecimal("200.00"), + 5L, + 2L, + true, + AccountStatus.ACTIVE + ); + Account newerSettings = new Account( + "account", + identity, + "crowns", + "hauntedmc/global", + new BigDecimal("100.00"), + 4L, + 3L, + false, + AccountStatus.FROZEN + ); + + Account merged = EconomyService.mergeAccount(newerBalance, newerSettings); + + assertEquals(new BigDecimal("200.00"), merged.balance()); + assertEquals(5L, merged.version()); + assertEquals(3L, merged.settingsVersion()); + assertEquals(false, merged.paymentsEnabled()); + assertSame(AccountStatus.FROZEN, merged.status()); + } + +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/lottery/economy/BuiltinLotteryEconomyTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/lottery/economy/BuiltinLotteryEconomyTest.java new file mode 100644 index 000000000..d94989922 --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/lottery/economy/BuiltinLotteryEconomyTest.java @@ -0,0 +1,81 @@ +package nl.hauntedmc.serverfeatures.features.lottery.economy; + +import nl.hauntedmc.serverfeatures.api.economy.EconomyApi; +import nl.hauntedmc.serverfeatures.api.economy.EconomyCurrency; +import nl.hauntedmc.serverfeatures.api.economy.EconomyScope; +import nl.hauntedmc.serverfeatures.api.economy.EconomyResult; +import nl.hauntedmc.serverfeatures.api.economy.EconomyResultStatus; +import nl.hauntedmc.serverfeatures.api.economy.EconomyScopeType; +import nl.hauntedmc.serverfeatures.features.lottery.model.Money; +import org.bukkit.OfflinePlayer; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class BuiltinLotteryEconomyTest { + + @Test + void requiresCurrencyPrecisionThatMatchesLotteryStorage() { + EconomyApi economy = mock(EconomyApi.class); + when(economy.currency("money")).thenReturn(Optional.of(currency(2))); + when(economy.currency("crowns")).thenReturn(Optional.of(currency(0))); + + assertDoesNotThrow(() -> new BuiltinLotteryEconomy(economy, "money")); + assertThrows(IllegalStateException.class, () -> new BuiltinLotteryEconomy(economy, "crowns")); + } + + + @Test + void retriesTemporaryNativeFailureWithTheSameIdempotentRequest() { + EconomyApi economy = mock(EconomyApi.class); + when(economy.currency("money")).thenReturn(Optional.of(currency(2))); + when(economy.withdraw(any())) + .thenReturn(CompletableFuture.completedFuture(new EconomyResult( + EconomyResultStatus.TEMPORARY_FAILURE, null, null, null, "temporary" + ))) + .thenReturn(CompletableFuture.completedFuture(new EconomyResult( + EconomyResultStatus.SUCCESS, + UUID.fromString("00000000-0000-0000-0000-000000000010"), + new BigDecimal("90.00"), + null, + "" + ))); + OfflinePlayer player = mock(OfflinePlayer.class); + when(player.getUniqueId()).thenReturn(UUID.fromString("00000000-0000-0000-0000-000000000001")); + when(player.getName()).thenReturn("Player"); + + LotteryEconomyGateway.EconomyResult result = new BuiltinLotteryEconomy(economy, "money") + .withdraw(player, Money.of(new BigDecimal("10.00")), "purchase:test") + .toCompletableFuture() + .join(); + + assertTrue(result.successful()); + verify(economy, times(2)).withdraw(any()); + } + + private static EconomyCurrency currency(int fractionalDigits) { + return new EconomyCurrency( + "money", + "coin", + "coins", + "$", + fractionalDigits, + new EconomyScope(EconomyScopeType.SERVER, "hauntedmc/server/survival"), + BigDecimal.ZERO, + new BigDecimal("1000000000.00"), + true + ); + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/framework/loader/FeatureDescriptorTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/framework/loader/FeatureDescriptorTest.java index 82bb073f0..b4df2070a 100644 --- a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/framework/loader/FeatureDescriptorTest.java +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/framework/loader/FeatureDescriptorTest.java @@ -52,6 +52,7 @@ void createsFreshMetadataForEveryFeatureContext() { "Example", "2.0", Set.of(), + Set.of(), Set.of() ); @@ -72,6 +73,7 @@ void fallsBackToDescriptorSnapshotWhenMetadataCannotBeConstructed() { "Fallback", "3.0", Set.of("Dependency"), + Set.of(), Set.of("Plugin") ); diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/framework/loader/FeatureLoadOrderResolverTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/framework/loader/FeatureLoadOrderResolverTest.java index 9c19328cd..c305fb757 100644 --- a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/framework/loader/FeatureLoadOrderResolverTest.java +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/framework/loader/FeatureLoadOrderResolverTest.java @@ -141,6 +141,36 @@ void dependentOnCycleIsSkippedWhileUnrelatedDependencyStillLoads() { assertEquals(Set.of("Root", "A", "B"), result.skippedFeatures()); } + @Test + void optionalDependencyLoadsFirstWhenAvailable() { + Map descriptors = new LinkedHashMap<>(); + descriptors.put("Lottery", new FeatureDescriptor( + "Lottery", "x.Lottery", "Lottery", "1", Set.of(), Set.of("Economy"), Set.of() + )); + descriptors.put("Economy", descriptor("Economy")); + + FeatureLoadOrderResolver.Result result = resolve( + descriptors, List.of("Lottery", "Economy"), new ArrayList<>() + ); + + assertBefore(result.loadOrder(), "Economy", "Lottery"); + assertTrue(result.skippedFeatures().isEmpty()); + } + + @Test + void missingOptionalDependencyDoesNotSkipFeature() { + Map descriptors = Map.of( + "Lottery", new FeatureDescriptor( + "Lottery", "x.Lottery", "Lottery", "1", Set.of(), Set.of("Economy"), Set.of() + ) + ); + + FeatureLoadOrderResolver.Result result = resolve(descriptors, List.of("Lottery"), new ArrayList<>()); + + assertEquals(List.of("Lottery"), result.loadOrder()); + assertTrue(result.skippedFeatures().isEmpty()); + } + @Test void duplicateRequestedFeatureNamesDoNotDuplicateLoadEntries() { Map descriptors = Map.of("A", descriptor("A"));