feat(quests): add shared active quest browsing
Release / release (push) Successful in 2m17s
CI / build (push) Successful in 1m7s

This commit is contained in:
dmg
2026-09-04 23:51:34 -04:00
parent 638d2328ab
commit a09a0a3163
17 changed files with 431 additions and 29 deletions
@@ -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");