feat(quests): add shared active quest browsing
This commit is contained in:
@@ -13,7 +13,8 @@ record Quest(
|
||||
int requestedAmount,
|
||||
List<EscrowItem> reward,
|
||||
Instant createdAt,
|
||||
Instant expiresAt
|
||||
Instant expiresAt,
|
||||
QuestStatus status
|
||||
) {
|
||||
Quest {
|
||||
Objects.requireNonNull(id, "id");
|
||||
@@ -33,8 +34,25 @@ record Quest(
|
||||
}
|
||||
Objects.requireNonNull(createdAt, "createdAt");
|
||||
Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
Objects.requireNonNull(status, "status");
|
||||
if (!expiresAt.isAfter(createdAt)) {
|
||||
throw new IllegalArgumentException("Expiration must follow creation");
|
||||
}
|
||||
}
|
||||
|
||||
Quest(
|
||||
UUID id,
|
||||
UUID issuerId,
|
||||
String issuerName,
|
||||
String requestedMaterial,
|
||||
int requestedAmount,
|
||||
List<EscrowItem> reward,
|
||||
Instant createdAt,
|
||||
Instant expiresAt
|
||||
) {
|
||||
this(
|
||||
id, issuerId, issuerName, requestedMaterial, requestedAmount, reward,
|
||||
createdAt, expiresAt, QuestStatus.ACTIVE
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,12 @@ import org.bukkit.entity.Player;
|
||||
|
||||
final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
private final QuestCreationGateway creator;
|
||||
private final QuestBrowser browser;
|
||||
private final Clock clock;
|
||||
|
||||
QuestBoardDialogUi(QuestCreationGateway creator, Clock clock) {
|
||||
QuestBoardDialogUi(QuestCreationGateway creator, QuestBrowser browser, Clock clock) {
|
||||
this.creator = Objects.requireNonNull(creator, "creator");
|
||||
this.browser = Objects.requireNonNull(browser, "browser");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
@@ -43,12 +45,14 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
.lifetime(Duration.ofMinutes(10))
|
||||
.build()))
|
||||
.build();
|
||||
DialogBase base = DialogBase.builder(Component.text("Create a block-delivery quest"))
|
||||
.externalTitle(Component.text("Quest Board — Create"))
|
||||
String listing = listingText();
|
||||
DialogBase base = DialogBase.builder(Component.text("Quest Board"))
|
||||
.externalTitle(Component.text("Quest Board — Active quests and create"))
|
||||
.body(List.of(DialogBody.plainMessage(Component.text(
|
||||
"Hold the reward in your main hand. The entire exact stack, including all item metadata, "
|
||||
"ACTIVE QUESTS\n" + listing + "\n\nCREATE A QUEST\n"
|
||||
+ "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)))
|
||||
), 800)))
|
||||
.inputs(List.of(
|
||||
DialogInput.text("requested_material", Component.text("Requested block"))
|
||||
.initial("")
|
||||
@@ -68,6 +72,11 @@ final class QuestBoardDialogUi implements QuestBoardUi {
|
||||
.type(DialogType.notice(create))));
|
||||
}
|
||||
|
||||
String listingText() {
|
||||
java.time.Instant now = clock.instant();
|
||||
return QuestListingFormatter.formatAll(browser.activeQuests(now), now);
|
||||
}
|
||||
|
||||
void submit(Player player, String material, String quantityText) {
|
||||
final int quantity;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
interface QuestBrowser {
|
||||
List<Quest> activeQuests(Instant now);
|
||||
|
||||
default List<String> completableQuestIds(Instant now) {
|
||||
return activeQuests(now).stream().map(Quest::id).map(UUID::toString).toList();
|
||||
}
|
||||
|
||||
default List<String> cancellableQuestIds(UUID issuerId, Instant now) {
|
||||
return activeQuests(now).stream()
|
||||
.filter(quest -> quest.issuerId().equals(issuerId))
|
||||
.map(Quest::id)
|
||||
.map(UUID::toString)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
@@ -14,10 +15,12 @@ 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 QuestBrowser browser;
|
||||
private final Clock clock;
|
||||
|
||||
QuestCommand(QuestCreationGateway creator, Clock clock) {
|
||||
QuestCommand(QuestCreationGateway creator, QuestBrowser browser, Clock clock) {
|
||||
this.creator = Objects.requireNonNull(creator, "creator");
|
||||
this.browser = Objects.requireNonNull(browser, "browser");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
@@ -25,14 +28,20 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
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.");
|
||||
if (arguments.length == 0
|
||||
|| (arguments.length == 1 && "list".equalsIgnoreCase(arguments[0]))) {
|
||||
Instant now = clock.instant();
|
||||
sender.sendMessage(QuestListingFormatter.formatAll(browser.activeQuests(now), now));
|
||||
return true;
|
||||
}
|
||||
if (arguments.length != 3 || !"create".equalsIgnoreCase(arguments[0])) {
|
||||
usage(sender);
|
||||
return true;
|
||||
}
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage("Only players can create quests with inventory rewards.");
|
||||
return true;
|
||||
}
|
||||
final int quantity;
|
||||
try {
|
||||
quantity = Integer.parseInt(arguments[2]);
|
||||
@@ -59,7 +68,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
return List.of();
|
||||
}
|
||||
if (arguments.length == 1) {
|
||||
return startsWith(List.of("create"), arguments[0]);
|
||||
return startsWith(List.of("create", "list"), arguments[0]);
|
||||
}
|
||||
if (arguments.length == 2 && "create".equalsIgnoreCase(arguments[0])) {
|
||||
return creator.suggestBlockMaterials(arguments[1]);
|
||||
@@ -67,6 +76,15 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
if (arguments.length == 3 && "create".equalsIgnoreCase(arguments[0])) {
|
||||
return startsWith(QUANTITIES, arguments[2]);
|
||||
}
|
||||
if (arguments.length == 2 && "complete".equalsIgnoreCase(arguments[0])) {
|
||||
return startsWith(browser.completableQuestIds(clock.instant()), arguments[1]);
|
||||
}
|
||||
if (arguments.length == 2 && "cancel".equalsIgnoreCase(arguments[0])) {
|
||||
Player player = (Player) sender;
|
||||
return startsWith(
|
||||
browser.cancellableQuestIds(player.getUniqueId(), clock.instant()), arguments[1]
|
||||
);
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@@ -78,7 +96,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
|
||||
}
|
||||
|
||||
private static void usage(CommandSender sender) {
|
||||
sender.sendMessage("Usage: /quests create <block> <quantity>");
|
||||
sender.sendMessage("Usage: /quests [list] | /quests create <block> <quantity>");
|
||||
sender.sendMessage("Hold the entire reward stack in your main hand; its exact metadata will be escrowed.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
final class QuestListingFormatter {
|
||||
private QuestListingFormatter() {
|
||||
}
|
||||
|
||||
static String formatAll(List<Quest> quests, Instant now) {
|
||||
Objects.requireNonNull(quests, "quests");
|
||||
Objects.requireNonNull(now, "now");
|
||||
if (quests.isEmpty()) {
|
||||
return "No active quests.";
|
||||
}
|
||||
return quests.stream()
|
||||
.map(quest -> format(quest, now))
|
||||
.collect(Collectors.joining("\n"));
|
||||
}
|
||||
|
||||
static String format(Quest quest, Instant now) {
|
||||
Objects.requireNonNull(quest, "quest");
|
||||
Objects.requireNonNull(now, "now");
|
||||
if (quest.status() != QuestStatus.ACTIVE || !now.isBefore(quest.expiresAt())) {
|
||||
throw new IllegalArgumentException("Only active, unexpired quests can be listed");
|
||||
}
|
||||
String reward = quest.reward().stream()
|
||||
.map(QuestListingFormatter::formatReward)
|
||||
.collect(Collectors.joining(" + "));
|
||||
return quest.id() + " — " + quest.requestedAmount() + " × "
|
||||
+ quest.requestedMaterial() + " | Reward: " + reward
|
||||
+ " | Issuer: " + quest.issuerName()
|
||||
+ " | Time remaining: " + formatRemaining(now, quest.expiresAt());
|
||||
}
|
||||
|
||||
private static String formatReward(EscrowItem item) {
|
||||
return item.amount() + " × " + item.material()
|
||||
+ (item.serializedItem() == null ? "" : " (with exact item data)");
|
||||
}
|
||||
|
||||
private static String formatRemaining(Instant now, Instant expiresAt) {
|
||||
Duration duration = Duration.between(now, expiresAt);
|
||||
long seconds = duration.getSeconds() + (duration.getNano() == 0 ? 0 : 1);
|
||||
long days = seconds / 86_400;
|
||||
long hours = seconds % 86_400 / 3_600;
|
||||
long minutes = seconds % 3_600 / 60;
|
||||
long remainingSeconds = seconds % 60;
|
||||
StringBuilder result = new StringBuilder();
|
||||
appendUnit(result, days, "d");
|
||||
appendUnit(result, hours, "h");
|
||||
appendUnit(result, minutes, "m");
|
||||
appendUnit(result, remainingSeconds, "s");
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private static void appendUnit(StringBuilder result, long amount, String unit) {
|
||||
if (amount == 0 && !result.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (amount > 0) {
|
||||
if (!result.isEmpty()) {
|
||||
result.append(' ');
|
||||
}
|
||||
result.append(amount).append(unit);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
final class QuestService {
|
||||
final class QuestService implements QuestBrowser {
|
||||
private final QuestRepository repository;
|
||||
private Map<UUID, Quest> quests;
|
||||
|
||||
@@ -45,6 +45,15 @@ final class QuestService {
|
||||
return quest;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized List<Quest> activeQuests(Instant now) {
|
||||
Objects.requireNonNull(now, "now");
|
||||
return quests.values().stream()
|
||||
.filter(quest -> quest.status() == QuestStatus.ACTIVE)
|
||||
.filter(quest -> now.isBefore(quest.expiresAt()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
synchronized QuestState state() {
|
||||
return new QuestState(quests);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
enum QuestStatus {
|
||||
ACTIVE,
|
||||
COMPLETED,
|
||||
CANCELLED,
|
||||
EXPIRED
|
||||
}
|
||||
@@ -31,12 +31,12 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin {
|
||||
QuestCreationGateway creator = new QuestCreationController(
|
||||
quests, new BukkitBlockMaterialCatalog(), new BukkitHeldRewardInventory()
|
||||
);
|
||||
QuestCommand questCommand = new QuestCommand(creator, Clock.systemUTC());
|
||||
QuestCommand questCommand = new QuestCommand(creator, quests, Clock.systemUTC());
|
||||
command("quests").setExecutor(questCommand);
|
||||
command("quests").setTabCompleter(questCommand);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new QuestBoardInteractionListener(
|
||||
boards, new QuestBoardDialogUi(creator, Clock.systemUTC())
|
||||
boards, new QuestBoardDialogUi(creator, quests, Clock.systemUTC())
|
||||
),
|
||||
this
|
||||
);
|
||||
|
||||
@@ -45,7 +45,8 @@ final class YamlQuestRepository implements QuestRepository {
|
||||
requiredInteger(entry, "requested-amount"),
|
||||
readRewards(entry.get("reward")),
|
||||
Instant.parse(requiredString(entry, "created-at")),
|
||||
Instant.parse(requiredString(entry, "expires-at"))
|
||||
Instant.parse(requiredString(entry, "expires-at")),
|
||||
readStatus(entry.get("status"))
|
||||
);
|
||||
if (quests.put(id, quest) != null) {
|
||||
throw new IllegalArgumentException("Duplicate quest id: " + id);
|
||||
@@ -70,6 +71,7 @@ final class YamlQuestRepository implements QuestRepository {
|
||||
serialized.put("requested-amount", quest.requestedAmount());
|
||||
serialized.put("created-at", quest.createdAt().toString());
|
||||
serialized.put("expires-at", quest.expiresAt().toString());
|
||||
serialized.put("status", quest.status().name());
|
||||
List<Map<String, Object>> rewards = new ArrayList<>();
|
||||
for (EscrowItem reward : quest.reward()) {
|
||||
Map<String, Object> serializedReward = new LinkedHashMap<>();
|
||||
@@ -124,6 +126,16 @@ final class YamlQuestRepository implements QuestRepository {
|
||||
return rewards;
|
||||
}
|
||||
|
||||
private static QuestStatus readStatus(Object value) {
|
||||
if (value == null) {
|
||||
return QuestStatus.ACTIVE;
|
||||
}
|
||||
if (!(value instanceof String status)) {
|
||||
throw new IllegalArgumentException("Invalid status");
|
||||
}
|
||||
return QuestStatus.valueOf(status);
|
||||
}
|
||||
|
||||
private static String requiredString(Map<?, ?> entry, String key) {
|
||||
Object value = entry.get(key);
|
||||
if (!(value instanceof String text) || text.isBlank()) {
|
||||
|
||||
@@ -20,7 +20,7 @@ final class QuestBoardDialogUiTest {
|
||||
void dialogSubmissionUsesEquivalentCreationFlow() {
|
||||
RecordingCreator creator = new RecordingCreator(false);
|
||||
QuestBoardDialogUi ui = new QuestBoardDialogUi(
|
||||
creator, Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
creator, now -> List.of(), Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
Player player = mock(Player.class);
|
||||
|
||||
@@ -35,10 +35,21 @@ final class QuestBoardDialogUiTest {
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyOpeningBuildsAListingFromTheCurrentActiveQuests() {
|
||||
RecordingCreator creator = new RecordingCreator(false);
|
||||
Quest active = creator.quest(NOW);
|
||||
QuestBoardDialogUi ui = new QuestBoardDialogUi(
|
||||
creator, now -> List.of(active), Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
|
||||
assertEquals(QuestListingFormatter.format(active, NOW), ui.listingText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistenceFailureExplainsThatRewardWasRestored() {
|
||||
QuestBoardDialogUi ui = new QuestBoardDialogUi(
|
||||
new RecordingCreator(true), Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
new RecordingCreator(true), now -> List.of(), Clock.fixed(NOW, ZoneOffset.UTC)
|
||||
);
|
||||
Player player = mock(Player.class);
|
||||
|
||||
@@ -69,10 +80,14 @@ final class QuestBoardDialogUiTest {
|
||||
material = requestedMaterial;
|
||||
quantity = requestedAmount;
|
||||
createdAt = instant;
|
||||
return quest(instant);
|
||||
}
|
||||
|
||||
private Quest quest(Instant instant) {
|
||||
return new Quest(
|
||||
UUID.fromString("00000000-0000-0000-0000-000000000010"),
|
||||
UUID.fromString("00000000-0000-0000-0000-000000000001"),
|
||||
"Issuer", "STONE", requestedAmount,
|
||||
"Issuer", "STONE", 64,
|
||||
List.of(new EscrowItem("DIAMOND", 1, null)),
|
||||
instant, instant.plusSeconds(604800)
|
||||
);
|
||||
|
||||
@@ -4,7 +4,9 @@ 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.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
@@ -35,6 +37,41 @@ final class QuestCommandTest {
|
||||
verify(player).sendMessage(contains("exact held stack"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bareCommandAndListAliasShowTheSameActiveQuestTerms() {
|
||||
RecordingCreator creator = new RecordingCreator();
|
||||
Quest quest = creator.quest(64);
|
||||
QuestCommand executor = command(creator, now -> List.of(quest));
|
||||
Player player = mock(Player.class);
|
||||
String expected = QuestListingFormatter.format(quest, NOW);
|
||||
|
||||
assertTrue(executor.onCommand(
|
||||
player, mock(Command.class), "quests", new String[] {}
|
||||
));
|
||||
assertTrue(executor.onCommand(
|
||||
player, mock(Command.class), "quests", new String[] {"list"}
|
||||
));
|
||||
|
||||
verify(player, times(2)).sendMessage(expected);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listNeverDisplaysExpiredOrStaleQuestIdentifiers() {
|
||||
RecordingCreator creator = new RecordingCreator();
|
||||
Quest active = creator.quest(64);
|
||||
Quest expired = new Quest(
|
||||
UUID.randomUUID(), UUID.randomUUID(), "OldIssuer", "DIRT", 1,
|
||||
List.of(new EscrowItem("COAL", 1, null)), NOW.minusSeconds(604800), NOW
|
||||
);
|
||||
QuestCommand executor = command(creator, instant -> List.of(active));
|
||||
Player player = mock(Player.class);
|
||||
|
||||
executor.onCommand(player, mock(Command.class), "quests", new String[] {"list"});
|
||||
|
||||
verify(player).sendMessage(contains(active.id().toString()));
|
||||
verify(player, org.mockito.Mockito.never()).sendMessage(contains(expired.id().toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidQuantityNeverReachesCreation() {
|
||||
RecordingCreator creator = new RecordingCreator();
|
||||
@@ -49,6 +86,29 @@ final class QuestCommandTest {
|
||||
verify(player).sendMessage("Quest quantity must be a positive whole number.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void questIdentifierAutocompleteOnlyUsesActiveAndOwnedQuests() {
|
||||
RecordingCreator creator = new RecordingCreator();
|
||||
UUID issuerId = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
Quest active = creator.quest(64);
|
||||
QuestCommand executor = command(creator, now -> List.of(active));
|
||||
Player issuer = mock(Player.class);
|
||||
when(issuer.getUniqueId()).thenReturn(issuerId);
|
||||
Player otherPlayer = mock(Player.class);
|
||||
when(otherPlayer.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
Command command = mock(Command.class);
|
||||
|
||||
assertEquals(List.of(active.id().toString()), executor.onTabComplete(
|
||||
issuer, command, "quests", new String[] {"complete", ""}
|
||||
));
|
||||
assertEquals(List.of(active.id().toString()), executor.onTabComplete(
|
||||
issuer, command, "quests", new String[] {"cancel", ""}
|
||||
));
|
||||
assertTrue(executor.onTabComplete(
|
||||
otherPlayer, command, "quests", new String[] {"cancel", ""}
|
||||
).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void autocompleteIsPlayerOnlyAndContextual() {
|
||||
RecordingCreator creator = new RecordingCreator();
|
||||
@@ -58,6 +118,8 @@ final class QuestCommandTest {
|
||||
|
||||
assertEquals(List.of("create"),
|
||||
executor.onTabComplete(player, command, "quests", new String[] {"cr"}));
|
||||
assertEquals(List.of("list"),
|
||||
executor.onTabComplete(player, command, "quests", new String[] {"li"}));
|
||||
assertEquals(List.of("STONE", "STONE_BRICKS"), executor.onTabComplete(
|
||||
player, command, "quests", new String[] {"create", "sto"}
|
||||
));
|
||||
@@ -71,7 +133,11 @@ final class QuestCommandTest {
|
||||
}
|
||||
|
||||
private static QuestCommand command(RecordingCreator creator) {
|
||||
return new QuestCommand(creator, Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
return command(creator, now -> List.of());
|
||||
}
|
||||
|
||||
private static QuestCommand command(RecordingCreator creator, QuestBrowser browser) {
|
||||
return new QuestCommand(creator, browser, Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
private static final class RecordingCreator implements QuestCreationGateway {
|
||||
@@ -91,12 +157,17 @@ final class QuestCommandTest {
|
||||
quantity = requestedAmount;
|
||||
this.createdAt = createdAt;
|
||||
calls++;
|
||||
return quest(requestedAmount);
|
||||
}
|
||||
|
||||
private Quest quest(int requestedAmount) {
|
||||
Instant instant = createdAt == null ? NOW : createdAt;
|
||||
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)
|
||||
instant, instant.plusSeconds(604800)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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.assertTrue;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class QuestListingFormatterTest {
|
||||
private static final Instant NOW = Instant.parse("2026-09-05T03:00:00Z");
|
||||
|
||||
@Test
|
||||
void describesEveryEssentialTermAndEachExactRewardStack() {
|
||||
Quest quest = quest(
|
||||
NOW.plusSeconds(90061),
|
||||
List.of(
|
||||
new EscrowItem("DIAMOND", 3, null),
|
||||
new EscrowItem("DIAMOND_SWORD", 1, "opaque-exact-data")
|
||||
)
|
||||
);
|
||||
|
||||
String listing = QuestListingFormatter.format(quest, NOW);
|
||||
|
||||
assertTrue(listing.contains(quest.id().toString()));
|
||||
assertTrue(listing.contains("64 × STONE"));
|
||||
assertTrue(listing.contains("3 × DIAMOND"));
|
||||
assertTrue(listing.contains("1 × DIAMOND_SWORD (with exact item data)"));
|
||||
assertTrue(listing.contains("Issuer: Issuer"));
|
||||
assertTrue(listing.contains("Time remaining: 1d 1h 1m 1s"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundsAnActiveSubsecondBoundaryUpToOneSecond() {
|
||||
Quest quest = quest(NOW.plusNanos(1), List.of(new EscrowItem("DIAMOND", 1, null)));
|
||||
|
||||
String listing = QuestListingFormatter.format(quest, NOW);
|
||||
|
||||
assertTrue(listing.contains("Time remaining: 1s"));
|
||||
assertFalse(listing.contains("0s"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void formatsAnEmptyListingClearly() {
|
||||
assertEquals("No active quests.", QuestListingFormatter.formatAll(List.of(), NOW));
|
||||
}
|
||||
|
||||
private static Quest quest(Instant expiresAt, List<EscrowItem> rewards) {
|
||||
return new Quest(
|
||||
UUID.fromString("00000000-0000-0000-0000-000000000010"),
|
||||
UUID.fromString("00000000-0000-0000-0000-000000000001"),
|
||||
"Issuer", "STONE", 64, rewards, NOW.minusSeconds(60), expiresAt
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,44 @@ final class QuestServiceTest {
|
||||
assertEquals(0, repository.saveCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsOnlyQuestsThatHaveNotReachedTheirExpiration() throws Exception {
|
||||
MemoryQuestRepository repository = new MemoryQuestRepository();
|
||||
QuestService service = new QuestService(repository);
|
||||
Instant createdAt = Instant.parse("2026-09-05T00:00:00Z");
|
||||
Quest quest = service.create(
|
||||
UUID.randomUUID(), "Issuer", "STONE", 1,
|
||||
List.of(new EscrowItem("DIAMOND", 1, null)), createdAt
|
||||
);
|
||||
|
||||
assertEquals(List.of(quest), service.activeQuests(quest.expiresAt().minusNanos(1)));
|
||||
assertTrue(service.activeQuests(quest.expiresAt()).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeListingExcludesNonActiveLifecycleStatuses() throws Exception {
|
||||
Instant now = Instant.parse("2026-09-06T00:00:00Z");
|
||||
Quest active = questWithStatus(QuestStatus.ACTIVE);
|
||||
Quest completed = questWithStatus(QuestStatus.COMPLETED);
|
||||
Quest cancelled = questWithStatus(QuestStatus.CANCELLED);
|
||||
Quest expired = questWithStatus(QuestStatus.EXPIRED);
|
||||
MemoryQuestRepository repository = new MemoryQuestRepository();
|
||||
repository.state = new QuestState(java.util.stream.Stream.of(
|
||||
active, completed, cancelled, expired
|
||||
).collect(java.util.stream.Collectors.toMap(
|
||||
Quest::id, quest -> quest, (left, right) -> left, java.util.LinkedHashMap::new
|
||||
)));
|
||||
|
||||
QuestService service = new QuestService(repository);
|
||||
|
||||
assertEquals(List.of(active), service.activeQuests(now));
|
||||
assertEquals(List.of(active.id().toString()), service.completableQuestIds(now));
|
||||
assertEquals(
|
||||
List.of(active.id().toString()), service.cancellableQuestIds(active.issuerId(), now)
|
||||
);
|
||||
assertTrue(service.cancellableQuestIds(UUID.randomUUID(), now).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistenceFailureDoesNotPublishQuest() throws Exception {
|
||||
QuestRepository repository = new QuestRepository() {
|
||||
@@ -83,6 +121,15 @@ final class QuestServiceTest {
|
||||
assertTrue(service.state().quests().isEmpty());
|
||||
}
|
||||
|
||||
private static Quest questWithStatus(QuestStatus status) {
|
||||
Instant createdAt = Instant.parse("2026-09-05T00:00:00Z");
|
||||
return new Quest(
|
||||
UUID.randomUUID(), UUID.randomUUID(), "Issuer", "STONE", 1,
|
||||
List.of(new EscrowItem("DIAMOND", 1, null)), createdAt,
|
||||
createdAt.plus(7, ChronoUnit.DAYS), status
|
||||
);
|
||||
}
|
||||
|
||||
private static final class MemoryQuestRepository implements QuestRepository {
|
||||
private QuestState state = QuestState.empty();
|
||||
private int saveCount;
|
||||
|
||||
@@ -53,6 +53,46 @@ final class YamlQuestRepositoryTest {
|
||||
assertTrue(yaml.contains(itemData));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingLifecycleStatusDefaultsToActiveForExistingYaml() throws Exception {
|
||||
Path path = temporaryDirectory.resolve("quests.yml");
|
||||
Files.writeString(path, """
|
||||
quests:
|
||||
- id: 00000000-0000-0000-0000-000000000010
|
||||
issuer-id: 00000000-0000-0000-0000-000000000001
|
||||
issuer-name: Issuer
|
||||
requested-material: STONE
|
||||
requested-amount: 64
|
||||
created-at: '2026-09-05T03:00:00Z'
|
||||
expires-at: '2026-09-12T03:00:00Z'
|
||||
reward:
|
||||
- material: DIAMOND
|
||||
amount: 2
|
||||
""");
|
||||
|
||||
Quest quest = new YamlQuestRepository(path).load().quests().values().iterator().next();
|
||||
|
||||
assertEquals(QuestStatus.ACTIVE, quest.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundTripsNonActiveLifecycleStatus() throws Exception {
|
||||
Path path = temporaryDirectory.resolve("quests.yml");
|
||||
YamlQuestRepository repository = new YamlQuestRepository(path);
|
||||
UUID id = UUID.fromString("00000000-0000-0000-0000-000000000010");
|
||||
Quest quest = new Quest(
|
||||
id, UUID.randomUUID(), "Issuer", "STONE", 1,
|
||||
List.of(new EscrowItem("DIAMOND", 1, null)),
|
||||
Instant.parse("2026-09-05T03:00:00Z"),
|
||||
Instant.parse("2026-09-12T03:00:00Z"), QuestStatus.COMPLETED
|
||||
);
|
||||
|
||||
repository.save(new QuestState(Map.of(id, quest)));
|
||||
|
||||
assertEquals(QuestStatus.COMPLETED, repository.load().quests().get(id).status());
|
||||
assertTrue(Files.readString(path).contains("status: COMPLETED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedStateIsRejectedRatherThanPartiallyLoaded() throws Exception {
|
||||
Path path = temporaryDirectory.resolve("quests.yml");
|
||||
|
||||
Reference in New Issue
Block a user