diff --git a/CoalGov/src/main/java/com/librewiki/coalgov/listener/MarketListener.java b/CoalGov/src/main/java/com/librewiki/coalgov/listener/MarketListener.java index c18a4e1..ea4f7eb 100644 --- a/CoalGov/src/main/java/com/librewiki/coalgov/listener/MarketListener.java +++ b/CoalGov/src/main/java/com/librewiki/coalgov/listener/MarketListener.java @@ -39,6 +39,7 @@ public final class MarketListener implements Listener { return; } plugin.marketService().buy(player, clicked.getType(), purchaseAmount(event.getClick())); + plugin.marketService().open(player); } catch (SQLException exception) { plugin.getLogger().warning("Market click failed: " + exception.getMessage()); player.sendMessage(plugin.messages().text("&cMarket ledger unavailable.")); diff --git a/CoalGov/src/main/java/com/librewiki/coalgov/service/GovernmentService.java b/CoalGov/src/main/java/com/librewiki/coalgov/service/GovernmentService.java index 8aea5cc..cead9dc 100644 --- a/CoalGov/src/main/java/com/librewiki/coalgov/service/GovernmentService.java +++ b/CoalGov/src/main/java/com/librewiki/coalgov/service/GovernmentService.java @@ -52,6 +52,14 @@ public final class GovernmentService { repository.addTreasury(amount); } + public void collectMarketRevenue(long amount) throws SQLException { + repository.addTreasury(amount); + } + + public boolean spendMarketTransaction(long amount) throws SQLException { + return spendTreasuryBudget(amount, "market.treasury.transaction_budget_percent"); + } + public boolean taxExempt(UUID uuid) throws SQLException { return repository.taxExempt(uuid); } @@ -113,6 +121,18 @@ public final class GovernmentService { return PayFineResult.success(fine.amount(), ""); } + private boolean spendTreasuryBudget(long amount, String configPath) throws SQLException { + if (amount <= 0L) { + return false; + } + long balance = repository.treasuryBalance(); + long budget = Math.max(0L, Math.round(balance * Math.max(0.0D, config.getDouble(configPath, 0.0D)) / 100.0D)); + if (amount > budget) { + return false; + } + return repository.spendTreasury(amount); + } + private long transactionTax(UUID playerUuid, long gross, String configPath) throws SQLException { if (gross <= 0L || repository.taxExempt(playerUuid)) { return 0L; diff --git a/CoalGov/src/main/java/com/librewiki/coalgov/service/MarketService.java b/CoalGov/src/main/java/com/librewiki/coalgov/service/MarketService.java index 323a6c0..ea082d7 100644 --- a/CoalGov/src/main/java/com/librewiki/coalgov/service/MarketService.java +++ b/CoalGov/src/main/java/com/librewiki/coalgov/service/MarketService.java @@ -2,6 +2,7 @@ package com.librewiki.coalgov.service; import com.librewiki.coalgov.CoalGovPlugin; import com.librewiki.coalgov.storage.MarketRepository; +import com.librewiki.coalgov.storage.MarketRepository.Flow; import com.librewiki.coalgov.util.CoalMoney; import org.bukkit.Bukkit; import org.bukkit.Material; @@ -70,7 +71,9 @@ public final class MarketService { long stock = simulatedStock.containsKey(material) ? simulatedStock.get(material) : repository.stock(material, initialStock(material)); - grossCredit += sellCredit(material, basePrice, stock, item.getAmount()); + Flow flow = repository.flow(material, flowHalfLifeMillis(material)); + long alreadySold = sold.getOrDefault(material, 0L); + grossCredit += sellCredit(material, basePrice, stock, flow, alreadySold, item.getAmount()); simulatedStock.put(material, stock + item.getAmount()); sold.merge(material, (long) item.getAmount(), Long::sum); } @@ -80,11 +83,20 @@ public final class MarketService { } long tax = plugin.governmentService().marketSaleTax(player.getUniqueId(), grossCredit); long credit = Math.max(0L, grossCredit - tax); - plugin.economyService().credit(player.getUniqueId(), credit, "market_sale"); + if (!plugin.governmentService().spendMarketTransaction(grossCredit)) { + player.sendMessage(plugin.messages().text("&cThe Ministry treasury cannot fund that market purchase right now.")); + return; + } + if (!plugin.economyService().credit(player.getUniqueId(), credit, "market_sale")) { + plugin.governmentService().collectTax(grossCredit); + player.sendMessage(plugin.messages().text("&cMarket sale failed.")); + return; + } plugin.governmentService().collectTax(tax); removeSoldItems(player, sold); for (var entry : sold.entrySet()) { repository.addStock(entry.getKey(), entry.getValue()); + repository.recordSell(entry.getKey(), entry.getValue(), flowHalfLifeMillis(entry.getKey())); } if (tax > 0L) { player.sendMessage(plugin.messages().text("&aSold resources for &f" + CoalMoney.format(credit) + " &aafter &f" + CoalMoney.format(tax) + " &atax.")); @@ -132,7 +144,8 @@ public final class MarketService { player.sendMessage(plugin.messages().text("&cYou need more inventory space.")); return; } - plugin.governmentService().collectTax(tax); + plugin.governmentService().collectMarketRevenue(total); + repository.recordBuy(material, amount, flowHalfLifeMillis(material)); if (tax > 0L) { player.sendMessage(plugin.messages().text("&aBought " + amount + " " + readable(material) + " for &f" + CoalMoney.format(total) + "&a, including &f" + CoalMoney.format(tax) + " &atax.")); } else { @@ -165,34 +178,47 @@ public final class MarketService { private Quote quote(Material material) throws SQLException { double basePrice = basePrices().get(material); long stock = repository.stock(material, initialStock(material)); - return quote(material, basePrice, stock); + Flow flow = repository.flow(material, flowHalfLifeMillis(material)); + return quote(material, basePrice, stock, flow); } - private Quote quote(Material material, double basePrice, long stock) { - return new Quote(stock, sellUnitPrice(material, basePrice, stock), buyUnitPrice(material, basePrice, stock)); + private Quote quote(Material material, double basePrice, long stock, Flow flow) { + return new Quote(stock, sellUnitPrice(material, basePrice, stock, flow, 0L), buyUnitPrice(material, basePrice, stock, flow)); } - private long sellCredit(Material material, double basePrice, long startingStock, int amount) { + private long sellCredit(Material material, double basePrice, long startingStock, Flow flow, long alreadySold, int amount) { double total = 0.0D; for (int index = 0; index < amount; index++) { - total += sellUnitPrice(material, basePrice, startingStock + index); + total += sellUnitPrice(material, basePrice, startingStock + index, flow, alreadySold + index); } return Math.max(1L, CoalMoney.fromCoal(total)); } - private double sellUnitPrice(Material material, double basePrice, long stock) { - double target = Math.max(1.0D, targetStock()); - double multiplier = target / Math.max(1.0D, stock); - multiplier = Math.max(minMultiplier(), Math.min(maxMultiplier(), multiplier)); - return Math.max(coalEquivalentFloor(material), basePrice * multiplier * sellMultiplier()); + private double sellUnitPrice(Material material, double basePrice, long stock, Flow flow, long batchSells) { + double multiplier = stockPressure(material, stock) * flowPressure(material, flow, batchSells); + multiplier = Math.max(minMultiplier(material), Math.min(maxMultiplier(material), multiplier)); + return Math.max(coalEquivalentFloor(material), basePrice * multiplier * sellMultiplier(material)); } - private double buyUnitPrice(Material material, double basePrice, long stock) { - double sellPrice = sellUnitPrice(material, basePrice, stock); - double target = Math.max(1.0D, targetStock()); - double multiplier = target / Math.max(1.0D, stock); - multiplier = Math.max(minMultiplier(), Math.min(maxMultiplier(), multiplier)); - return Math.max(sellPrice, basePrice * multiplier * buyMultiplier()); + private double buyUnitPrice(Material material, double basePrice, long stock, Flow flow) { + double sellPrice = sellUnitPrice(material, basePrice, stock, flow, 0L); + double multiplier = stockPressure(material, stock) * flowPressure(material, flow, 0L); + multiplier = Math.max(minMultiplier(material), Math.min(maxMultiplier(material), multiplier)); + return Math.max(sellPrice, basePrice * multiplier * buyMultiplier(material)); + } + + private double stockPressure(Material material, long stock) { + double target = Math.max(1.0D, targetStock(material)); + double floorStock = Math.max(1.0D, target * stockFloorRatio(material)); + double pressure = target / Math.max(floorStock, stock); + return Math.pow(Math.max(0.0D, pressure), elasticity(material)); + } + + private double flowPressure(Material material, Flow flow, long batchSells) { + double target = Math.max(1.0D, targetStock(material)); + double netDemand = flow.recentBuys() - flow.recentSells() - Math.max(0L, batchSells); + double pressure = 1.0D + (netDemand / target) * flowWeight(material); + return Math.max(flowMinMultiplier(material), Math.min(flowMaxMultiplier(material), pressure)); } private long buyCost(Quote quote, int amount) { @@ -262,24 +288,59 @@ public final class MarketService { return plugin.getConfig().getLong("market.initial_stock." + material.name(), initialStock()); } - private long targetStock() { - return plugin.getConfig().getLong("market.dynamic.target_stock", 1024L); + private long targetStock(Material material) { + return materialLong(material, "target_stock", 1024L); } - private double minMultiplier() { - return plugin.getConfig().getDouble("market.dynamic.min_multiplier", 0.35D); + private double elasticity(Material material) { + return materialDouble(material, "elasticity", 1.0D); } - private double maxMultiplier() { - return plugin.getConfig().getDouble("market.dynamic.max_multiplier", 3.0D); + private double minMultiplier(Material material) { + return materialDouble(material, "min_multiplier", 0.35D); } - private double sellMultiplier() { - return plugin.getConfig().getDouble("market.dynamic.sell_multiplier", 0.85D); + private double maxMultiplier(Material material) { + return materialDouble(material, "max_multiplier", 3.0D); } - private double buyMultiplier() { - return plugin.getConfig().getDouble("market.dynamic.buy_multiplier", 1.15D); + private double sellMultiplier(Material material) { + return materialDouble(material, "sell_multiplier", 0.85D); + } + + private double buyMultiplier(Material material) { + return materialDouble(material, "buy_multiplier", 1.15D); + } + + private double stockFloorRatio(Material material) { + return materialDouble(material, "stock_floor_ratio", 0.05D); + } + + private double flowWeight(Material material) { + return materialDouble(material, "flow_weight", 0.5D); + } + + private double flowMinMultiplier(Material material) { + return materialDouble(material, "flow_min_multiplier", 0.70D); + } + + private double flowMaxMultiplier(Material material) { + return materialDouble(material, "flow_max_multiplier", 1.75D); + } + + private long flowHalfLifeMillis(Material material) { + long minutes = materialLong(material, "flow_half_life_minutes", 180L); + return Math.max(1L, minutes) * 60_000L; + } + + private long materialLong(Material material, String key, long fallback) { + return plugin.getConfig().getLong("market.dynamic.materials." + material.name() + "." + key, + plugin.getConfig().getLong("market.dynamic." + key, fallback)); + } + + private double materialDouble(Material material, String key, double fallback) { + return plugin.getConfig().getDouble("market.dynamic.materials." + material.name() + "." + key, + plugin.getConfig().getDouble("market.dynamic." + key, fallback)); } private ItemStack button(Material material, String name, List lore) { diff --git a/CoalGov/src/main/java/com/librewiki/coalgov/storage/Database.java b/CoalGov/src/main/java/com/librewiki/coalgov/storage/Database.java index 4d55f05..93b64b3 100644 --- a/CoalGov/src/main/java/com/librewiki/coalgov/storage/Database.java +++ b/CoalGov/src/main/java/com/librewiki/coalgov/storage/Database.java @@ -133,6 +133,14 @@ public final class Database { updated_at INTEGER NOT NULL ) """); + statement.execute(""" + CREATE TABLE IF NOT EXISTS market_flow ( + material TEXT PRIMARY KEY, + recent_buys REAL NOT NULL DEFAULT 0, + recent_sells REAL NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL + ) + """); statement.execute(""" CREATE TABLE IF NOT EXISTS treasury ( id TEXT PRIMARY KEY, diff --git a/CoalGov/src/main/java/com/librewiki/coalgov/storage/MarketRepository.java b/CoalGov/src/main/java/com/librewiki/coalgov/storage/MarketRepository.java index 5cd50bd..49f2f28 100644 --- a/CoalGov/src/main/java/com/librewiki/coalgov/storage/MarketRepository.java +++ b/CoalGov/src/main/java/com/librewiki/coalgov/storage/MarketRepository.java @@ -66,4 +66,71 @@ public final class MarketRepository { return statement.executeUpdate() == 1; } } + + public Flow flow(Material material, long halfLifeMillis) throws SQLException { + long now = System.currentTimeMillis(); + try (PreparedStatement insert = database.connection().prepareStatement(""" + INSERT INTO market_flow(material, recent_buys, recent_sells, updated_at) + VALUES (?, 0, 0, ?) + ON CONFLICT(material) DO NOTHING + """)) { + insert.setString(1, material.name()); + insert.setLong(2, now); + insert.executeUpdate(); + } + try (PreparedStatement select = database.connection().prepareStatement( + "SELECT recent_buys, recent_sells, updated_at FROM market_flow WHERE material = ?")) { + select.setString(1, material.name()); + try (ResultSet result = select.executeQuery()) { + if (!result.next()) { + return new Flow(0.0D, 0.0D); + } + double decay = decayFactor(now - result.getLong("updated_at"), halfLifeMillis); + return new Flow(result.getDouble("recent_buys") * decay, result.getDouble("recent_sells") * decay); + } + } + } + + public void recordBuy(Material material, long amount, long halfLifeMillis) throws SQLException { + recordFlow(material, Math.max(0L, amount), 0L, halfLifeMillis); + } + + public void recordSell(Material material, long amount, long halfLifeMillis) throws SQLException { + recordFlow(material, 0L, Math.max(0L, amount), halfLifeMillis); + } + + private void recordFlow(Material material, long buys, long sells, long halfLifeMillis) throws SQLException { + if (buys <= 0L && sells <= 0L) { + return; + } + long now = System.currentTimeMillis(); + Flow flow = flow(material, halfLifeMillis); + try (PreparedStatement statement = database.connection().prepareStatement(""" + INSERT INTO market_flow(material, recent_buys, recent_sells, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(material) DO UPDATE SET + recent_buys = excluded.recent_buys, + recent_sells = excluded.recent_sells, + updated_at = excluded.updated_at + """)) { + statement.setString(1, material.name()); + statement.setDouble(2, flow.recentBuys() + buys); + statement.setDouble(3, flow.recentSells() + sells); + statement.setLong(4, now); + statement.executeUpdate(); + } + } + + private double decayFactor(long elapsedMillis, long halfLifeMillis) { + if (elapsedMillis <= 0L) { + return 1.0D; + } + if (halfLifeMillis <= 0L) { + return 0.0D; + } + return Math.pow(0.5D, (double) elapsedMillis / halfLifeMillis); + } + + public record Flow(double recentBuys, double recentSells) { + } } diff --git a/CoalGov/src/main/resources/config.yml b/CoalGov/src/main/resources/config.yml index 1627bd3..545a635 100644 --- a/CoalGov/src/main/resources/config.yml +++ b/CoalGov/src/main/resources/config.yml @@ -60,13 +60,30 @@ divining_rod: cooldown_seconds: 5 market: + treasury: + transaction_budget_percent: 25.0 dynamic: initial_stock: 1024 target_stock: 1024 + elasticity: 1.0 + stock_floor_ratio: 0.05 min_multiplier: 0.35 max_multiplier: 3.0 sell_multiplier: 0.85 buy_multiplier: 1.15 + flow_half_life_minutes: 180 + flow_weight: 0.5 + flow_min_multiplier: 0.70 + flow_max_multiplier: 1.75 + materials: + COBBLESTONE: + target_stock: 4096 + elasticity: 1.0 + min_multiplier: 0.20 + max_multiplier: 10.0 + flow_weight: 1.25 + flow_min_multiplier: 0.50 + flow_max_multiplier: 2.25 initial_stock: WHEAT: 0 BREAD: 0 diff --git a/coalgov-wiki-pages/admin.txt b/coalgov-wiki-pages/admin.txt index c0650a5..8865d70 100644 --- a/coalgov-wiki-pages/admin.txt +++ b/coalgov-wiki-pages/admin.txt @@ -90,7 +90,7 @@ Bypass mode is per-player and lasts until it is turned off or the plugin/server ===== Market Controls ===== -The market is configured in ''plugins/CoalGov/config.yml'' under ''market''. Admins can tune base prices, target stock, the starting stock for new materials, min/max price movement, and the buy/sell spread. +The market is configured in ''plugins/CoalGov/config.yml'' under ''market''. Admins can tune base prices, target stock, the starting stock for new materials, min/max price movement, the buy/sell spread, and per-material dynamic overrides under ''market.dynamic.materials''. After editing market config, run: diff --git a/coalgov-wiki-pages/market.txt b/coalgov-wiki-pages/market.txt index 3056be6..4266274 100644 --- a/coalgov-wiki-pages/market.txt +++ b/coalgov-wiki-pages/market.txt @@ -26,7 +26,11 @@ Every traded material has a base price in config. The live price is adjusted by * If stock is low, the multiplier rises and items become more expensive. * If stock is high, the multiplier falls and items become cheaper. + * Recent net buying raises prices for a while. + * Recent net selling lowers prices for a while. + * Recent buy/sell pressure decays over time. * The config has minimum and maximum multipliers to prevent runaway prices. + * Materials can override the global target stock and multipliers under ''market.dynamic.materials''. * The buy price is higher than the sell price because the market has a spread. This keeps the economy simple while still reacting to player behavior. If everyone sells cobblestone, cobblestone becomes less valuable. If everyone buys out iron, iron becomes more expensive until players sell more into the market. @@ -41,10 +45,25 @@ Food items start with Ministry stock 0 by default, so players must sell food int dynamic: initial_stock: 1024 target_stock: 1024 + elasticity: 1.0 + stock_floor_ratio: 0.05 min_multiplier: 0.35 max_multiplier: 3.0 sell_multiplier: 0.85 buy_multiplier: 1.15 + flow_half_life_minutes: 180 + flow_weight: 0.5 + flow_min_multiplier: 0.70 + flow_max_multiplier: 1.75 + materials: + COBBLESTONE: + target_stock: 4096 + elasticity: 1.0 + min_multiplier: 0.20 + max_multiplier: 10.0 + flow_weight: 1.25 + flow_min_multiplier: 0.50 + flow_max_multiplier: 2.25 initial_stock: BREAD: 0 COOKED_BEEF: 0