feat(quests): add atomic block delivery completion
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user