Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
<maven.compiler.proc>full</maven.compiler.proc>
<argLine></argLine>
<cooking.version>0.2.1</cooking.version>
<denareconomy.version>0.2.0</denareconomy.version>
<tlibs.version>2.0.0</tlibs.version>
<denareconomy.version>0.3.0</denareconomy.version>
<tlibs.version>2.4.0</tlibs.version>
</properties>

<dependencyManagement>
Expand Down Expand Up @@ -92,6 +92,13 @@
<version>5.14.0</version>
<scope>test</scope>
</dependency>
<dependency>
<!-- Paper bundles the SQLite driver that DenarEconomy's ledger uses. -->
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.49.1.0</version>
<scope>test</scope>
</dependency>
</dependencies>

<repositories>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package net.tfminecraft.marketblock.manager;

import java.util.Optional;
import java.util.Map;
import java.util.WeakHashMap;

import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
Expand All @@ -19,22 +23,26 @@

import net.tfminecraft.marketblock.Cache;
import net.tfminecraft.marketblock.MarketBlock;
import net.tfminecraft.denareconomy.DenarEconomy;
import net.tfminecraft.denareconomy.api.Denars;
import net.tfminecraft.denareconomy.api.Result;
import net.tfminecraft.marketblock.events.MarketSaleEvent;
import net.tfminecraft.marketblock.inventory.holder.MBGUI;
import net.tfminecraft.marketblock.inventory.holder.MBHolder;
import net.tfminecraft.marketblock.loader.CategoryLoader;
import net.tfminecraft.marketblock.loader.TradeLoader;
import net.tfminecraft.marketblock.trade.Category;
import net.tfminecraft.marketblock.trade.MarketSale;
import net.tfminecraft.marketblock.trade.Trade;
import net.tfminecraft.marketblock.util.DemandSchedule;
import net.tfminecraft.marketblock.util.InventoryUtils;
import net.tfminecraft.marketblock.util.PriceCalculator;
import net.tfminecraft.marketblock.util.SaleTake;
import net.tfminecraft.tlibs.character.CharacterRef;

public class TradeManager implements Listener {
InventoryManager inv = new InventoryManager();
private BukkitTask demandTask;
private final Map<InventoryClickEvent, Boolean> handledSales = new WeakHashMap<>();

public void update() {
for(Player p : Bukkit.getOnlinePlayers()) {
Expand Down Expand Up @@ -131,6 +139,7 @@ public void tradeClick(Player p, MBHolder h, ItemStack i, InventoryClickEvent e)
return;
}

if (e != null && handledSales.put(e, true) != null) return;
String id = getItemId(h.getType(), i);
if (id == null) return;

Expand All @@ -147,16 +156,36 @@ public void tradeClick(Player p, MBHolder h, ItemStack i, InventoryClickEvent e)
p.sendMessage("§cYou don't have enough items for this trade.");
return;
}
SaleTake take = InventoryUtils.removeItems(p, tradePath, requiredAmount);
if (take.total() == 0) {
Optional<CharacterRef> seller = Denars.active(p);
if (seller.isEmpty()) {
p.sendMessage("§cChoose a character before you sell.");
return;
}
SaleTake take = InventoryUtils.planTake(p, tradePath, requiredAmount);
if (take.total() != requiredAmount) {
p.sendMessage("§cThe market could not take the items for this trade.");
return;
}
p.playSound(p.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1f, 1f);
double base = PriceCalculator.calculatePrice(trade);
double price = applyFreshness(base, take);
DenarEconomy.getMoneyManager().addMoney(p, price, false, true);
long cents = Math.round(price * 100);
long tax = Denars.taxFor(seller.get(), cents);
try {
if (new MarketSale(MarketBlock.plugin.getDataFolder().toPath().resolve("pending-sales"),
seller.get(), take, cents).settle() != Result.APPLIED) {
p.sendMessage("§cThe market could not pay you, so it kept nothing.");
return;
}
} catch (Exception failure) {
MarketBlock.plugin.getLogger().warning("Sale needs review for " + p.getUniqueId() + ": " + failure);
p.sendMessage("§cThis sale needs staff review before you sell again.");
return;
}
p.playSound(p.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1f, 1f);
sendSaleBreakdown(p, take, (int) requiredAmount, base, price);
if (tax > 0) {
p.sendMessage("§7 Tax: " + formatMoney(tax / 100.0) + "d");
}
trade.sell();
Bukkit.getPluginManager().callEvent(new MarketSaleEvent(p, trade, price, requiredAmount));
update();
Expand Down
87 changes: 87 additions & 0 deletions src/main/java/net/tfminecraft/marketblock/trade/MarketSale.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package net.tfminecraft.marketblock.trade;

import java.io.IOException;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Properties;
import java.util.UUID;

import net.tfminecraft.denareconomy.api.Denars;
import net.tfminecraft.denareconomy.api.Result;
import net.tfminecraft.marketblock.util.SaleTake;
import net.tfminecraft.tlibs.character.CharacterRef;

/**
* One sale to the market. Its operation ID is fixed when the sale is planned, so settling it again
* returns the ledger's first result instead of paying twice, and the goods are taken once, only
* after the payment is applied.
*/
public final class MarketSale {
private final String opId;
private final CharacterRef seller;
private final SaleTake take;
private final long cents;
private final Path intent;
private boolean recorded;
private boolean taken;

public MarketSale(Path folder, CharacterRef seller, SaleTake take, long cents) {
this.opId = "market:" + UUID.randomUUID();
this.seller = seller;
this.take = take;
this.cents = cents;
this.intent = folder.resolve(seller.ownerId() + ".properties");
}

public String opId() {
return opId;
}

/** Pays the seller's pouch from the mint, taxed, then takes the goods if the payment applied. */
public Result settle() throws IOException {
if (taken) return Result.APPLIED;
if (!recorded && !Denars.taxReady()) return Result.UNAVAILABLE;
if (!recorded) record();
Result result = Denars.status(opId).orElseGet(
() -> Denars.earn(opId, seller, Denars.pouch(seller), cents, true));
if (result == Result.APPLIED) {
take.apply();
taken = true;
}
if (result == Result.APPLIED || result == Result.REFUSED) {
Files.deleteIfExists(intent);
syncDirectory();
}
return result;
}

private void record() throws IOException {
Files.createDirectories(intent.getParent());
try (FileChannel parent = FileChannel.open(intent.getParent().getParent(), StandardOpenOption.READ)) {
parent.force(true);
}
Properties data = new Properties();
data.setProperty("operation", opId);
data.setProperty("owner", seller.ownerId().toString());
data.setProperty("character", seller.characterId().toString());
data.setProperty("grossCents", Long.toString(cents));
data.setProperty("items", take.description());
// CREATE_NEW also blocks another payment when an interrupted sale needs reconciliation.
try (FileChannel channel = FileChannel.open(intent,
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
data.store(Channels.newOutputStream(channel), "Pending market sale; reconcile before removing");
channel.force(true);
}
syncDirectory();
recorded = true;
}

private void syncDirectory() throws IOException {
try (FileChannel directory = FileChannel.open(intent.getParent(), StandardOpenOption.READ)) {
directory.force(true);
}
}
}
20 changes: 10 additions & 10 deletions src/main/java/net/tfminecraft/marketblock/util/InventoryUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ public static boolean hasEnough(Player p, String path, double requiredAmount) {
&& getTotalAmount(p, path) >= requiredAmount;
}

public static SaleTake removeItems(Player p, String path, double amountToRemove) {
/**
* Plans which matching stacks a sale would take, without changing the inventory. Call
* {@link SaleTake#apply()} to take them.
*/
public static SaleTake planTake(Player p, String path, double amountToRemove) {
SaleTake take = new SaleTake();
if (!Double.isFinite(amountToRemove) || amountToRemove <= 0
|| amountToRemove > Integer.MAX_VALUE
Expand All @@ -35,22 +39,18 @@ public static SaleTake removeItems(Player p, String path, double amountToRemove)
}
double remaining = amountToRemove;

for (ItemStack item : p.getInventory().getContents()) {
ItemStack[] contents = p.getInventory().getContents();
for (int slot = 0; slot < contents.length; slot++) {
ItemStack item = contents[slot];
if (item == null || item.getType().isAir()) continue;

if (!TLibs.getItemAPI().getChecker().checkItemWithPath(item, path)) continue;

int stackAmount = item.getAmount();
int taken = stackAmount <= remaining ? stackAmount : (int) remaining;
take.add(FreshnessLookup.stepId(item), taken);

if (stackAmount <= remaining) {
remaining -= stackAmount;
item.setAmount(0);
} else {
item.setAmount(stackAmount - taken);
remaining = 0;
}
take.plan(p.getInventory(), slot, taken);
remaining -= taken;

if (remaining <= 0) break;
}
Expand Down
30 changes: 30 additions & 0 deletions src/main/java/net/tfminecraft/marketblock/util/SaleTake.java
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
package net.tfminecraft.marketblock.util;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;

import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;

/** The goods a sale takes: counts per freshness step and the planned amount from each stack. */
public class SaleTake {
private record Part(PlayerInventory inventory, int slot, ItemStack stack, int amount) {
}

private final Map<String, Integer> counts = new LinkedHashMap<>();
private final List<Part> parts = new ArrayList<>();

public void add(String stepId, int amount) {
if (amount <= 0) {
Expand All @@ -15,6 +25,26 @@ public void add(String stepId, int amount) {
counts.merge(key, amount, Integer::sum);
}

void plan(PlayerInventory inventory, int slot, int amount) {
parts.add(new Part(inventory, slot, inventory.getItem(slot).clone(), amount));
}

/** Takes the planned amounts from their stacks. */
public void apply() {
for (Part part : parts) {
if (!part.stack().equals(part.inventory().getItem(part.slot()))) {
throw new IllegalStateException("Paid market goods changed before removal");
}
}
for (Part part : parts) {
part.inventory().getItem(part.slot()).setAmount(part.stack().getAmount() - part.amount());
}
}

public String description() {
return parts.stream().map(part -> "slot " + part.slot() + ": " + part.amount() + " x " + part.stack()).toList().toString();
}

public Map<String, Integer> getCounts() {
return counts;
}
Expand Down
100 changes: 100 additions & 0 deletions src/test/java/net/tfminecraft/marketblock/TestMoney.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package net.tfminecraft.marketblock;

import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Logger;
import net.tfminecraft.denareconomy.DenarEconomy;
import net.tfminecraft.denareconomy.api.TaxLeg;
import net.tfminecraft.denareconomy.api.TaxPolicy;
import net.tfminecraft.denareconomy.ledger.AccountIds;
import net.tfminecraft.denareconomy.ledger.Ledger;
import net.tfminecraft.tlibs.character.CharacterIdentity;
import net.tfminecraft.tlibs.character.CharacterInfo;
import net.tfminecraft.tlibs.character.CharacterRef;
import net.tfminecraft.tlibs.character.CharacterSession;
import net.tfminecraft.tlibs.character.CharacterStatus;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.plugin.ServicePriority;
import org.mockbukkit.mockbukkit.MockBukkit;

/** A real DenarEconomy ledger in a temporary folder and a character provider for tests. */
public final class TestMoney implements CharacterIdentity {
private final Map<UUID, CharacterSession> sessions = new HashMap<>();
private final Map<UUID, CharacterInfo> characters = new HashMap<>();
private long nextSession;

/** Opens the ledger and registers the provider with the running MockBukkit server. */
public static TestMoney open(Path folder) {
DenarEconomy.setLedger(Ledger.open(folder.resolve("ledger.db").toFile(),
Logger.getLogger("MarketBlockTest"), AccountIds::frozen));
TestMoney money = new TestMoney();
Bukkit.getServicesManager().register(CharacterIdentity.class, money,
MockBukkit.createMockPlugin(), ServicePriority.Normal);
return money;
}

/** Closes the ledger. Call before MockBukkit.unmock(). */
public static void close() {
DenarEconomy.getLedger().close();
DenarEconomy.setLedger(Ledger.unavailable());
}

/** Taxes every earning at this percentage into a guild's holding account. */
public static String taxAt(int percent) {
String account = AccountIds.guildTax(UUID.randomUUID());
TaxPolicy policy = (earner, gross) -> Optional.of(new TaxLeg(account, gross * percent / 100));
Bukkit.getServicesManager().register(TaxPolicy.class, policy,
MockBukkit.createMockPlugin(), ServicePriority.Normal);
return account;
}

/** Gives the player a new living character and makes it current. */
public CharacterRef play(Player player) {
CharacterRef ref = new CharacterRef(player.getUniqueId(), UUID.randomUUID());
characters.put(ref.characterId(), new CharacterInfo(ref, "Char", CharacterStatus.ALIVE, null,
Set.of(), 0, null));
sessions.put(player.getUniqueId(), new CharacterSession(ref, ++nextSession));
return ref;
}

public void stop(Player player) {
sessions.remove(player.getUniqueId());
}

@Override
public Optional<CharacterSession> currentSession(Player player) {
return Optional.ofNullable(sessions.get(player.getUniqueId()));
}

@Override
public boolean isCurrent(CharacterSession session) {
return sessions.containsValue(session);
}

@Override
public Optional<CharacterInfo> info(UUID characterId) {
return Optional.ofNullable(characters.get(characterId));
}

@Override
public Optional<List<CharacterInfo>> characters(UUID ownerId) {
return Optional.of(characters.values().stream()
.filter(c -> c.ref().ownerId().equals(ownerId)).toList());
}

@Override
public Optional<CharacterInfo> lastPlayed(UUID ownerId) {
return Optional.empty();
}

@Override
public String displayName(Player player) {
return player.getName();
}
}
Loading