feat(quests): add quest creation and reward escrow
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
interface BlockMaterialCatalog {
|
||||
Optional<String> normalizeBlock(String input);
|
||||
List<String> suggest(String prefix);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import org.bukkit.Material;
|
||||
|
||||
final class BukkitBlockMaterialCatalog implements BlockMaterialCatalog {
|
||||
@Override
|
||||
public Optional<String> normalizeBlock(String input) {
|
||||
Material material = Material.matchMaterial(input == null ? "" : input);
|
||||
if (material == null || material.isAir() || !material.isBlock()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(material.name());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> suggest(String prefix) {
|
||||
String normalizedPrefix = prefix.toUpperCase(Locale.ROOT);
|
||||
return Arrays.stream(Material.values())
|
||||
.filter(material -> material.isBlock() && !material.isAir())
|
||||
.map(Material::name)
|
||||
.filter(name -> name.startsWith(normalizedPrefix))
|
||||
.sorted()
|
||||
.limit(100)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
|
||||
final class BukkitHeldRewardInventory implements HeldRewardInventory {
|
||||
@Override
|
||||
public RemovedReward remove(Player player) {
|
||||
PlayerInventory inventory = player.getInventory();
|
||||
ItemStack held = inventory.getItemInMainHand();
|
||||
if (held.getType().isAir() || held.getAmount() <= 0) {
|
||||
throw new IllegalArgumentException("Hold the reward stack in your main hand");
|
||||
}
|
||||
int slot = inventory.getHeldItemSlot();
|
||||
ItemStack snapshot = held.clone();
|
||||
EscrowItem escrow = EscrowItem.fromItemStack(snapshot);
|
||||
inventory.clear(slot);
|
||||
return new RemovedReward() {
|
||||
private boolean rolledBack;
|
||||
|
||||
@Override
|
||||
public EscrowItem item() {
|
||||
return escrow;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void rollback() {
|
||||
if (!rolledBack) {
|
||||
inventory.setItem(slot, snapshot);
|
||||
rolledBack = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.Base64;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
record EscrowItem(String material, int amount, String serializedItem) {
|
||||
EscrowItem {
|
||||
Objects.requireNonNull(material, "material");
|
||||
material = material.trim().toUpperCase(java.util.Locale.ROOT);
|
||||
if (material.isEmpty() || "AIR".equals(material)) {
|
||||
throw new IllegalArgumentException("Invalid escrow material: " + material);
|
||||
}
|
||||
if (amount <= 0) {
|
||||
throw new IllegalArgumentException("Escrow amount must be positive");
|
||||
}
|
||||
if (serializedItem != null && serializedItem.isBlank()) {
|
||||
throw new IllegalArgumentException("Serialized item must not be blank");
|
||||
}
|
||||
}
|
||||
|
||||
static EscrowItem fromItemStack(ItemStack stack) {
|
||||
Objects.requireNonNull(stack, "stack");
|
||||
if (stack.getType().isAir() || stack.getAmount() <= 0) {
|
||||
throw new IllegalArgumentException("Reward stack must not be empty");
|
||||
}
|
||||
ItemStack snapshot = stack.clone();
|
||||
return new EscrowItem(
|
||||
snapshot.getType().name(), snapshot.getAmount(),
|
||||
Base64.getEncoder().encodeToString(snapshot.serializeAsBytes())
|
||||
);
|
||||
}
|
||||
|
||||
ItemStack toItemStack() {
|
||||
if (serializedItem == null) {
|
||||
return new ItemStack(Objects.requireNonNull(Material.matchMaterial(material)), amount);
|
||||
}
|
||||
final ItemStack stack;
|
||||
try {
|
||||
stack = ItemStack.deserializeBytes(Base64.getDecoder().decode(serializedItem));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IllegalStateException("Invalid serialized escrow item", exception);
|
||||
}
|
||||
if (!stack.getType().name().equals(material) || stack.getAmount() != amount) {
|
||||
throw new IllegalStateException("Serialized escrow item does not match its envelope");
|
||||
}
|
||||
return stack;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
interface HeldRewardInventory {
|
||||
RemovedReward remove(Player player);
|
||||
|
||||
interface RemovedReward {
|
||||
EscrowItem item();
|
||||
void rollback();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
record Quest(
|
||||
UUID id,
|
||||
UUID issuerId,
|
||||
String issuerName,
|
||||
String requestedMaterial,
|
||||
int requestedAmount,
|
||||
List<EscrowItem> reward,
|
||||
Instant createdAt,
|
||||
Instant expiresAt
|
||||
) {
|
||||
Quest {
|
||||
Objects.requireNonNull(id, "id");
|
||||
Objects.requireNonNull(issuerId, "issuerId");
|
||||
if (Objects.requireNonNull(issuerName, "issuerName").isBlank()) {
|
||||
throw new IllegalArgumentException("Issuer name must not be blank");
|
||||
}
|
||||
if (Objects.requireNonNull(requestedMaterial, "requestedMaterial").isBlank()) {
|
||||
throw new IllegalArgumentException("Requested material must not be blank");
|
||||
}
|
||||
if (requestedAmount <= 0) {
|
||||
throw new IllegalArgumentException("Requested amount must be positive");
|
||||
}
|
||||
reward = List.copyOf(Objects.requireNonNull(reward, "reward"));
|
||||
if (reward.isEmpty()) {
|
||||
throw new IllegalArgumentException("Reward must not be empty");
|
||||
}
|
||||
Objects.requireNonNull(createdAt, "createdAt");
|
||||
Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
if (!expiresAt.isAfter(createdAt)) {
|
||||
throw new IllegalArgumentException("Expiration must follow creation");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,88 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import io.papermc.paper.dialog.Dialog;
|
||||
import io.papermc.paper.registry.data.dialog.ActionButton;
|
||||
import io.papermc.paper.registry.data.dialog.DialogBase;
|
||||
import io.papermc.paper.registry.data.dialog.action.DialogAction;
|
||||
import io.papermc.paper.registry.data.dialog.body.DialogBody;
|
||||
import io.papermc.paper.registry.data.dialog.input.DialogInput;
|
||||
import io.papermc.paper.registry.data.dialog.type.DialogType;
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.event.ClickCallback;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
private final QuestCreationGateway creator;
|
||||
private final Clock clock;
|
||||
|
||||
QuestBoardDialogUi(QuestCreationGateway creator, Clock clock) {
|
||||
this.creator = Objects.requireNonNull(creator, "creator");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void open(Player player) {
|
||||
DialogBase base = DialogBase.builder(Component.text("Quest Board"))
|
||||
.externalTitle(Component.text("Quest Board"))
|
||||
.body(List.of(DialogBody.plainMessage(
|
||||
Component.text("No active quests or claimable items."), 420
|
||||
)))
|
||||
ActionButton create = ActionButton.builder(Component.text("Create quest"))
|
||||
.tooltip(Component.text("Escrow your held stack and publish this quest"))
|
||||
.width(150)
|
||||
.action(DialogAction.customClick((response, audience) -> {
|
||||
if (audience instanceof Player respondingPlayer) {
|
||||
submit(
|
||||
respondingPlayer,
|
||||
response.getText("requested_material"),
|
||||
response.getText("requested_quantity")
|
||||
);
|
||||
}
|
||||
}, ClickCallback.Options.builder()
|
||||
.uses(1)
|
||||
.lifetime(Duration.ofMinutes(10))
|
||||
.build()))
|
||||
.build();
|
||||
DialogBase base = DialogBase.builder(Component.text("Create a block-delivery quest"))
|
||||
.externalTitle(Component.text("Quest Board — Create"))
|
||||
.body(List.of(DialogBody.plainMessage(Component.text(
|
||||
"Hold the reward in your main hand. The entire exact stack, including all item metadata, "
|
||||
+ "will be removed and held in escrow only if this quest saves successfully."
|
||||
), 420)))
|
||||
.inputs(List.of(
|
||||
DialogInput.text("requested_material", Component.text("Requested block"))
|
||||
.initial("")
|
||||
.maxLength(64)
|
||||
.build(),
|
||||
DialogInput.text("requested_quantity", Component.text("Quantity"))
|
||||
.initial("64")
|
||||
.maxLength(10)
|
||||
.build()
|
||||
))
|
||||
.canCloseWithEscape(true)
|
||||
.pause(false)
|
||||
.afterAction(DialogBase.DialogAfterAction.CLOSE)
|
||||
.build();
|
||||
Dialog dialog = Dialog.create(factory -> factory.empty()
|
||||
player.showDialog(Dialog.create(factory -> factory.empty()
|
||||
.base(base)
|
||||
.type(DialogType.notice()));
|
||||
player.showDialog(dialog);
|
||||
.type(DialogType.notice(create))));
|
||||
}
|
||||
|
||||
void submit(Player player, String material, String quantityText) {
|
||||
final int quantity;
|
||||
try {
|
||||
quantity = Integer.parseInt(quantityText == null ? "" : quantityText.trim());
|
||||
} catch (NumberFormatException exception) {
|
||||
player.sendMessage("Quest quantity must be a positive whole number.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Quest quest = creator.create(player, material, quantity, clock.instant());
|
||||
player.sendMessage("Quest " + quest.id() + " created. Your exact held stack is now escrowed.");
|
||||
} catch (IllegalArgumentException exception) {
|
||||
player.sendMessage(exception.getMessage());
|
||||
} catch (IOException exception) {
|
||||
player.sendMessage("The quest could not be saved. Your held reward was restored.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabCompleter;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
private static final List<String> QUANTITIES = List.of("1", "16", "32", "64");
|
||||
private final QuestCreationGateway creator;
|
||||
private final Clock clock;
|
||||
|
||||
QuestCommand(QuestCreationGateway creator, Clock clock) {
|
||||
this.creator = Objects.requireNonNull(creator, "creator");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(
|
||||
CommandSender sender, Command command, String label, String[] arguments
|
||||
) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage("Only players can create quests with inventory rewards.");
|
||||
return true;
|
||||
}
|
||||
if (arguments.length != 3 || !"create".equalsIgnoreCase(arguments[0])) {
|
||||
usage(sender);
|
||||
return true;
|
||||
}
|
||||
final int quantity;
|
||||
try {
|
||||
quantity = Integer.parseInt(arguments[2]);
|
||||
} catch (NumberFormatException exception) {
|
||||
sender.sendMessage("Quest quantity must be a positive whole number.");
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
Quest quest = creator.create(player, arguments[1], quantity, clock.instant());
|
||||
sender.sendMessage("Quest " + quest.id() + " created. Your exact held stack is now escrowed.");
|
||||
} catch (IllegalArgumentException exception) {
|
||||
sender.sendMessage(exception.getMessage());
|
||||
} catch (IOException exception) {
|
||||
sender.sendMessage("The quest could not be saved. Your held reward was restored.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> onTabComplete(
|
||||
CommandSender sender, Command command, String alias, String[] arguments
|
||||
) {
|
||||
if (!(sender instanceof Player)) {
|
||||
return List.of();
|
||||
}
|
||||
if (arguments.length == 1) {
|
||||
return startsWith(List.of("create"), arguments[0]);
|
||||
}
|
||||
if (arguments.length == 2 && "create".equalsIgnoreCase(arguments[0])) {
|
||||
return creator.suggestBlockMaterials(arguments[1]);
|
||||
}
|
||||
if (arguments.length == 3 && "create".equalsIgnoreCase(arguments[0])) {
|
||||
return startsWith(QUANTITIES, arguments[2]);
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private static List<String> startsWith(List<String> candidates, String prefix) {
|
||||
String normalized = prefix.toLowerCase(Locale.ROOT);
|
||||
return candidates.stream()
|
||||
.filter(candidate -> candidate.toLowerCase(Locale.ROOT).startsWith(normalized))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static void usage(CommandSender sender) {
|
||||
sender.sendMessage("Usage: /quests create <block> <quantity>");
|
||||
sender.sendMessage("Hold the entire reward stack in your main hand; its exact metadata will be escrowed.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
final class QuestCreationController implements QuestCreationGateway {
|
||||
private final QuestService quests;
|
||||
private final BlockMaterialCatalog materials;
|
||||
private final HeldRewardInventory rewards;
|
||||
|
||||
QuestCreationController(
|
||||
QuestService quests,
|
||||
BlockMaterialCatalog materials,
|
||||
HeldRewardInventory rewards
|
||||
) {
|
||||
this.quests = Objects.requireNonNull(quests, "quests");
|
||||
this.materials = Objects.requireNonNull(materials, "materials");
|
||||
this.rewards = Objects.requireNonNull(rewards, "rewards");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Quest create(
|
||||
Player player, String requestedMaterial, int requestedAmount, Instant createdAt
|
||||
) throws IOException {
|
||||
Objects.requireNonNull(player, "player");
|
||||
if (requestedAmount <= 0) {
|
||||
throw new IllegalArgumentException("Requested quantity must be positive");
|
||||
}
|
||||
String material = materials.normalizeBlock(requestedMaterial)
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Requested material must be a valid block"
|
||||
));
|
||||
HeldRewardInventory.RemovedReward removed = rewards.remove(player);
|
||||
try {
|
||||
return quests.create(
|
||||
player.getUniqueId(), player.getName(), material, requestedAmount,
|
||||
List.of(removed.item()), createdAt
|
||||
);
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
removed.rollback();
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> suggestBlockMaterials(String prefix) {
|
||||
return materials.suggest(prefix == null ? "" : prefix);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
interface QuestCreationGateway {
|
||||
Quest create(Player player, String requestedMaterial, int requestedAmount, Instant createdAt)
|
||||
throws IOException;
|
||||
|
||||
List<String> suggestBlockMaterials(String prefix);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
interface QuestRepository {
|
||||
QuestState load() throws IOException;
|
||||
void save(QuestState state) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
final class QuestService {
|
||||
private final QuestRepository repository;
|
||||
private Map<UUID, Quest> quests;
|
||||
|
||||
QuestService(QuestRepository repository) throws IOException {
|
||||
this.repository = Objects.requireNonNull(repository, "repository");
|
||||
quests = new LinkedHashMap<>(repository.load().quests());
|
||||
}
|
||||
|
||||
synchronized Quest create(
|
||||
UUID issuerId,
|
||||
String issuerName,
|
||||
String requestedMaterial,
|
||||
int requestedAmount,
|
||||
List<EscrowItem> reward,
|
||||
Instant createdAt
|
||||
) throws IOException {
|
||||
Objects.requireNonNull(issuerId, "issuerId");
|
||||
Objects.requireNonNull(createdAt, "createdAt");
|
||||
String material = Objects.requireNonNull(requestedMaterial, "requestedMaterial")
|
||||
.trim().toUpperCase(Locale.ROOT);
|
||||
if (material.isEmpty() || "AIR".equals(material)) {
|
||||
throw new IllegalArgumentException("Requested material must be a block");
|
||||
}
|
||||
Quest quest = new Quest(
|
||||
UUID.randomUUID(), issuerId, issuerName, material, requestedAmount,
|
||||
reward, createdAt, createdAt.plus(7, ChronoUnit.DAYS)
|
||||
);
|
||||
Map<UUID, Quest> candidate = new LinkedHashMap<>(quests);
|
||||
candidate.put(quest.id(), quest);
|
||||
repository.save(new QuestState(candidate));
|
||||
quests = Map.copyOf(candidate);
|
||||
return quest;
|
||||
}
|
||||
|
||||
synchronized QuestState state() {
|
||||
return new QuestState(quests);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
record QuestState(Map<UUID, Quest> quests) {
|
||||
QuestState {
|
||||
quests = Map.copyOf(Objects.requireNonNull(quests, "quests"));
|
||||
for (Map.Entry<UUID, Quest> entry : quests.entrySet()) {
|
||||
if (!entry.getKey().equals(entry.getValue().id())) {
|
||||
throw new IllegalArgumentException("Quest map key does not match quest id");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static QuestState empty() {
|
||||
return new QuestState(Map.of());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.Objects;
|
||||
import java.util.logging.Level;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
@@ -10,22 +11,39 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin {
|
||||
@Override
|
||||
public void onEnable() {
|
||||
final BoardRegistry boards;
|
||||
final QuestService quests;
|
||||
try {
|
||||
boards = new BoardRegistry(new YamlBoardRepository(
|
||||
getDataFolder().toPath().resolve("boards.yml")
|
||||
));
|
||||
quests = new QuestService(new YamlQuestRepository(
|
||||
getDataFolder().toPath().resolve("quests.yml")
|
||||
));
|
||||
} catch (IOException exception) {
|
||||
getLogger().log(Level.SEVERE, "Could not load quest boards", exception);
|
||||
getLogger().log(Level.SEVERE, "Could not load quest board state", exception);
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
return;
|
||||
}
|
||||
|
||||
QuestAdminCommand admin = new QuestAdminCommand(boards);
|
||||
command("questadmin").setExecutor(admin);
|
||||
|
||||
QuestCreationGateway creator = new QuestCreationController(
|
||||
quests, new BukkitBlockMaterialCatalog(), new BukkitHeldRewardInventory()
|
||||
);
|
||||
QuestCommand questCommand = new QuestCommand(creator, Clock.systemUTC());
|
||||
command("quests").setExecutor(questCommand);
|
||||
command("quests").setTabCompleter(questCommand);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new QuestBoardInteractionListener(boards, new QuestBoardDialogUi()), this
|
||||
new QuestBoardInteractionListener(
|
||||
boards, new QuestBoardDialogUi(creator, Clock.systemUTC())
|
||||
),
|
||||
this
|
||||
);
|
||||
getLogger().info(
|
||||
"Spigot Quest Board enabled with " + boards.size() + " boards and "
|
||||
+ quests.state().quests().size() + " quests."
|
||||
);
|
||||
getLogger().info("Spigot Quest Board enabled with " + boards.size() + " boards.");
|
||||
}
|
||||
|
||||
private PluginCommand command(String name) {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Instant;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.configuration.InvalidConfigurationException;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
|
||||
final class YamlQuestRepository implements QuestRepository {
|
||||
private final Path path;
|
||||
|
||||
YamlQuestRepository(Path path) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
@Override
|
||||
public QuestState load() throws IOException {
|
||||
if (!Files.exists(path)) {
|
||||
return QuestState.empty();
|
||||
}
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
try {
|
||||
yaml.load(path.toFile());
|
||||
} catch (InvalidConfigurationException exception) {
|
||||
throw new IOException("Invalid quest state", exception);
|
||||
}
|
||||
Map<UUID, Quest> quests = new LinkedHashMap<>();
|
||||
try {
|
||||
for (Map<?, ?> entry : yaml.getMapList("quests")) {
|
||||
UUID id = UUID.fromString(requiredString(entry, "id"));
|
||||
Quest quest = new Quest(
|
||||
id,
|
||||
UUID.fromString(requiredString(entry, "issuer-id")),
|
||||
requiredString(entry, "issuer-name"),
|
||||
requiredString(entry, "requested-material"),
|
||||
requiredInteger(entry, "requested-amount"),
|
||||
readRewards(entry.get("reward")),
|
||||
Instant.parse(requiredString(entry, "created-at")),
|
||||
Instant.parse(requiredString(entry, "expires-at"))
|
||||
);
|
||||
if (quests.put(id, quest) != null) {
|
||||
throw new IllegalArgumentException("Duplicate quest id: " + id);
|
||||
}
|
||||
}
|
||||
} catch (IllegalArgumentException | DateTimeParseException exception) {
|
||||
throw new IOException("Invalid quest record", exception);
|
||||
}
|
||||
return new QuestState(quests);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(QuestState state) throws IOException {
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
List<Map<String, Object>> serializedQuests = new ArrayList<>();
|
||||
for (Quest quest : state.quests().values()) {
|
||||
Map<String, Object> serialized = new LinkedHashMap<>();
|
||||
serialized.put("id", quest.id().toString());
|
||||
serialized.put("issuer-id", quest.issuerId().toString());
|
||||
serialized.put("issuer-name", quest.issuerName());
|
||||
serialized.put("requested-material", quest.requestedMaterial());
|
||||
serialized.put("requested-amount", quest.requestedAmount());
|
||||
serialized.put("created-at", quest.createdAt().toString());
|
||||
serialized.put("expires-at", quest.expiresAt().toString());
|
||||
List<Map<String, Object>> rewards = new ArrayList<>();
|
||||
for (EscrowItem reward : quest.reward()) {
|
||||
Map<String, Object> serializedReward = new LinkedHashMap<>();
|
||||
serializedReward.put("material", reward.material());
|
||||
serializedReward.put("amount", reward.amount());
|
||||
if (reward.serializedItem() != null) {
|
||||
serializedReward.put("item-data", reward.serializedItem());
|
||||
}
|
||||
rewards.add(serializedReward);
|
||||
}
|
||||
serialized.put("reward", rewards);
|
||||
serializedQuests.add(serialized);
|
||||
}
|
||||
yaml.set("quests", serializedQuests);
|
||||
writeAtomically(yaml);
|
||||
}
|
||||
|
||||
private void writeAtomically(YamlConfiguration yaml) throws IOException {
|
||||
Path parent = path.toAbsolutePath().getParent();
|
||||
Files.createDirectories(parent);
|
||||
Path temporary = Files.createTempFile(parent, path.getFileName().toString(), ".tmp");
|
||||
try {
|
||||
yaml.save(temporary.toFile());
|
||||
try {
|
||||
Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<EscrowItem> readRewards(Object value) {
|
||||
if (!(value instanceof List<?> entries) || entries.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing reward");
|
||||
}
|
||||
List<EscrowItem> rewards = new ArrayList<>();
|
||||
for (Object rawEntry : entries) {
|
||||
if (!(rawEntry instanceof Map<?, ?> entry)) {
|
||||
throw new IllegalArgumentException("Invalid reward");
|
||||
}
|
||||
Object data = entry.get("item-data");
|
||||
if (data != null && !(data instanceof String)) {
|
||||
throw new IllegalArgumentException("Invalid item-data");
|
||||
}
|
||||
rewards.add(new EscrowItem(
|
||||
requiredString(entry, "material"), requiredInteger(entry, "amount"), (String) data
|
||||
));
|
||||
}
|
||||
return rewards;
|
||||
}
|
||||
|
||||
private static String requiredString(Map<?, ?> entry, String key) {
|
||||
Object value = entry.get(key);
|
||||
if (!(value instanceof String text) || text.isBlank()) {
|
||||
throw new IllegalArgumentException("Missing " + key);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private static int requiredInteger(Map<?, ?> entry, String key) {
|
||||
Object value = entry.get(key);
|
||||
if (!(value instanceof Number number)) {
|
||||
throw new IllegalArgumentException("Missing " + key);
|
||||
}
|
||||
return number.intValue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class QuestBoardDialogUiTest {
|
||||
private static final Instant NOW = Instant.parse("2026-09-05T03:00:00Z");
|
||||
|
||||
@Test
|
||||
void dialogSubmissionUsesEquivalentCreationFlow() {
|
||||
RecordingCreator creator = new RecordingCreator(false);
|
||||
QuestBoardDialogUi ui = new QuestBoardDialogUi(
|
||||
creator, Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
Player player = mock(Player.class);
|
||||
|
||||
ui.submit(player, "stone", "64");
|
||||
|
||||
assertEquals("stone", creator.material);
|
||||
assertEquals(64, creator.quantity);
|
||||
assertEquals(NOW, creator.createdAt);
|
||||
verify(player).sendMessage(
|
||||
"Quest 00000000-0000-0000-0000-000000000010 created. "
|
||||
+ "Your exact held stack is now escrowed."
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistenceFailureExplainsThatRewardWasRestored() {
|
||||
QuestBoardDialogUi ui = new QuestBoardDialogUi(
|
||||
new RecordingCreator(true), Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
Player player = mock(Player.class);
|
||||
|
||||
ui.submit(player, "stone", "64");
|
||||
|
||||
verify(player).sendMessage(
|
||||
"The quest could not be saved. Your held reward was restored."
|
||||
);
|
||||
}
|
||||
|
||||
private static final class RecordingCreator implements QuestCreationGateway {
|
||||
private final boolean fail;
|
||||
private String material;
|
||||
private int quantity;
|
||||
private Instant createdAt;
|
||||
|
||||
private RecordingCreator(boolean fail) {
|
||||
this.fail = fail;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Quest create(
|
||||
Player player, String requestedMaterial, int requestedAmount, Instant instant
|
||||
) throws IOException {
|
||||
if (fail) {
|
||||
throw new IOException("disk full");
|
||||
}
|
||||
material = requestedMaterial;
|
||||
quantity = requestedAmount;
|
||||
createdAt = instant;
|
||||
return new Quest(
|
||||
UUID.fromString("00000000-0000-0000-0000-000000000010"),
|
||||
UUID.fromString("00000000-0000-0000-0000-000000000001"),
|
||||
"Issuer", "STONE", requestedAmount,
|
||||
List.of(new EscrowItem("DIAMOND", 1, null)),
|
||||
instant, instant.plusSeconds(604800)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> suggestBlockMaterials(String prefix) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class QuestCommandTest {
|
||||
private static final Instant NOW = Instant.parse("2026-09-05T03:00:00Z");
|
||||
|
||||
@Test
|
||||
void routesValidatedCreateArgumentsWithCurrentUtcTime() {
|
||||
RecordingCreator creator = new RecordingCreator();
|
||||
QuestCommand executor = command(creator);
|
||||
Player player = mock(Player.class);
|
||||
|
||||
assertTrue(executor.onCommand(
|
||||
player, mock(Command.class), "quests", new String[] {"create", "stone", "64"}
|
||||
));
|
||||
|
||||
assertEquals(player, creator.player);
|
||||
assertEquals("stone", creator.material);
|
||||
assertEquals(64, creator.quantity);
|
||||
assertEquals(NOW, creator.createdAt);
|
||||
verify(player).sendMessage(contains("exact held stack"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidQuantityNeverReachesCreation() {
|
||||
RecordingCreator creator = new RecordingCreator();
|
||||
QuestCommand executor = command(creator);
|
||||
Player player = mock(Player.class);
|
||||
|
||||
executor.onCommand(
|
||||
player, mock(Command.class), "quests", new String[] {"create", "stone", "many"}
|
||||
);
|
||||
|
||||
assertEquals(0, creator.calls);
|
||||
verify(player).sendMessage("Quest quantity must be a positive whole number.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void autocompleteIsPlayerOnlyAndContextual() {
|
||||
RecordingCreator creator = new RecordingCreator();
|
||||
QuestCommand executor = command(creator);
|
||||
Player player = mock(Player.class);
|
||||
Command command = mock(Command.class);
|
||||
|
||||
assertEquals(List.of("create"),
|
||||
executor.onTabComplete(player, command, "quests", new String[] {"cr"}));
|
||||
assertEquals(List.of("STONE", "STONE_BRICKS"), executor.onTabComplete(
|
||||
player, command, "quests", new String[] {"create", "sto"}
|
||||
));
|
||||
assertEquals("sto", creator.suggestionPrefix);
|
||||
assertEquals(List.of("1", "16"), executor.onTabComplete(
|
||||
player, command, "quests", new String[] {"create", "stone", "1"}
|
||||
));
|
||||
assertTrue(executor.onTabComplete(
|
||||
mock(org.bukkit.command.CommandSender.class), command, "quests", new String[] {""}
|
||||
).isEmpty());
|
||||
}
|
||||
|
||||
private static QuestCommand command(RecordingCreator creator) {
|
||||
return new QuestCommand(creator, Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
private static final class RecordingCreator implements QuestCreationGateway {
|
||||
private Player player;
|
||||
private String material;
|
||||
private int quantity;
|
||||
private Instant createdAt;
|
||||
private int calls;
|
||||
private String suggestionPrefix;
|
||||
|
||||
@Override
|
||||
public Quest create(
|
||||
Player player, String requestedMaterial, int requestedAmount, Instant createdAt
|
||||
) {
|
||||
this.player = player;
|
||||
material = requestedMaterial;
|
||||
quantity = requestedAmount;
|
||||
this.createdAt = createdAt;
|
||||
calls++;
|
||||
return new Quest(
|
||||
UUID.fromString("00000000-0000-0000-0000-000000000010"),
|
||||
UUID.fromString("00000000-0000-0000-0000-000000000001"),
|
||||
"Issuer", "STONE", requestedAmount,
|
||||
List.of(new EscrowItem("DIAMOND", 1, null)),
|
||||
createdAt, createdAt.plusSeconds(604800)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> suggestBlockMaterials(String prefix) {
|
||||
suggestionPrefix = prefix;
|
||||
return List.of("STONE", "STONE_BRICKS");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class QuestCreationControllerTest {
|
||||
private static final EscrowItem EXACT_REWARD = new EscrowItem(
|
||||
"DIAMOND_SWORD", 1, "opaque-safe-item-stack-data"
|
||||
);
|
||||
|
||||
@Test
|
||||
void validatesBeforeRemovingAndEscrowsRemovedRewardOnSuccess() throws Exception {
|
||||
MemoryQuestRepository repository = new MemoryQuestRepository(false);
|
||||
RecordingRewardInventory inventory = new RecordingRewardInventory(EXACT_REWARD);
|
||||
QuestCreationController controller = controller(repository, inventory);
|
||||
Player player = player();
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> controller.create(player, "not-a-block", 1, Instant.EPOCH));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> controller.create(player, "stone", 0, Instant.EPOCH));
|
||||
assertEquals(0, inventory.removeCount);
|
||||
|
||||
Quest quest = controller.create(player, "stone", 64, Instant.EPOCH);
|
||||
|
||||
assertEquals(1, inventory.removeCount);
|
||||
assertFalse(inventory.rolledBack);
|
||||
assertEquals(List.of(EXACT_REWARD), quest.reward());
|
||||
assertEquals(quest, repository.state.quests().get(quest.id()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistenceFailureRestoresRemovedRewardAndCreatesNothing() throws Exception {
|
||||
MemoryQuestRepository repository = new MemoryQuestRepository(true);
|
||||
RecordingRewardInventory inventory = new RecordingRewardInventory(EXACT_REWARD);
|
||||
QuestCreationController controller = controller(repository, inventory);
|
||||
|
||||
assertThrows(IOException.class,
|
||||
() -> controller.create(player(), "stone", 4, Instant.EPOCH));
|
||||
|
||||
assertTrue(inventory.rolledBack);
|
||||
assertTrue(repository.state.quests().isEmpty());
|
||||
assertTrue(controllerState(repository).quests().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingHeldRewardCreatesNothing() throws Exception {
|
||||
MemoryQuestRepository repository = new MemoryQuestRepository(false);
|
||||
HeldRewardInventory inventory = ignored -> {
|
||||
throw new IllegalArgumentException("Hold the reward stack in your main hand");
|
||||
};
|
||||
QuestCreationController controller = controller(repository, inventory);
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> controller.create(player(), "stone", 1, Instant.EPOCH));
|
||||
assertTrue(repository.state.quests().isEmpty());
|
||||
}
|
||||
|
||||
private static QuestCreationController controller(
|
||||
MemoryQuestRepository repository, HeldRewardInventory inventory
|
||||
) throws IOException {
|
||||
BlockMaterialCatalog catalog = new BlockMaterialCatalog() {
|
||||
@Override
|
||||
public Optional<String> normalizeBlock(String input) {
|
||||
return "stone".equalsIgnoreCase(input) ? Optional.of("STONE") : Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> suggest(String prefix) {
|
||||
return List.of("STONE");
|
||||
}
|
||||
};
|
||||
return new QuestCreationController(new QuestService(repository), catalog, inventory);
|
||||
}
|
||||
|
||||
private static Player player() {
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(
|
||||
UUID.fromString("00000000-0000-0000-0000-000000000001")
|
||||
);
|
||||
when(player.getName()).thenReturn("Issuer");
|
||||
return player;
|
||||
}
|
||||
|
||||
private static QuestState controllerState(MemoryQuestRepository repository) throws IOException {
|
||||
return new QuestService(repository).state();
|
||||
}
|
||||
|
||||
private static final class RecordingRewardInventory implements HeldRewardInventory {
|
||||
private final EscrowItem reward;
|
||||
private int removeCount;
|
||||
private boolean rolledBack;
|
||||
|
||||
private RecordingRewardInventory(EscrowItem reward) {
|
||||
this.reward = reward;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RemovedReward remove(Player player) {
|
||||
removeCount++;
|
||||
return new RemovedReward() {
|
||||
@Override public EscrowItem item() { return reward; }
|
||||
@Override public void rollback() { rolledBack = true; }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static final class MemoryQuestRepository implements QuestRepository {
|
||||
private QuestState state = QuestState.empty();
|
||||
private final boolean failSave;
|
||||
|
||||
private MemoryQuestRepository(boolean failSave) {
|
||||
this.failSave = failSave;
|
||||
}
|
||||
|
||||
@Override public QuestState load() { return state; }
|
||||
|
||||
@Override
|
||||
public void save(QuestState state) throws IOException {
|
||||
if (failSave) {
|
||||
throw new IOException("disk full");
|
||||
}
|
||||
this.state = state;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class QuestServiceTest {
|
||||
@Test
|
||||
void createsSevenDayQuestWithEscrowedRewards() throws Exception {
|
||||
MemoryQuestRepository repository = new MemoryQuestRepository();
|
||||
QuestService service = new QuestService(repository);
|
||||
Instant createdAt = Instant.parse("2026-09-05T00:00:00Z");
|
||||
EscrowItem reward = new EscrowItem("DIAMOND", 3, null);
|
||||
|
||||
Quest quest = service.create(
|
||||
UUID.fromString("00000000-0000-0000-0000-000000000001"),
|
||||
"Issuer", "STONE", 64, List.of(reward), createdAt
|
||||
);
|
||||
|
||||
assertEquals(createdAt.plus(7, ChronoUnit.DAYS), quest.expiresAt());
|
||||
assertEquals(List.of(reward), quest.reward());
|
||||
assertEquals(createdAt, quest.createdAt());
|
||||
assertEquals("STONE", quest.requestedMaterial());
|
||||
assertEquals(64, quest.requestedAmount());
|
||||
assertEquals("Issuer", quest.issuerName());
|
||||
assertEquals(quest, repository.state.quests().get(quest.id()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsUniqueIdentifiers() throws Exception {
|
||||
MemoryQuestRepository repository = new MemoryQuestRepository();
|
||||
QuestService service = new QuestService(repository);
|
||||
UUID issuer = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
List<EscrowItem> reward = List.of(new EscrowItem("DIAMOND", 1, null));
|
||||
|
||||
Quest first = service.create(issuer, "Issuer", "STONE", 1, reward, Instant.EPOCH);
|
||||
Quest second = service.create(issuer, "Issuer", "DIRT", 2, reward, Instant.EPOCH);
|
||||
|
||||
assertNotEquals(first.id(), second.id());
|
||||
assertEquals(2, service.state().quests().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidInputWithoutSaving() throws Exception {
|
||||
MemoryQuestRepository repository = new MemoryQuestRepository();
|
||||
QuestService service = new QuestService(repository);
|
||||
UUID issuer = UUID.randomUUID();
|
||||
List<EscrowItem> reward = List.of(new EscrowItem("DIAMOND", 1, null));
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.create(issuer, "Issuer", "AIR", 1, reward, Instant.EPOCH));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.create(issuer, "Issuer", "STONE", 0, reward, Instant.EPOCH));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.create(issuer, "Issuer", "STONE", 1, List.of(), Instant.EPOCH));
|
||||
|
||||
assertTrue(repository.state.quests().isEmpty());
|
||||
assertEquals(0, repository.saveCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistenceFailureDoesNotPublishQuest() throws Exception {
|
||||
QuestRepository repository = new QuestRepository() {
|
||||
@Override public QuestState load() { return QuestState.empty(); }
|
||||
@Override public void save(QuestState state) throws IOException {
|
||||
throw new IOException("disk full");
|
||||
}
|
||||
};
|
||||
QuestService service = new QuestService(repository);
|
||||
|
||||
assertThrows(IOException.class, () -> service.create(
|
||||
UUID.randomUUID(), "Issuer", "STONE", 1,
|
||||
List.of(new EscrowItem("DIAMOND", 1, null)), Instant.EPOCH
|
||||
));
|
||||
assertTrue(service.state().quests().isEmpty());
|
||||
}
|
||||
|
||||
private static final class MemoryQuestRepository implements QuestRepository {
|
||||
private QuestState state = QuestState.empty();
|
||||
private int saveCount;
|
||||
@Override public QuestState load() { return state; }
|
||||
@Override public void save(QuestState state) {
|
||||
this.state = state;
|
||||
saveCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
final class YamlQuestRepositoryTest {
|
||||
@TempDir Path temporaryDirectory;
|
||||
|
||||
@Test
|
||||
void missingFileLoadsEmptyState() throws Exception {
|
||||
YamlQuestRepository repository = new YamlQuestRepository(
|
||||
temporaryDirectory.resolve("quests.yml")
|
||||
);
|
||||
|
||||
assertTrue(repository.load().quests().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundTripsQuestAndOpaqueExactItemData() throws Exception {
|
||||
Path path = temporaryDirectory.resolve("quests.yml");
|
||||
YamlQuestRepository repository = new YamlQuestRepository(path);
|
||||
String itemData = Base64.getEncoder().encodeToString(new byte[] {0, 1, 2, 3, 127, -1});
|
||||
UUID id = UUID.fromString("00000000-0000-0000-0000-000000000010");
|
||||
Instant createdAt = Instant.parse("2026-09-05T03:00:00Z");
|
||||
Quest quest = new Quest(
|
||||
id,
|
||||
UUID.fromString("00000000-0000-0000-0000-000000000001"),
|
||||
"Issuer",
|
||||
"STONE",
|
||||
64,
|
||||
List.of(new EscrowItem("DIAMOND_SWORD", 1, itemData)),
|
||||
createdAt,
|
||||
Instant.parse("2026-09-12T03:00:00Z")
|
||||
);
|
||||
|
||||
repository.save(new QuestState(Map.of(id, quest)));
|
||||
|
||||
assertEquals(new QuestState(Map.of(id, quest)), repository.load());
|
||||
String yaml = Files.readString(path);
|
||||
assertTrue(yaml.contains("created-at: '2026-09-05T03:00:00Z'"));
|
||||
assertTrue(yaml.contains(itemData));
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedStateIsRejectedRatherThanPartiallyLoaded() throws Exception {
|
||||
Path path = temporaryDirectory.resolve("quests.yml");
|
||||
Files.writeString(path, "quests:\n- id: not-a-uuid\n");
|
||||
|
||||
assertThrows(IOException.class, () -> new YamlQuestRepository(path).load());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user