feat(quests): add expiration and held item claims
Release / release (push) Successful in 2m22s
CI / build (push) Successful in 1m6s

This commit is contained in:
dmg
2026-09-05 08:15:16 -04:00
parent 5ca939be6c
commit af6d6d0827
25 changed files with 1006 additions and 28 deletions
@@ -37,6 +37,29 @@ final class BukkitIssuerNotifierTest {
assertTrue(repository.state.notifications().isEmpty());
}
@Test
void failedNotificationAcknowledgementRemainsPendingForRetry() throws Exception {
Repository repository = new Repository();
QuestService service = completedService(repository);
IssuerNotification notification = service.state().notifications().values().iterator().next();
Server server = mock(Server.class);
Player player = mock(Player.class);
when(server.getPlayer(notification.recipientId())).thenReturn(player);
when(player.isOnline()).thenReturn(true);
when(player.getUniqueId()).thenReturn(notification.recipientId());
BukkitIssuerNotifier notifier = new BukkitIssuerNotifier(
service, server, Logger.getAnonymousLogger()
);
repository.fail = true;
notifier.notifyIfOnline(notification.recipientId());
assertEquals(1, service.pendingNotifications(notification.recipientId()).size());
repository.fail = false;
notifier.notifyIfOnline(notification.recipientId());
assertTrue(service.pendingNotifications(notification.recipientId()).isEmpty());
}
@Test
void offlineNotificationRemainsDurableUntilNextLogin() throws Exception {
Repository repository = new Repository();
@@ -73,7 +96,14 @@ final class BukkitIssuerNotifierTest {
private static final class Repository implements QuestRepository {
private QuestState state = QuestState.empty();
private boolean fail;
@Override public QuestState load() { return state; }
@Override public void save(QuestState candidate) { state = candidate; }
@Override
public void save(QuestState candidate) throws java.io.IOException {
if (fail) {
throw new java.io.IOException("disk full");
}
state = candidate;
}
}
}
@@ -0,0 +1,94 @@
package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.HashMap;
import java.util.List;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Item;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
final class BukkitQuestClaimInventoryTest {
@Test
void inventoryOverflowIsDroppedAtFeetWithOwnerProtection() {
UUID owner = UUID.randomUUID();
Player player = mock(Player.class);
PlayerInventory inventory = mock(PlayerInventory.class);
World world = mock(World.class);
Location feet = mock(Location.class);
Item dropped = mock(Item.class);
ItemStack requested = mock(ItemStack.class);
ItemStack requestedCopy = mock(ItemStack.class);
ItemStack leftover = mock(ItemStack.class);
ItemStack leftoverCopy = mock(ItemStack.class);
when(requested.clone()).thenReturn(requestedCopy);
when(leftover.clone()).thenReturn(leftoverCopy);
when(leftover.getAmount()).thenReturn(2);
when(player.getUniqueId()).thenReturn(owner);
when(player.getInventory()).thenReturn(inventory);
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(feet);
when(inventory.getStorageContents()).thenReturn(new ItemStack[36]);
HashMap<Integer, ItemStack> leftovers = new HashMap<>();
leftovers.put(0, leftover);
when(inventory.addItem(any(ItemStack.class))).thenReturn(leftovers);
when(world.dropItem(any(Location.class), any(ItemStack.class))).thenReturn(dropped);
QuestClaimInventory.Delivery delivery = BukkitQuestClaimInventory.deliver(
player, List.of(requested)
);
assertEquals(2, delivery.overflowAmount());
verify(world).dropItem(feet, leftoverCopy);
verify(dropped).setOwner(owner);
}
@Test
void ownerProtectionFailureRestoresInventoryAndRemovesPartialDrop() {
Player player = mock(Player.class);
PlayerInventory inventory = mock(PlayerInventory.class);
World world = mock(World.class);
Location feet = mock(Location.class);
Item dropped = mock(Item.class);
ItemStack requested = mock(ItemStack.class);
ItemStack requestedCopy = mock(ItemStack.class);
ItemStack leftover = mock(ItemStack.class);
ItemStack leftoverCopy = mock(ItemStack.class);
ItemStack existing = mock(ItemStack.class);
ItemStack existingCopy = mock(ItemStack.class);
when(requested.clone()).thenReturn(requestedCopy);
when(leftover.clone()).thenReturn(leftoverCopy);
when(existing.clone()).thenReturn(existingCopy);
when(existingCopy.clone()).thenReturn(existingCopy);
ItemStack[] original = new ItemStack[] {existing};
when(player.getInventory()).thenReturn(inventory);
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(feet);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(inventory.getStorageContents()).thenReturn(original);
HashMap<Integer, ItemStack> leftovers = new HashMap<>();
leftovers.put(0, leftover);
when(inventory.addItem(requestedCopy)).thenReturn(leftovers);
when(world.dropItem(any(Location.class), any(ItemStack.class))).thenReturn(dropped);
doThrow(new IllegalStateException("owner rejected")).when(dropped).setOwner(any());
assertThrows(
IllegalStateException.class,
() -> BukkitQuestClaimInventory.deliver(player, List.of(requested))
);
verify(inventory).setStorageContents(any(ItemStack[].class));
verify(dropped).remove();
}
}
@@ -52,6 +52,26 @@ final class QuestBoardDialogUiTest {
assertEquals(NOW, completer.completedAt);
}
@Test
void claimActionUsesEquivalentCollectionFlowAtABoard() {
RecordingCreator creator = new RecordingCreator(false);
java.util.concurrent.atomic.AtomicReference<Player> claimantPlayer =
new java.util.concurrent.atomic.AtomicReference<>();
QuestClaimGateway claimant = player -> {
claimantPlayer.set(player);
return new ClaimCollectionResult(1, 0);
};
QuestBoardDialogUi ui = new QuestBoardDialogUi(
creator, now -> List.of(), null, null, claimant,
Clock.fixed(NOW, ZoneOffset.UTC)
);
Player player = mock(Player.class);
ui.submitClaim(player);
assertEquals(player, claimantPlayer.get());
}
@Test
void cancellationActionUsesEquivalentCancellationFlow() {
RecordingCreator creator = new RecordingCreator(false);
@@ -69,6 +89,47 @@ final class QuestBoardDialogUiTest {
assertEquals(NOW, canceller.cancelledAt);
}
@Test
void boardListingDistinguishesDeliveredBlocksFromReturnedRewards() {
RecordingCreator creator = new RecordingCreator(false);
Player player = mock(Player.class);
UUID owner = UUID.randomUUID();
org.mockito.Mockito.when(player.getUniqueId()).thenReturn(owner);
QuestClaimGateway claimant = new QuestClaimGateway() {
@Override
public ClaimCollectionResult collect(Player ignored) {
return new ClaimCollectionResult(0, 0);
}
@Override
public List<QuestClaim> pendingClaims(Player ignored) {
return List.of(
new QuestClaim(
UUID.randomUUID(), UUID.randomUUID(), owner,
List.of(new EscrowItem("STONE", 2, null)), NOW,
QuestClaimSource.COMPLETION
),
new QuestClaim(
UUID.randomUUID(), UUID.randomUUID(), owner,
List.of(new EscrowItem("DIAMOND", 1, null)), NOW,
QuestClaimSource.EXPIRATION
)
);
}
};
QuestBoardDialogUi ui = new QuestBoardDialogUi(
creator, now -> List.of(), null, null, claimant,
Clock.fixed(NOW, ZoneOffset.UTC)
);
String listing = ui.claimListingText(player);
org.junit.jupiter.api.Assertions.assertTrue(listing.contains("DELIVERED BLOCKS — 2 STONE"));
org.junit.jupiter.api.Assertions.assertTrue(
listing.contains("RETURNED REWARD (EXPIRATION) — 1 DIAMOND")
);
}
@Test
void boardOnlyOffersCancellationActionsForPlayersOwnedActiveQuests() {
RecordingCreator creator = new RecordingCreator(false);
@@ -0,0 +1,133 @@
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 static org.mockito.ArgumentMatchers.contains;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class QuestClaimControllerTest {
@Test
void collectsEveryClaimAndClearlyDistinguishesItemsAndOverflow() throws Exception {
Repository repository = new Repository();
QuestService service = serviceWithAllClaimSources(repository);
UUID issuer = service.state().claims().keySet().iterator().next();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(issuer);
RecordingInventory inventory = new RecordingInventory(2);
ClaimCollectionResult result = new QuestClaimController(service, inventory).collect(player);
assertEquals(new ClaimCollectionResult(3, 6), result);
assertTrue(service.claimsFor(issuer).isEmpty());
verify(player).sendMessage(contains("delivered blocks from completed quest"));
verify(player).sendMessage(contains("returned reward from cancelled quest"));
verify(player).sendMessage(contains("returned reward from expired quest"));
verify(player).sendMessage(contains("dropped at your feet, protected for you"));
}
@Test
void deliveryFailureLeavesClaimPendingForRetry() throws Exception {
Repository repository = new Repository();
QuestService service = expiredService(repository);
UUID issuer = service.state().claims().keySet().iterator().next();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(issuer);
QuestClaimInventory inventory = items -> ignored -> {
throw new IllegalStateException("world rejected drop");
};
assertThrows(
IllegalStateException.class,
() -> new QuestClaimController(service, inventory).collect(player)
);
assertEquals(1, service.claimsFor(issuer).size());
}
@Test
void acknowledgementFailureRollsBackDeliveryAndLeavesClaimPending() throws Exception {
Repository repository = new Repository();
QuestService service = expiredService(repository);
UUID issuer = service.state().claims().keySet().iterator().next();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(issuer);
RecordingInventory inventory = new RecordingInventory(0);
repository.fail = true;
assertThrows(
IOException.class,
() -> new QuestClaimController(service, inventory).collect(player)
);
assertEquals(1, inventory.rollbacks);
assertEquals(1, service.claimsFor(issuer).size());
}
private static QuestService serviceWithAllClaimSources(Repository repository) throws Exception {
QuestService service = new QuestService(repository);
UUID issuer = UUID.randomUUID();
Quest completed = create(service, issuer, "STONE");
Quest cancelled = create(service, issuer, "DIRT");
Quest expired = create(service, issuer, "SAND");
service.complete(
completed.id(), new EscrowItem("STONE", 1, null), Instant.EPOCH.plusSeconds(1)
);
service.cancel(cancelled.id(), issuer, Instant.EPOCH.plusSeconds(1));
service.expire(expired.expiresAt());
return service;
}
private static QuestService expiredService(Repository repository) throws Exception {
QuestService service = new QuestService(repository);
Quest quest = create(service, UUID.randomUUID(), "STONE");
service.expire(quest.expiresAt());
return service;
}
private static Quest create(QuestService service, UUID issuer, String material)
throws IOException {
return service.create(
issuer, "Issuer", material, 1,
List.of(new EscrowItem("DIAMOND", 1, null)), Instant.EPOCH
);
}
private static final class RecordingInventory implements QuestClaimInventory {
private final int overflow;
private int rollbacks;
private RecordingInventory(int overflow) {
this.overflow = overflow;
}
@Override
public PreparedClaim prepare(List<EscrowItem> items) {
return player -> new Delivery() {
@Override public int overflowAmount() { return overflow; }
@Override public void rollback() { rollbacks++; }
};
}
}
private static final class Repository implements QuestRepository {
private QuestState state = QuestState.empty();
private boolean fail;
@Override public QuestState load() { return state; }
@Override public void save(QuestState candidate) throws IOException {
if (fail) {
throw new IOException("disk full");
}
state = candidate;
}
}
}
@@ -105,6 +105,28 @@ final class QuestCommandTest {
assertEquals(NOW, completer.completedAt);
}
@Test
void claimCommandUsesTheSameCollectionGateway() {
RecordingCreator creator = new RecordingCreator();
Player player = mock(Player.class);
java.util.concurrent.atomic.AtomicReference<Player> claimantPlayer =
new java.util.concurrent.atomic.AtomicReference<>();
QuestClaimGateway claimant = claimingPlayer -> {
claimantPlayer.set(claimingPlayer);
return new ClaimCollectionResult(1, 0);
};
QuestCommand executor = new QuestCommand(
creator, now -> List.of(), null, null, claimant,
Clock.fixed(NOW, ZoneOffset.UTC)
);
assertTrue(executor.onCommand(
player, mock(Command.class), "quests", new String[] {"claim"}
));
assertEquals(player, claimantPlayer.get());
}
@Test
void cancelCommandRoutesPlayerAndCurrentTimeToEquivalentGateway() {
RecordingCreator creator = new RecordingCreator();
@@ -160,6 +182,8 @@ final class QuestCommandTest {
executor.onTabComplete(player, command, "quests", new String[] {"li"}));
assertEquals(List.of("cancel"),
executor.onTabComplete(player, command, "quests", new String[] {"ca"}));
assertEquals(List.of("claim"),
executor.onTabComplete(player, command, "quests", new String[] {"cl"}));
assertEquals(List.of("STONE", "STONE_BRICKS"), executor.onTabComplete(
player, command, "quests", new String[] {"create", "sto"}
));
@@ -0,0 +1,127 @@
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.time.Instant;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class QuestExpiryAndClaimServiceTest {
@Test
void expiryReturnsExactRewardAsTypedClaimAndAcknowledgementRemovesIt() throws Exception {
MemoryQuestRepository repository = new MemoryQuestRepository();
QuestService service = new QuestService(repository);
UUID issuer = UUID.randomUUID();
EscrowItem reward = new EscrowItem("DIAMOND", 3, "exact-data");
Quest quest = service.create(
issuer, "Issuer", "STONE", 64, List.of(reward), Instant.EPOCH
);
service.expire(quest.expiresAt());
List<QuestClaim> claims = service.claimsFor(issuer);
assertEquals(QuestStatus.EXPIRED, service.state().quests().get(quest.id()).status());
assertEquals(List.of(reward), claims.getFirst().items());
assertEquals(QuestClaimSource.EXPIRATION, claims.getFirst().source());
assertEquals(QuestClaimType.RETURNED_REWARD, claims.getFirst().type());
service.acknowledgeClaim(issuer, claims.getFirst().id());
assertTrue(service.claimsFor(issuer).isEmpty());
}
@Test
void sevenDayBoundaryIsExclusiveBeforeAndDueExactlyAtExpiration() throws Exception {
QuestService service = new QuestService(new MemoryQuestRepository());
Quest quest = create(service, UUID.randomUUID());
assertTrue(service.expire(quest.expiresAt().minusNanos(1)).isEmpty());
assertEquals(QuestStatus.ACTIVE, service.state().quests().get(quest.id()).status());
assertEquals(1, service.expire(quest.expiresAt()).size());
assertEquals(QuestStatus.EXPIRED, service.state().quests().get(quest.id()).status());
assertTrue(service.expire(quest.expiresAt().plusSeconds(1)).isEmpty());
assertEquals(1, service.claimsFor(quest.issuerId()).size());
}
@Test
void completionCancellationAndExpirationClaimsKeepTheirDistinctSources() throws Exception {
QuestService service = new QuestService(new MemoryQuestRepository());
UUID issuer = UUID.randomUUID();
Quest completed = create(service, issuer);
Quest cancelled = service.create(
issuer, "Issuer", "DIRT", 1,
List.of(new EscrowItem("EMERALD", 2, null)), Instant.EPOCH
);
Quest expired = service.create(
issuer, "Issuer", "SAND", 1,
List.of(new EscrowItem("GOLD_INGOT", 4, null)), Instant.EPOCH
);
service.complete(
completed.id(), new EscrowItem("STONE", 1, null), Instant.EPOCH.plusSeconds(1)
);
service.cancel(cancelled.id(), issuer, Instant.EPOCH.plusSeconds(1));
service.expire(expired.expiresAt());
assertEquals(
List.of(
QuestClaimSource.COMPLETION,
QuestClaimSource.CANCELLATION,
QuestClaimSource.EXPIRATION
),
service.claimsFor(issuer).stream().map(QuestClaim::source).toList()
);
assertEquals(
List.of(
QuestClaimType.DELIVERED_BLOCKS,
QuestClaimType.RETURNED_REWARD,
QuestClaimType.RETURNED_REWARD
),
service.claimsFor(issuer).stream().map(QuestClaim::type).toList()
);
}
@Test
void failedExpirationPersistsNothingAndCanBeRetriedWithoutDuplication() throws Exception {
MemoryQuestRepository repository = new MemoryQuestRepository();
QuestService service = new QuestService(repository);
Quest quest = create(service, UUID.randomUUID());
repository.fail = true;
assertThrows(IOException.class, () -> service.expire(quest.expiresAt()));
assertEquals(QuestStatus.ACTIVE, service.state().quests().get(quest.id()).status());
assertTrue(service.claimsFor(quest.issuerId()).isEmpty());
assertTrue(service.pendingNotifications(quest.issuerId()).isEmpty());
repository.fail = false;
service.expire(quest.expiresAt());
service.expire(quest.expiresAt());
assertEquals(1, service.claimsFor(quest.issuerId()).size());
assertEquals(1, service.pendingNotifications(quest.issuerId()).size());
}
private static Quest create(QuestService service, UUID issuer) throws IOException {
return service.create(
issuer, "Issuer", "STONE", 1,
List.of(new EscrowItem("DIAMOND", 3, null)), Instant.EPOCH
);
}
private static final class MemoryQuestRepository implements QuestRepository {
private QuestState state = QuestState.empty();
private boolean fail;
@Override public QuestState load() { return state; }
@Override
public void save(QuestState candidate) throws IOException {
if (fail) {
throw new IOException("disk full");
}
state = candidate;
}
}
}
@@ -0,0 +1,44 @@
package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Logger;
import org.junit.jupiter.api.Test;
final class QuestExpiryTaskTest {
@Test
void scheduledPassExpiresDueQuestAndRequestsImmediateOnlineNotification() throws Exception {
Instant boundary = Instant.parse("2026-09-12T03:00:00Z");
Repository repository = new Repository();
QuestService service = new QuestService(repository);
UUID issuer = UUID.randomUUID();
service.create(
issuer, "Issuer", "STONE", 1,
List.of(new EscrowItem("DIAMOND", 1, null)),
boundary.minusSeconds(604800)
);
AtomicReference<UUID> notified = new AtomicReference<>();
QuestExpiryTask task = new QuestExpiryTask(
service, notified::set, Clock.fixed(boundary, ZoneOffset.UTC),
Logger.getAnonymousLogger()
);
task.run();
assertEquals(issuer, notified.get());
assertEquals(1, service.pendingNotifications(issuer).size());
assertEquals(QuestStatus.EXPIRED, service.state().quests().values().iterator().next().status());
}
private static final class Repository implements QuestRepository {
private QuestState state = QuestState.empty();
@Override public QuestState load() { return state; }
@Override public void save(QuestState candidate) { state = candidate; }
}
}
@@ -124,9 +124,43 @@ final class YamlQuestRepositoryTest {
assertEquals(expected, repository.load());
String yaml = Files.readString(path);
assertTrue(yaml.contains("claims:"));
assertTrue(yaml.contains("source: COMPLETION"));
assertTrue(yaml.contains("notifications:"));
}
@Test
void sourceLessHistoricalClaimsInferTypeFromQuestLifecycle() 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: 1
created-at: '2026-09-05T03:00:00Z'
expires-at: '2026-09-12T03:00:00Z'
status: CANCELLED
reward:
- material: DIAMOND
amount: 1
claims:
- id: 00000000-0000-0000-0000-000000000020
quest-id: 00000000-0000-0000-0000-000000000010
owner-id: 00000000-0000-0000-0000-000000000001
created-at: '2026-09-05T04:00:00Z'
items:
- material: DIAMOND
amount: 1
""");
QuestClaim claim = new YamlQuestRepository(path).load().claims().values()
.iterator().next().getFirst();
assertEquals(QuestClaimSource.CANCELLATION, claim.source());
assertEquals(QuestClaimType.RETURNED_REWARD, claim.type());
}
@Test
void malformedStateIsRejectedRatherThanPartiallyLoaded() throws Exception {
Path path = temporaryDirectory.resolve("quests.yml");