feat(quests): add atomic block delivery completion
Release / release (push) Successful in 2m26s
CI / build (push) Successful in 1m6s

This commit is contained in:
dmg
2026-09-05 07:48:59 -04:00
parent a09a0a3163
commit 9344390599
25 changed files with 1087 additions and 49 deletions
@@ -0,0 +1,79 @@
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 static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import java.util.logging.Logger;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class BukkitIssuerNotifierTest {
@Test
void onlineIssuerIsNotifiedImmediatelyAndDurablePendingStateIsCleared() throws Exception {
Repository repository = new Repository();
QuestService service = completedService(repository);
UUID issuer = service.state().notifications().values().iterator().next().recipientId();
Server server = mock(Server.class);
Player player = mock(Player.class);
when(server.getPlayer(issuer)).thenReturn(player);
when(player.isOnline()).thenReturn(true);
when(player.getUniqueId()).thenReturn(issuer);
BukkitIssuerNotifier notifier = new BukkitIssuerNotifier(
service, server, Logger.getAnonymousLogger()
);
notifier.notifyIfOnline(issuer);
verify(player).sendMessage(contains("can be claimed at a quest board"));
assertTrue(service.state().notifications().isEmpty());
assertTrue(repository.state.notifications().isEmpty());
}
@Test
void offlineNotificationRemainsDurableUntilNextLogin() throws Exception {
Repository repository = new Repository();
QuestService service = completedService(repository);
IssuerNotification notification = service.state().notifications().values().iterator().next();
Server server = mock(Server.class);
when(server.getPlayer(notification.recipientId())).thenReturn(null);
BukkitIssuerNotifier notifier = new BukkitIssuerNotifier(
service, server, Logger.getAnonymousLogger()
);
notifier.notifyIfOnline(notification.recipientId());
assertEquals(1, service.state().notifications().size());
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(notification.recipientId());
notifier.deliver(player);
verify(player).sendMessage(notification.message());
assertTrue(service.state().notifications().isEmpty());
}
private static QuestService completedService(Repository repository) throws Exception {
QuestService service = new QuestService(repository);
Quest quest = service.create(
UUID.randomUUID(), "Issuer", "STONE", 1,
List.of(new EscrowItem("DIAMOND", 1, null)), Instant.EPOCH
);
service.complete(
quest.id(), new EscrowItem("STONE", 1, null), Instant.EPOCH.plusSeconds(1)
);
return service;
}
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; }
}
}
@@ -35,6 +35,23 @@ final class QuestBoardDialogUiTest {
);
}
@Test
void completionActionUsesEquivalentCompletionFlow() {
RecordingCreator creator = new RecordingCreator(false);
RecordingCompleter completer = new RecordingCompleter();
QuestBoardDialogUi ui = new QuestBoardDialogUi(
creator, now -> List.of(), completer, Clock.fixed(NOW, ZoneOffset.UTC)
);
Player player = mock(Player.class);
String id = UUID.randomUUID().toString();
ui.submitCompletion(player, id);
assertEquals(player, completer.player);
assertEquals(id, completer.questId);
assertEquals(NOW, completer.completedAt);
}
@Test
void everyOpeningBuildsAListingFromTheCurrentActiveQuests() {
RecordingCreator creator = new RecordingCreator(false);
@@ -60,6 +77,20 @@ final class QuestBoardDialogUiTest {
);
}
private static final class RecordingCompleter implements QuestCompletionGateway {
private Player player;
private String questId;
private Instant completedAt;
@Override
public QuestCompletion complete(Player completingPlayer, String id, Instant instant) {
player = completingPlayer;
questId = id;
completedAt = instant;
return null;
}
}
private static final class RecordingCreator implements QuestCreationGateway {
private final boolean fail;
private String material;
@@ -86,6 +86,25 @@ final class QuestCommandTest {
verify(player).sendMessage("Quest quantity must be a positive whole number.");
}
@Test
void completeCommandRoutesPlayerAndCurrentTimeToEquivalentGateway() {
RecordingCreator creator = new RecordingCreator();
RecordingCompleter completer = new RecordingCompleter();
QuestCommand executor = new QuestCommand(
creator, now -> List.of(), completer, Clock.fixed(NOW, ZoneOffset.UTC)
);
Player player = mock(Player.class);
String id = UUID.randomUUID().toString();
assertTrue(executor.onCommand(
player, mock(Command.class), "quests", new String[] {"complete", id}
));
assertEquals(player, completer.player);
assertEquals(id, completer.questId);
assertEquals(NOW, completer.completedAt);
}
@Test
void questIdentifierAutocompleteOnlyUsesActiveAndOwnedQuests() {
RecordingCreator creator = new RecordingCreator();
@@ -140,6 +159,20 @@ final class QuestCommandTest {
return new QuestCommand(creator, browser, Clock.fixed(NOW, ZoneOffset.UTC));
}
private static final class RecordingCompleter implements QuestCompletionGateway {
private Player player;
private String questId;
private Instant completedAt;
@Override
public QuestCompletion complete(Player completingPlayer, String id, Instant instant) {
player = completingPlayer;
questId = id;
completedAt = instant;
return null;
}
}
private static final class RecordingCreator implements QuestCreationGateway {
private Player player;
private String material;
@@ -0,0 +1,118 @@
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.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.junit.jupiter.api.Test;
final class QuestCompletionAtomicityTest {
@Test
void persistenceFailureChangesNoDurableOrInMemoryState() throws Exception {
FailingRepository repository = new FailingRepository();
QuestService service = new QuestService(repository);
Quest quest = service.create(
UUID.randomUUID(), "Issuer", "STONE", 2,
List.of(new EscrowItem("DIAMOND", 1, null)), Instant.EPOCH
);
QuestState before = service.state();
repository.fail = true;
assertThrows(IOException.class, () -> service.complete(
quest.id(), new EscrowItem("STONE", 2, null), Instant.EPOCH.plusSeconds(1)
));
assertEquals(before, service.state());
assertEquals(before, repository.state);
}
@Test
void rejectsWrongMaterialQuantityExpiredAndAlreadyCompleted() throws Exception {
FailingRepository repository = new FailingRepository();
QuestService service = new QuestService(repository);
Quest quest = service.create(
UUID.randomUUID(), "Issuer", "STONE", 2,
List.of(new EscrowItem("DIAMOND", 1, null)), Instant.EPOCH
);
assertThrows(IllegalArgumentException.class, () -> service.complete(
quest.id(), new EscrowItem("DIRT", 2, null), Instant.EPOCH.plusSeconds(1)
));
assertThrows(IllegalArgumentException.class, () -> service.complete(
quest.id(), new EscrowItem("STONE", 1, null), Instant.EPOCH.plusSeconds(1)
));
assertThrows(IllegalStateException.class, () -> service.complete(
quest.id(), new EscrowItem("STONE", 2, null), quest.expiresAt()
));
service.complete(
quest.id(), new EscrowItem("STONE", 2, null), Instant.EPOCH.plusSeconds(1)
);
assertThrows(IllegalStateException.class, () -> service.complete(
quest.id(), new EscrowItem("STONE", 2, null), Instant.EPOCH.plusSeconds(2)
));
assertTrue(service.activeQuests(Instant.EPOCH.plusSeconds(2)).isEmpty());
}
@Test
void simultaneousAttemptsSettleExactlyOnce() throws Exception {
FailingRepository repository = new FailingRepository();
QuestService service = new QuestService(repository);
UUID issuer = UUID.randomUUID();
Quest quest = service.create(
issuer, "Issuer", "STONE", 1,
List.of(new EscrowItem("DIAMOND", 1, null)), Instant.EPOCH
);
CountDownLatch start = new CountDownLatch(1);
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
List<Future<Boolean>> attempts = new ArrayList<>();
for (int index = 0; index < 2; index++) {
attempts.add(executor.submit(() -> {
start.await();
try {
service.complete(
quest.id(), new EscrowItem("STONE", 1, null),
Instant.EPOCH.plusSeconds(1)
);
return true;
} catch (IllegalStateException exception) {
return false;
}
}));
}
start.countDown();
assertEquals(1, attempts.stream().filter(attempt -> {
try {
return attempt.get();
} catch (Exception exception) {
throw new AssertionError(exception);
}
}).count());
}
assertEquals(1, service.state().claims().get(issuer).size());
assertEquals(1, service.state().notifications().size());
}
private static final class FailingRepository 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,142 @@
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.ArgumentMatchers.contains;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
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 QuestCompletionControllerTest {
@Test
void removesExactDeliverySettlesRewardReportsOverflowAndNotifies() throws Exception {
Repository repository = new Repository();
QuestService service = new QuestService(repository);
UUID issuer = UUID.randomUUID();
Quest quest = quest(service, issuer);
Inventory inventory = new Inventory();
inventory.overflow = 2;
RecordingNotifier notifier = new RecordingNotifier();
QuestCompletionController controller = new QuestCompletionController(
service, inventory, notifier
);
Player player = mock(Player.class);
controller.complete(player, quest.id().toString(), Instant.EPOCH.plusSeconds(1));
assertEquals("STONE", inventory.material);
assertEquals(3, inventory.amount);
assertTrue(inventory.granted);
assertFalse(inventory.rolledBack);
assertEquals(issuer, notifier.issuer);
assertEquals(
List.of(new EscrowItem("STONE", 3, null)),
service.state().claims().get(issuer).getFirst().items()
);
verify(player).sendMessage(contains("exact escrowed reward"));
verify(player).sendMessage(contains("dropped safely at your feet"));
}
@Test
void inventoryValidationFailureConsumesAndReleasesNothing() throws Exception {
Repository repository = new Repository();
QuestService service = new QuestService(repository);
Quest quest = quest(service, UUID.randomUUID());
Inventory inventory = new Inventory();
inventory.insufficient = true;
Player player = mock(Player.class);
assertThrows(IllegalArgumentException.class, () -> new QuestCompletionController(
service, inventory, ignored -> { }
).complete(player, quest.id().toString(), Instant.EPOCH.plusSeconds(1)));
assertFalse(inventory.granted);
assertEquals(QuestStatus.ACTIVE, service.state().quests().get(quest.id()).status());
assertTrue(service.state().claims().isEmpty());
verify(player, never()).sendMessage(contains("completed"));
}
@Test
void persistenceFailureRollsBackDeliveryAndDoesNotGrantReward() throws Exception {
Repository repository = new Repository();
QuestService service = new QuestService(repository);
Quest quest = quest(service, UUID.randomUUID());
Inventory inventory = new Inventory();
repository.fail = true;
assertThrows(IOException.class, () -> new QuestCompletionController(
service, inventory, ignored -> { }
).complete(mock(Player.class), quest.id().toString(), Instant.EPOCH.plusSeconds(1)));
assertTrue(inventory.rolledBack);
assertFalse(inventory.granted);
assertEquals(QuestStatus.ACTIVE, service.state().quests().get(quest.id()).status());
}
private static Quest quest(QuestService service, UUID issuer) throws IOException {
return service.create(
issuer, "Issuer", "STONE", 3,
List.of(new EscrowItem("DIAMOND", 2, null)), Instant.EPOCH
);
}
private static final class Inventory implements QuestCompletionInventory {
private String material;
private int amount;
private boolean insufficient;
private boolean rolledBack;
private boolean granted;
private int overflow;
@Override
public RemovedDelivery remove(Player player, String requestedMaterial, int requestedAmount) {
material = requestedMaterial;
amount = requestedAmount;
if (insufficient) {
throw new IllegalArgumentException("not enough blocks");
}
return new RemovedDelivery() {
@Override
public List<EscrowItem> items() {
return List.of(new EscrowItem(requestedMaterial, requestedAmount, null));
}
@Override public void rollback() { rolledBack = true; }
};
}
@Override
public PreparedReward prepare(List<EscrowItem> reward) {
return player -> {
granted = true;
return overflow;
};
}
}
private static final class RecordingNotifier implements IssuerNotifier {
private UUID issuer;
@Override public void notifyIfOnline(UUID issuerId) { issuer = issuerId; }
}
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;
}
}
}
@@ -0,0 +1,37 @@
package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertEquals;
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 QuestCompletionServiceTest {
@Test
void completesOnceAndHoldsDeliveredBlocksForIssuer() throws Exception {
MemoryQuestRepository repository = new MemoryQuestRepository();
QuestService service = new QuestService(repository);
UUID issuer = UUID.randomUUID();
Quest quest = service.create(
issuer, "Issuer", "STONE", 64,
List.of(new EscrowItem("DIAMOND", 3, null)), Instant.EPOCH
);
QuestCompletion completion = service.complete(
quest.id(), new EscrowItem("STONE", 64, null), Instant.EPOCH.plusSeconds(1)
);
assertEquals(QuestStatus.COMPLETED, service.state().quests().get(quest.id()).status());
assertEquals(quest.reward(), completion.reward());
assertTrue(service.state().claims().get(issuer).stream()
.anyMatch(claim -> claim.items().equals(List.of(new EscrowItem("STONE", 64, null)))));
}
private static final class MemoryQuestRepository implements QuestRepository {
private QuestState state = QuestState.empty();
@Override public QuestState load() { return state; }
@Override public void save(QuestState state) { this.state = state; }
}
}
@@ -93,6 +93,40 @@ final class YamlQuestRepositoryTest {
assertTrue(Files.readString(path).contains("status: COMPLETED"));
}
@Test
void roundTripsClaimsAndPendingNotificationsWhileOldFilesDefaultThemEmpty() throws Exception {
Path path = temporaryDirectory.resolve("quests.yml");
YamlQuestRepository repository = new YamlQuestRepository(path);
UUID issuer = UUID.randomUUID();
UUID questId = UUID.randomUUID();
Quest quest = new Quest(
questId, issuer, "Issuer", "STONE", 2,
List.of(new EscrowItem("DIAMOND", 1, null)),
Instant.parse("2026-09-05T03:00:00Z"),
Instant.parse("2026-09-12T03:00:00Z"), QuestStatus.COMPLETED
);
QuestClaim claim = new QuestClaim(
UUID.randomUUID(), questId, issuer,
List.of(new EscrowItem("STONE", 2, null)),
Instant.parse("2026-09-05T03:01:00Z")
);
IssuerNotification notification = new IssuerNotification(
UUID.randomUUID(), questId, issuer, "Your delivery can be claimed.",
Instant.parse("2026-09-05T03:01:00Z")
);
QuestState expected = new QuestState(
Map.of(questId, quest), Map.of(issuer, List.of(claim)),
Map.of(notification.id(), notification)
);
repository.save(expected);
assertEquals(expected, repository.load());
String yaml = Files.readString(path);
assertTrue(yaml.contains("claims:"));
assertTrue(yaml.contains("notifications:"));
}
@Test
void malformedStateIsRejectedRatherThanPartiallyLoaded() throws Exception {
Path path = temporaryDirectory.resolve("quests.yml");