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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ BarterShops turns a sign and a stocked chest into a place to trade. Players can
- **Owner-set trades** — choose the quantity and price for each transaction through an in-game creation flow.
- **Denar payments** — connect customer spending and shop-owner bank balances through DenarEconomy.
- **Diplomatic consequences** — respect trade embargoes between nations when SimpleFactions is available.
- **Shops pass on at death** — a dead owner's shop closes, then passes with the estate to the will heir or the guild leader.

## Part of the economy

Expand All @@ -27,7 +28,8 @@ Technical documentation is maintained in [TF-Minecraft/Docs](https://github.com/
With Java 21 and the [build dependencies](https://github.com/TF-Minecraft/Docs/blob/main/projects/BarterShops/README.md#build-and-dependencies) prepared,
run `mvn clean verify`.

JUnit 5 and Mockito cover sign creation, trades, storage and embargo handling.
JUnit 5 and Mockito cover sign creation, trades, storage, embargo handling,
estate transfers and the staff trade command.
Surefire writes results to `target/surefire-reports/`, which the Build workflow
uploads. No coverage report or threshold is configured. The suite runs without
a live Minecraft server or the real economy and faction plugins.
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<maven.compiler.proc>full</maven.compiler.proc>
<denareconomy.version>0.3.0</denareconomy.version>
<simplefactions.version>4.0.0</simplefactions.version>
<tlibs.version>2.4.0</tlibs.version>
<tlibs.version>2.6.0</tlibs.version>
</properties>

<dependencies>
Expand Down
78 changes: 78 additions & 0 deletions src/main/java/net/tfminecraft/bartershops/Database.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStreamReader;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.UUID;
import java.util.logging.Logger;

import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
Expand All @@ -20,13 +27,18 @@
import com.google.gson.GsonBuilder;
import com.google.gson.ToNumberPolicy;

import net.tfminecraft.tlibs.character.CharacterRef;

public class Database {
static final Gson SHOP_READER = new GsonBuilder()
.setObjectToNumberStrategy(ToNumberPolicy.BIG_DECIMAL).create();
// The owner's account; written before shops belonged to characters, so every shop has it.
static final String OWNER = "owner";
static final String OWNER_CHARACTER = "owner character";
private final File dataFolder;
Logger logger = Logger.getLogger("BarterShops");
// Unreadable shop files already reported, so the hourly estate pass warns once per file.
private final Set<String> unreadable = new HashSet<>();

public Database() {
this(new File("plugins/BarterShops/Data"));
Expand Down Expand Up @@ -81,6 +93,72 @@ static String readCharacter(Object value) {
}
}

/** One shop file's owner and sign, as estates need them. */
public record ShopRecord(String shopId, String ownerCharacter, String type, String world, double x, double y, double z) {
String label() {
return (type == null || type.isEmpty() ? "Shop" : type.substring(0, 1).toUpperCase() + type.substring(1) + " shop")
+ " at " + world + " " + (int) Math.floor(x) + ", " + (int) Math.floor(y) + ", " + (int) Math.floor(z);
}
}

/** Every readable shop file. Unreadable files are skipped, as the sign lookups skip them. */
public List<ShopRecord> shops() {
List<ShopRecord> shops = new ArrayList<>();
for (File file : shopFiles()) {
try {
Map<?, ?> shopJson = read(file);
shops.add(new ShopRecord(shopId(file), readCharacter(shopJson.get(OWNER_CHARACTER)),
shopJson.get("type") instanceof String type ? type : null, String.valueOf(shopJson.get("sign world")),
number(shopJson.get("sign xPos")), number(shopJson.get("sign yPos")), number(shopJson.get("sign zPos"))));
} catch (IOException ex) {
if (unreadable.add(file.getName()))
logger.warning("Skipping unreadable shop file " + file.getName() + ": " + ex.getMessage());
}
}
return shops;
}

/** The owning character of one shop; empty when the shop is gone or has no character. */
public Optional<String> ownerCharacter(String shopId) throws IOException {
File file = shopFile(shopId);
if (!file.isFile()) return Optional.empty();
return Optional.ofNullable(readCharacter(read(file).get(OWNER_CHARACTER)));
}

/**
* Gives one shop to another character, rewriting its file in place and keeping every other value.
* False when the shop is gone.
*/
public boolean setOwner(String shopId, CharacterRef owner) throws IOException {
File file = shopFile(shopId);
if (!file.isFile()) return false;
Map<String, Object> shopJson = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
read(file).forEach((key, value) -> shopJson.put(String.valueOf(key), value));
shopJson.put(OWNER, owner.ownerId().toString());
shopJson.put(OWNER_CHARACTER, owner.characterId().toString());
DurableFiles.write(file.toPath(), new GsonBuilder().setPrettyPrinting().create().toJson(shopJson));
return true;
}

// Shop IDs are file names, so only a UUID may name one.
private File shopFile(String shopId) {
return new File(dataFolder, UUID.fromString(shopId) + ".json");
}

private static Map<?, ?> read(File file) throws IOException {
try (InputStreamReader reader = new InputStreamReader(new FileInputStream(file), "UTF-8")) {
Map<?, ?> shopJson = SHOP_READER.fromJson(reader, Map.class);
if (shopJson == null) throw new IOException("Empty shop file " + file);
return shopJson;
} catch (RuntimeException ex) {
throw new IOException("Unreadable shop file " + file, ex);
}
}

private static double number(Object value) {
return value instanceof Number number ? number.doubleValue() : 0;
}

// The data folder only exists once a shop has been saved.
private File[] shopFiles() {
File[] files = dataFolder.listFiles((folder, name) -> name.endsWith(".json"));
Expand Down
23 changes: 23 additions & 0 deletions src/main/java/net/tfminecraft/bartershops/EstateProbe.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package net.tfminecraft.bartershops;

/** Whether TLibs has the estate API (2.6.0). Older TLibs leaves shops with dead owners. */
final class EstateProbe {
static final String[] CLASSES = {
"net.tfminecraft.tlibs.character.estate.Estates",
"net.tfminecraft.tlibs.character.estate.EstateParticipant",
"net.tfminecraft.tlibs.character.estate.EstateDecision",
"net.tfminecraft.tlibs.character.estate.Outcome",
"net.tfminecraft.tlibs.character.estate.Holdings",
};

private EstateProbe() {}

static boolean available(String... classes) {
try {
for (String name : classes) Class.forName(name);
return true;
} catch (ReflectiveOperationException | LinkageError ex) {
return false;
}
}
}
143 changes: 143 additions & 0 deletions src/main/java/net/tfminecraft/bartershops/IntentCommand.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package net.tfminecraft.bartershops;

import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.logging.Logger;

import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;

import net.tfminecraft.denareconomy.api.Denars;
import net.tfminecraft.denareconomy.api.Result;

/**
* {@code /bartershops intent <shopId> [clear [--confirm]]}: staff inspect and clear the unfinished
* trade that keeps a shop closed. Only a prepared trade that moved no money clears without
* {@code --confirm}; every other state may already have moved money or items, so staff settle it by
* hand first. Nothing clears while the ledger is not ready.
*/
final class IntentCommand implements CommandExecutor, TabCompleter {
static final String USAGE = "§eUsage: /bartershops intent <shopId> [clear [--confirm]]";
private static final String UNREADABLE = "unreadable";

enum Clear { YES, CONFIRM, LEDGER }

private final ShopTrades trades;
private final Path auditFile;
private final Logger logger;

IntentCommand(ShopTrades trades, Path auditFile, Logger logger) {
this.trades = trades;
this.auditFile = auditFile;
this.logger = logger;
}

/** Whether a trade may be cleared: the table in the BarterShops guide. */
static Clear clearable(String state, Optional<Result> posting, boolean ledgerReady) {
if (!ledgerReady) return Clear.LEDGER;
boolean noMoney = posting.isEmpty() || posting.get() == Result.REFUSED;
return "prepared".equals(state) && noMoney ? Clear.YES : Clear.CONFIRM;
}

@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!sender.hasPermission(ShopEvents.ADMIN_PERMISSION)) {
sender.sendMessage("§cYou do not have permission to use this command.");
return true;
}
boolean clear = args.length >= 3 && args[2].equalsIgnoreCase("clear");
boolean confirm = clear && args.length == 4 && args[3].equalsIgnoreCase("--confirm");
if (args.length < 2 || !args[0].equalsIgnoreCase("intent") || (args.length >= 3 && !clear)
|| (args.length == 4 && !confirm) || args.length > 4) {
sender.sendMessage(USAGE);
return true;
}
String shopId;
try {
shopId = UUID.fromString(args[1]).toString();
} catch (IllegalArgumentException ex) {
sender.sendMessage("§cA shop ID is a UUID, the name of its file in Data/.");
return true;
}
ShopTrades.Intent intent;
try {
Optional<ShopTrades.Intent> found = trades.inspect(shopId);
if (found.isEmpty()) {
sender.sendMessage("§aShop " + shopId + " has no unfinished trade.");
return true;
}
intent = found.get();
} catch (IOException ex) {
intent = new ShopTrades.Intent(null, null, UNREADABLE);
sender.sendMessage("§cThe trade record of shop " + shopId + " cannot be read: " + ex.getMessage());
}
boolean ledgerReady = Denars.ready();
Optional<Result> posting = ledgerReady && intent.operationId() != null
? Denars.status(intent.operationId()) : Optional.empty();
String postingText = !ledgerReady ? "unknown (ledger not ready)"
: intent.operationId() == null ? "unknown" : posting.map(Result::name).orElse("absent");
String summary = "shop " + shopId + " state " + intent.state() + ", posting " + postingText
+ (intent.operationId() == null ? "" : ", operation " + intent.operationId())
+ (intent.request() == null ? "" : ", " + describe(intent.request()));
Clear verdict = clearable(intent.state(), posting, ledgerReady);
if (!clear) {
show(sender, shopId, intent, postingText, verdict);
ShopAudit.record(auditFile, logger, sender.getName(), "viewed the trade at " + summary);
return true;
}
if (verdict == Clear.LEDGER) {
sender.sendMessage("§cThe ledger is not ready, so nothing was cleared. Try again once DenarEconomy is up.");
return true;
}
if (verdict == Clear.CONFIRM && !confirm) {
show(sender, shopId, intent, postingText, verdict);
return true;
}
try {
trades.clear(shopId);
} catch (IOException ex) {
sender.sendMessage("§cCould not remove the trade record: " + ex.getMessage());
return true;
}
ShopAudit.record(auditFile, logger, sender.getName(),
"cleared the trade at " + summary + (confirm ? " (confirmed)" : ""));
sender.sendMessage("§aCleared the trade at shop " + shopId + "; it trades again.");
sender.sendMessage("§7If its owner is dead, run /estate retry <deathId> bartershops.");
return true;
}

private static void show(CommandSender sender, String shopId, ShopTrades.Intent intent, String posting, Clear verdict) {
sender.sendMessage("§eShop " + shopId + ": unfinished trade, state " + intent.state() + ", posting " + posting);
if (intent.operationId() != null) sender.sendMessage("§7Operation " + intent.operationId());
if (intent.request() != null) sender.sendMessage("§7" + describe(intent.request()));
String command = "/bartershops intent " + shopId + " clear";
sender.sendMessage(switch (verdict) {
case YES -> "§aNo money moved. " + command + " removes it.";
case LEDGER -> "§cThe ledger is not ready; nothing can be cleared yet.";
case CONFIRM -> "prepared".equals(intent.state())
? "§cThe payment applied but the items did not move. Hand over the items, then " + command + " --confirm."
: "§cThe items may already have moved. Check the chest and the customer, put things right, then "
+ command + " --confirm.";
});
}

private static String describe(ShopTrades.Request request) {
return request.direction() + " of " + request.quantity() + " for " + request.cents() + " cents, customer "
+ (request.customer() == null ? "?" : request.customer().characterId()) + ", owner "
+ (request.owner() == null ? "?" : request.owner().characterId());
}

@Override
public List<String> onTabComplete(CommandSender sender, Command command, String label, String[] args) {
if (!sender.hasPermission(ShopEvents.ADMIN_PERMISSION)) return List.of();
if (args.length == 1) return List.of("intent");
if (args.length == 3) return List.of("clear");
if (args.length == 4 && args[2].equalsIgnoreCase("clear")) return List.of("--confirm");
return List.of();
}
}
25 changes: 25 additions & 0 deletions src/main/java/net/tfminecraft/bartershops/ShopAudit.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package net.tfminecraft.bartershops;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.util.logging.Logger;

/** Staff shop commands go to the server log and to {@code plugins/BarterShops/logs/staff-audit.log}. */
final class ShopAudit {
private ShopAudit() {}

static void record(Path file, Logger log, String actor, String action) {
log.info("[Shop audit] " + actor + " " + action);
try {
Files.createDirectories(file.toAbsolutePath().getParent());
Files.writeString(file, Instant.now() + " " + actor + " " + action + System.lineSeparator(),
StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (IOException ex) {
log.warning("Could not write the shop audit log: " + ex.getMessage());
}
}
}
Loading