Astral Realms Documentation Help

Developer API

AstralEconomy is consumed by other plugins mainly through the AstralCore-defined EconomyService SPI, implemented by this plugin's EconomyServiceImpl and registered on startup — this is the recommended entry point for any plugin that needs to move money. Town balances additionally get a small static facade, EconomyAPI, for keeping a town's balance warm in the read cache. No custom Bukkit events are fired; integrate through these two surfaces instead of listening for events.

Maven coordinates

<dependency> <groupId>com.astralrealms</groupId> <artifactId>economy</artifactId> <version>3.0-SNAPSHOT</version> <scope>provided</scope> </dependency>

Repository: https://maven.astralrealms.fr/repository/maven-public/.

EconomyService SPI

EconomyService is declared in AstralCore (com.astralrealms.core.service.impl.EconomyService), so a dependent plugin only needs a provided dependency on AstralCore's SPI module plus AstralEconomy on the server — not a compile-time dependency on AstralEconomy itself. Resolve it through the framework's service registry:

import com.astralrealms.core.paper.AstralPaperAPI; import com.astralrealms.core.service.impl.EconomyService; AstralPaperAPI.getService(EconomyService.class).ifPresent(economy -> { economy.getBalance(playerId).thenAccept(balance -> { /* Optional<Double> */ }); });

getService returns empty whenever no plugin has registered an EconomyService implementation yet — in practice, because AstralEconomy isn't installed, or your plugin resolved it before AstralEconomy's onEnable ran (add AstralEconomy to your plugin's softdepend/depend to avoid this).

Currency resolution. Every method that takes a String currency resolves it the same way: a non-null id must match a currency configured in currencies.yml (see Currencies) or the call fails closed; a null id resolves to whichever currency is marked default: true, and if none is configured, that also fails closed. There is no fallback that fabricates a currency or an account row for an id that doesn't resolve — reads report empty/zero/false and writes report false without touching the database.

Double-precision boundary. The SPI is double-based; internally every amount is money-safe BigDecimal. Amounts passed in are converted with BigDecimal.valueOf(double) (never the binary-exact new BigDecimal(double) constructor) and balances are returned via BigDecimal.doubleValue() — the precision loss, if any, is confined to that single conversion at the SPI boundary, not compounded inside the plugin.

Reads

Method

Returns

Notes

getCachedBalance(UUID id)

BigDecimal

Synchronous, from the read cache, default currency.

getCachedBalance(UUID id, String currency)

BigDecimal

Synchronous, from the read cache, named currency.

getBalance(UUID id)

CompletableFuture<Optional<Double>>

Authoritative async read, default currency; loads from the database on a cache miss.

getBalance(UUID id, String currency)

CompletableFuture<Optional<Double>>

Authoritative async read, named currency.

hasBalance(UUID id, double amount)

CompletableFuture<Boolean>

Default currency. false for a non-finite amount.

hasBalance(UUID id, String currency, double amount)

CompletableFuture<Boolean>

Named currency.

Both getCachedBalance overloads return BigDecimal.ZERO when the currency can't be resolved (unknown id, or no default currency configured). Both getBalance overloads return Optional.empty() in that same case, and also when the currency resolves but the account has no row for it yet — getBalance cannot distinguish "unresolvable currency" from "no account." hasBalance treats a missing row as a balance of zero and compares amount against it, so it never returns empty — only true/false.

The cached read is what backs %economy_*% placeholders and the action bar; see Reads: an in-memory cache, DB on miss for how pinning (online players, and towns via EconomyAPI below) keeps it warm.

Deposits, withdrawals, and transfers

Method

Returns

Notes

deposit(UUID id, double amount)/deposit(UUID id, double amount, boolean flag)

CompletableFuture<Boolean>

Default currency.

deposit(UUID id, String currency, double amount)/(..., boolean flag)

CompletableFuture<Boolean>

Named currency.

withdraw(UUID id, double amount)/withdraw(UUID id, double amount, boolean flag)

CompletableFuture<Boolean>

Default currency.

withdraw(UUID id, String currency, double amount)/(..., boolean flag)

CompletableFuture<Boolean>

Named currency.

transfer(UUID from, UUID to, double amount)

CompletableFuture<Boolean>

Default currency.

transfer(UUID from, UUID to, String currency, double amount)

CompletableFuture<Boolean>

Named currency. Rejects from == to (and a null endpoint) as invalid.

Every write method above rejects a non-finite amount and an unresolvable currency by returning false immediately, with nothing written.

economy.deposit(playerId, 500.0); // default currency economy.deposit(playerId, "astralys", 500.0); // named currency economy.withdraw(playerId, 1_000_000.0) // fails (false) rather than overdrawing .thenAccept(ok -> { /* ok == false */ }); economy.transfer(fromId, toId, "astralys", 25.0);

Account lifecycle

Method

Returns

Notes

loadAccount(UUID id, String currency, boolean player)

CompletableFuture<Boolean>

Provisions the (id, currency) row if it doesn't exist yet (granting that currency's configured starting balance), tagged PLAYER when player is true, TOWN otherwise. false when currency doesn't resolve.

deleteAccount(UUID id)

CompletableFuture<Boolean>

Deletes every currency row for id and drops its cached entries (ledger history is kept). Always resolves true.

loadAccount only provisions the database row — unlike EconomyAPI.loadTown below, it does not pin the entry into the read cache. Player rows are normally provisioned automatically on join; loadAccount is for callers that need to guarantee a row exists ahead of time (e.g. a town system creating a town's account) or for a non-player entity the plugin wouldn't otherwise see join/quit for.

Bulk transactions

import com.astralrealms.core.service.impl.EconomyService.TransactionRequest; import com.astralrealms.core.service.impl.EconomyService.TransactionType; List<TransactionRequest> batch = List.of( new TransactionRequest(null, winnerId, 1000.0, "default", TransactionType.DEPOSIT), new TransactionRequest(sellerId, buyerId, 50.0, "astralys", TransactionType.TRANSFER) ); economy.bulkTransactions(batch).thenAccept(allSucceeded -> { /* ... */ });

TransactionRequest(UUID source, UUID target, double amount, String currency, TransactionType type) — for DEPOSIT/WITHDRAW, target is the account and source is ignored (@Nullable); for TRANSFER, source is the sender and target is the recipient. currency follows the same resolution rule as every other method (null → default currency).

There is no cross-account batch primitive in the storage layer — bulkTransactions applies each request sequentially, not atomically: every request is routed to the matching deposit/withdraw/transfer call and runs as its own independently guarded database transaction, one at a time, in iteration order. Every request is attempted regardless of earlier failures (apply-what-you-can) — a failed request neither rolls back requests already committed nor skips requests still to come. The returned boolean is true only if every request in the batch succeeded; a null or empty batch is a vacuous success. A malformed request (null request, null type, or null target) counts as a failed request rather than throwing.

EconomyAPI town-pinning facade

com.astralrealms.economy.api.EconomyAPI is this plugin's own static facade (mirroring the AstralPaperAPI-style static convention) for keeping a town's default-currency balance warm in the synchronous read cache — it has no player-facing equivalent because players are pinned automatically on join/quit.

Method

Returns

Notes

loadTown(UUID town)

CompletableFuture<Void>

Pins one town's default-currency balance (ensuring its row, then warming the cache).

loadTowns(Collection<UUID> towns)

CompletableFuture<Void>

Pins each town's default-currency balance; completes when all are loaded.

unloadTown(UUID town)

void

Unpins one town's default-currency balance (drops the cached entry; the balance itself is untouched).

unloadTowns(Collection<UUID> towns)

void

Unpins each town's default-currency balance.

Every method operates on the configured default currency only. If no default currency is configured, loadTown/loadTowns log a warning and no-op (returning an already-completed future); unloadTown/unloadTowns no-op silently.

import com.astralrealms.economy.api.EconomyAPI; // On this server's startup, re-pin every town it knows about EconomyAPI.loadTowns(townService.allTownIds()); // On town creation EconomyAPI.loadTown(newTown.getUniqueId()); // On town deletion or release EconomyAPI.unloadTown(town.getUniqueId());

A pinned entry never TTL-expires and is refreshed in place on every cross-server BalanceInvalidatePacket, so EconomyService.getCachedBalance stays warm for that town on that server. See Reads: an in-memory cache, DB on miss for how pinning interacts with normal (unpinned) cache entries.

Cross-server contract

EconomyAPI does not broadcast town pins across servers — pin state is per-server and in-memory only. Each server pins independently, so the owning town plugin is responsible for:

  • calling loadTowns for every known town on that server's startup,

  • calling loadTown on town creation, and

  • calling unloadTown on town deletion or release,

on each server it runs on. A town pinned on only one server gets warm synchronous reads only there — everywhere else its balance is still correct via the asynchronous, authoritative database read (EconomyService.getBalance); it's just not served from the synchronous cache. Because pin state doesn't survive a restart, the caller must re-load its towns after every server restart, not only the first boot.

Money-safety guarantees

The database is the sole authority for every balance — accounts.balance and transactions.delta/transactions.balance_after are all DECIMAL(19, 4). There is no in-memory balance, no lease, and no write-behind: a mutation is durable the instant its call returns.

Internally, every mutation resolves to a MutationResult carrying one of four outcomes (MutationResult.Result):

Result

Meaning

SUCCESS

Applied; MutationResult.account() (and, for a transfer, counterparty()) carries the post-mutation snapshot.

INSUFFICIENT_FUNDS

The guarded update would have taken the balance negative — nothing written.

ACCOUNT_NOT_FOUND

The account has no row for that currency. The ledger never provisions accounts — it only ever mutates a row that already exists.

INVALID_AMOUNT

The amount was null or failed the operation's positivity rule. Deposit, withdraw, and transfer all require a positive magnitude (zero or negative is rejected); the admin /eco set path (TransactionService.set, not exposed on the SPI) instead requires a non-negative target (only negative is rejected).

The EconomyService SPI's write methods collapse this down to a single boolean (SUCCESStrue, everything else → false). A caller with a hard compile-time dependency on AstralEconomy itself (rather than the SPI) can reach the richer MutationResult directly via AstralEconomy.get().transactions(), but this is the lower-level engine, not the recommended integration surface — prefer the SPI / EconomyAPI above.

Messaging surface

AstralEconomy registers two cross-server packets on its own EconomyPacketRegistry, both best-effort and never money-bearing — a lost or duplicated packet never produces a wrong balance, only a stale cache read or a missed/repeated action-bar blip, both of which self-heal.

Packet

Channel

Payload

Purpose

BalanceInvalidatePacket

economy.balance

uuid, currency — a cache key only, no amount

Broadcast after a successful mutation so other servers drop (or, if pinned, reload) their cached entry for (uuid, currency) and re-read the database.

BalanceNotifyPacket

economy.notify

uuid, currency, deltadisplay-only

Broadcast so whichever server actually holds the player can show the net change on their action bar, even when the mutation ran on a different server. delta is never applied to a balance.

Both packets are best-effort: a send failure is logged and swallowed, it never fails the mutation that triggered it, and a lost invalidation simply self-heals once the receiving server's cache entry naturally expires. See Cross-server model and Action-bar notifications for the full mechanics.

No custom Bukkit events

AstralEconomy fires no custom Bukkit events for balance changes — there is nothing to @EventHandler for. Integrate exclusively through the EconomyService SPI and EconomyAPI above; the plugin's own cross-server signaling (BalanceInvalidatePacket/BalanceNotifyPacket) is internal wiring, not a public integration point.

Last modified: 25 July 2026