From 638d2328ab2becf8c7566a798758d7cb825e7363 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Fri, 4 Sep 2026 23:45:00 -0400 Subject: [PATCH] feat(quests): add quest creation and reward escrow --- README.md | 2 +- knowledge/log.md | 7 + .../us-003-create-a-block-delivery-quest.md | 20 +-- .../BlockMaterialCatalog.java | 9 ++ .../BukkitBlockMaterialCatalog.java | 30 ++++ .../BukkitHeldRewardInventory.java | 36 +++++ .../dmg/spigotquestboard/EscrowItem.java | 50 ++++++ .../spigotquestboard/HeldRewardInventory.java | 12 ++ .../games/dmg/spigotquestboard/Quest.java | 40 +++++ .../spigotquestboard/QuestBoardDialogUi.java | 76 +++++++++- .../dmg/spigotquestboard/QuestCommand.java | 84 +++++++++++ .../QuestCreationController.java | 52 +++++++ .../QuestCreationGateway.java | 13 ++ .../dmg/spigotquestboard/QuestRepository.java | 8 + .../dmg/spigotquestboard/QuestService.java | 51 +++++++ .../dmg/spigotquestboard/QuestState.java | 20 +++ .../SpigotQuestBoardPlugin.java | 24 ++- .../spigotquestboard/YamlQuestRepository.java | 142 ++++++++++++++++++ .../QuestBoardDialogUiTest.java | 86 +++++++++++ .../spigotquestboard/QuestCommandTest.java | 109 ++++++++++++++ .../QuestCreationControllerTest.java | 138 +++++++++++++++++ .../spigotquestboard/QuestServiceTest.java | 95 ++++++++++++ .../YamlQuestRepositoryTest.java | 63 ++++++++ 23 files changed, 1145 insertions(+), 22 deletions(-) create mode 100644 src/main/java/games/dmg/spigotquestboard/BlockMaterialCatalog.java create mode 100644 src/main/java/games/dmg/spigotquestboard/BukkitBlockMaterialCatalog.java create mode 100644 src/main/java/games/dmg/spigotquestboard/BukkitHeldRewardInventory.java create mode 100644 src/main/java/games/dmg/spigotquestboard/EscrowItem.java create mode 100644 src/main/java/games/dmg/spigotquestboard/HeldRewardInventory.java create mode 100644 src/main/java/games/dmg/spigotquestboard/Quest.java create mode 100644 src/main/java/games/dmg/spigotquestboard/QuestCommand.java create mode 100644 src/main/java/games/dmg/spigotquestboard/QuestCreationController.java create mode 100644 src/main/java/games/dmg/spigotquestboard/QuestCreationGateway.java create mode 100644 src/main/java/games/dmg/spigotquestboard/QuestRepository.java create mode 100644 src/main/java/games/dmg/spigotquestboard/QuestService.java create mode 100644 src/main/java/games/dmg/spigotquestboard/QuestState.java create mode 100644 src/main/java/games/dmg/spigotquestboard/YamlQuestRepository.java create mode 100644 src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java create mode 100644 src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java create mode 100644 src/test/java/games/dmg/spigotquestboard/QuestCreationControllerTest.java create mode 100644 src/test/java/games/dmg/spigotquestboard/QuestServiceTest.java create mode 100644 src/test/java/games/dmg/spigotquestboard/YamlQuestRepositoryTest.java diff --git a/README.md b/README.md index 8f35421..f58717e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The approved behavior is specified in the [OKF knowledge bundle](knowledge/index ## Status -Administrators can register persistent shared quest boards by targeting a block within five blocks and running `/questadmin createboard`. Right-clicking any registered board opens the native quest-board dialog. Quest creation, delivery, and claims remain under development. +Administrators can register persistent shared quest boards by targeting a block within five blocks and running `/questadmin createboard`. Right-clicking any registered board opens a native dialog where a player can request a block and quantity while escrowing the exact reward stack held in their main hand. Delivery and claims remain under development. ## Requirements diff --git a/knowledge/log.md b/knowledge/log.md index 14248d8..51f4fdc 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -33,3 +33,10 @@ description: Chronological record of material decisions affecting Spigot Quest B - Registered main-hand interactions open one shared native Purpur dialog while unregistered blocks remain untouched. - Persistence publishes a board only after an atomic YAML save succeeds and rejects duplicate locations. - Verified 11 tests and the plugin JAR with `./gradlew clean check jar`. + +## 2026-09-05 — Quest creation and reward escrow + +- Added board-dialog and `/quests create ` creation using a valid block material, positive quantity, and the exact held reward stack. +- Escrow preserves full Bukkit item metadata in durable Base64-backed YAML and rolls inventory removal back if persistence fails. +- Quests receive UUID identifiers and UTC creation and seven-day expiration timestamps. +- Verified 26 tests and the plugin JAR with `./gradlew clean check jar`. diff --git a/knowledge/user-stories/us-003-create-a-block-delivery-quest.md b/knowledge/user-stories/us-003-create-a-block-delivery-quest.md index 1177c73..e912e88 100644 --- a/knowledge/user-stories/us-003-create-a-block-delivery-quest.md +++ b/knowledge/user-stories/us-003-create-a-block-delivery-quest.md @@ -2,7 +2,7 @@ type: User Story title: "US-003: Create a block-delivery quest" description: Let a player request block items while safely escrowing an inventory-funded reward. -status: backlog +status: done --- # US-003: Create a block-delivery quest @@ -11,15 +11,15 @@ As a **player**, I want to request a block material and quantity in exchange for ## Acceptance criteria -- [ ] A player can create a quest through a registered quest board by selecting a valid block material, a positive quantity, and reward items from their inventory. -- [ ] Reward items must exist in the issuer's inventory and are removed only when quest creation succeeds. -- [ ] Removed rewards are held durably in escrow by the quest system. -- [ ] A created quest records a unique identifier, issuer UUID and current name, requested material and quantity, exact reward items, creation time, and expiration time. -- [ ] Each quest expires exactly seven days after successful creation. -- [ ] Invalid input, insufficient reward items, or persistence failure leaves the issuer's inventory unchanged and creates no quest. -- [ ] When player commands are enabled, `/quests create ...` provides equivalent validated creation behavior with contextual autocomplete. -- [ ] Quest state and item metadata survive server restarts without duplication or loss. -- [ ] Automated tests verify validation, escrow, rollback, timestamps, command routing, and persistence. +- [x] A player can create a quest through a registered quest board by selecting a valid block material, a positive quantity, and reward items from their inventory. +- [x] Reward items must exist in the issuer's inventory and are removed only when quest creation succeeds. +- [x] Removed rewards are held durably in escrow by the quest system. +- [x] A created quest records a unique identifier, issuer UUID and current name, requested material and quantity, exact reward items, creation time, and expiration time. +- [x] Each quest expires exactly seven days after successful creation. +- [x] Invalid input, insufficient reward items, or persistence failure leaves the issuer's inventory unchanged and creates no quest. +- [x] When player commands are enabled, `/quests create ...` provides equivalent validated creation behavior with contextual autocomplete. +- [x] Quest state and item metadata survive server restarts without duplication or loss. +- [x] Automated tests verify validation, escrow, rollback, timestamps, command routing, and persistence. ## Related diff --git a/src/main/java/games/dmg/spigotquestboard/BlockMaterialCatalog.java b/src/main/java/games/dmg/spigotquestboard/BlockMaterialCatalog.java new file mode 100644 index 0000000..acb1d1f --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/BlockMaterialCatalog.java @@ -0,0 +1,9 @@ +package games.dmg.spigotquestboard; + +import java.util.List; +import java.util.Optional; + +interface BlockMaterialCatalog { + Optional normalizeBlock(String input); + List suggest(String prefix); +} diff --git a/src/main/java/games/dmg/spigotquestboard/BukkitBlockMaterialCatalog.java b/src/main/java/games/dmg/spigotquestboard/BukkitBlockMaterialCatalog.java new file mode 100644 index 0000000..f37a19a --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/BukkitBlockMaterialCatalog.java @@ -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 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 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(); + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/BukkitHeldRewardInventory.java b/src/main/java/games/dmg/spigotquestboard/BukkitHeldRewardInventory.java new file mode 100644 index 0000000..710a5eb --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/BukkitHeldRewardInventory.java @@ -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; + } + } + }; + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/EscrowItem.java b/src/main/java/games/dmg/spigotquestboard/EscrowItem.java new file mode 100644 index 0000000..dbd487b --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/EscrowItem.java @@ -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; + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/HeldRewardInventory.java b/src/main/java/games/dmg/spigotquestboard/HeldRewardInventory.java new file mode 100644 index 0000000..786a87f --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/HeldRewardInventory.java @@ -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(); + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/Quest.java b/src/main/java/games/dmg/spigotquestboard/Quest.java new file mode 100644 index 0000000..714de8c --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/Quest.java @@ -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 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"); + } + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java b/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java index 2030c87..0b902e3 100644 --- a/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java +++ b/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java @@ -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."); + } } } diff --git a/src/main/java/games/dmg/spigotquestboard/QuestCommand.java b/src/main/java/games/dmg/spigotquestboard/QuestCommand.java new file mode 100644 index 0000000..be3dbdd --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestCommand.java @@ -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 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 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 startsWith(List 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 "); + sender.sendMessage("Hold the entire reward stack in your main hand; its exact metadata will be escrowed."); + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestCreationController.java b/src/main/java/games/dmg/spigotquestboard/QuestCreationController.java new file mode 100644 index 0000000..c250c5b --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestCreationController.java @@ -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 suggestBlockMaterials(String prefix) { + return materials.suggest(prefix == null ? "" : prefix); + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestCreationGateway.java b/src/main/java/games/dmg/spigotquestboard/QuestCreationGateway.java new file mode 100644 index 0000000..d21e8b1 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestCreationGateway.java @@ -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 suggestBlockMaterials(String prefix); +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestRepository.java b/src/main/java/games/dmg/spigotquestboard/QuestRepository.java new file mode 100644 index 0000000..8d54b93 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestRepository.java @@ -0,0 +1,8 @@ +package games.dmg.spigotquestboard; + +import java.io.IOException; + +interface QuestRepository { + QuestState load() throws IOException; + void save(QuestState state) throws IOException; +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestService.java b/src/main/java/games/dmg/spigotquestboard/QuestService.java new file mode 100644 index 0000000..23b4b3a --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestService.java @@ -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 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 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 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); + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestState.java b/src/main/java/games/dmg/spigotquestboard/QuestState.java new file mode 100644 index 0000000..f2b98dd --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestState.java @@ -0,0 +1,20 @@ +package games.dmg.spigotquestboard; + +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +record QuestState(Map quests) { + QuestState { + quests = Map.copyOf(Objects.requireNonNull(quests, "quests")); + for (Map.Entry 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()); + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java b/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java index e8044a0..2c1a77c 100644 --- a/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java +++ b/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java @@ -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) { diff --git a/src/main/java/games/dmg/spigotquestboard/YamlQuestRepository.java b/src/main/java/games/dmg/spigotquestboard/YamlQuestRepository.java new file mode 100644 index 0000000..64a52af --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/YamlQuestRepository.java @@ -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 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> serializedQuests = new ArrayList<>(); + for (Quest quest : state.quests().values()) { + Map 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> rewards = new ArrayList<>(); + for (EscrowItem reward : quest.reward()) { + Map 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 readRewards(Object value) { + if (!(value instanceof List entries) || entries.isEmpty()) { + throw new IllegalArgumentException("Missing reward"); + } + List 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(); + } +} diff --git a/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java b/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java new file mode 100644 index 0000000..f00b1d5 --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java @@ -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 suggestBlockMaterials(String prefix) { + return List.of(); + } + } +} diff --git a/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java b/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java new file mode 100644 index 0000000..3754510 --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java @@ -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 suggestBlockMaterials(String prefix) { + suggestionPrefix = prefix; + return List.of("STONE", "STONE_BRICKS"); + } + } +} diff --git a/src/test/java/games/dmg/spigotquestboard/QuestCreationControllerTest.java b/src/test/java/games/dmg/spigotquestboard/QuestCreationControllerTest.java new file mode 100644 index 0000000..dd1f2ef --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/QuestCreationControllerTest.java @@ -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 normalizeBlock(String input) { + return "stone".equalsIgnoreCase(input) ? Optional.of("STONE") : Optional.empty(); + } + + @Override + public List 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; + } + } +} diff --git a/src/test/java/games/dmg/spigotquestboard/QuestServiceTest.java b/src/test/java/games/dmg/spigotquestboard/QuestServiceTest.java new file mode 100644 index 0000000..b0d8167 --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/QuestServiceTest.java @@ -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 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 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++; + } + } +} diff --git a/src/test/java/games/dmg/spigotquestboard/YamlQuestRepositoryTest.java b/src/test/java/games/dmg/spigotquestboard/YamlQuestRepositoryTest.java new file mode 100644 index 0000000..67ae028 --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/YamlQuestRepositoryTest.java @@ -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()); + } +}