Add market feedback pricing

This commit is contained in:
CoalGov Deploy
2026-05-06 01:01:01 +00:00
parent 8b5ff1f92f
commit b88e3074fd
8 changed files with 223 additions and 30 deletions

View File

@@ -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."));

View File

@@ -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;

View File

@@ -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<String> lore) {

View File

@@ -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,

View File

@@ -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) {
}
}

View File

@@ -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

View File

@@ -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:

View File

@@ -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