feat(quests): add quest creation and reward escrow
Release / release (push) Successful in 2m17s
CI / build (push) Successful in 1m4s

This commit is contained in:
dmg
2026-09-04 23:45:00 -04:00
parent 63779d1631
commit 638d2328ab
23 changed files with 1145 additions and 22 deletions
@@ -0,0 +1,86 @@
package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class QuestBoardDialogUiTest {
private static final Instant NOW = Instant.parse("2026-09-05T03:00:00Z");
@Test
void dialogSubmissionUsesEquivalentCreationFlow() {
RecordingCreator creator = new RecordingCreator(false);
QuestBoardDialogUi ui = new QuestBoardDialogUi(
creator, Clock.fixed(NOW, ZoneOffset.UTC)
);
Player player = mock(Player.class);
ui.submit(player, "stone", "64");
assertEquals("stone", creator.material);
assertEquals(64, creator.quantity);
assertEquals(NOW, creator.createdAt);
verify(player).sendMessage(
"Quest 00000000-0000-0000-0000-000000000010 created. "
+ "Your exact held stack is now escrowed."
);
}
@Test
void persistenceFailureExplainsThatRewardWasRestored() {
QuestBoardDialogUi ui = new QuestBoardDialogUi(
new RecordingCreator(true), Clock.fixed(NOW, ZoneOffset.UTC)
);
Player player = mock(Player.class);
ui.submit(player, "stone", "64");
verify(player).sendMessage(
"The quest could not be saved. Your held reward was restored."
);
}
private static final class RecordingCreator implements QuestCreationGateway {
private final boolean fail;
private String material;
private int quantity;
private Instant createdAt;
private RecordingCreator(boolean fail) {
this.fail = fail;
}
@Override
public Quest create(
Player player, String requestedMaterial, int requestedAmount, Instant instant
) throws IOException {
if (fail) {
throw new IOException("disk full");
}
material = requestedMaterial;
quantity = requestedAmount;
createdAt = instant;
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)),
instant, instant.plusSeconds(604800)
);
}
@Override
public List<String> suggestBlockMaterials(String prefix) {
return List.of();
}
}
}
@@ -0,0 +1,109 @@
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 java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.UUID;
import org.bukkit.command.Command;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class QuestCommandTest {
private static final Instant NOW = Instant.parse("2026-09-05T03:00:00Z");
@Test
void routesValidatedCreateArgumentsWithCurrentUtcTime() {
RecordingCreator creator = new RecordingCreator();
QuestCommand executor = command(creator);
Player player = mock(Player.class);
assertTrue(executor.onCommand(
player, mock(Command.class), "quests", new String[] {"create", "stone", "64"}
));
assertEquals(player, creator.player);
assertEquals("stone", creator.material);
assertEquals(64, creator.quantity);
assertEquals(NOW, creator.createdAt);
verify(player).sendMessage(contains("exact held stack"));
}
@Test
void invalidQuantityNeverReachesCreation() {
RecordingCreator creator = new RecordingCreator();
QuestCommand executor = command(creator);
Player player = mock(Player.class);
executor.onCommand(
player, mock(Command.class), "quests", new String[] {"create", "stone", "many"}
);
assertEquals(0, creator.calls);
verify(player).sendMessage("Quest quantity must be a positive whole number.");
}
@Test
void autocompleteIsPlayerOnlyAndContextual() {
RecordingCreator creator = new RecordingCreator();
QuestCommand executor = command(creator);
Player player = mock(Player.class);
Command command = mock(Command.class);
assertEquals(List.of("create"),
executor.onTabComplete(player, command, "quests", new String[] {"cr"}));
assertEquals(List.of("STONE", "STONE_BRICKS"), executor.onTabComplete(
player, command, "quests", new String[] {"create", "sto"}
));
assertEquals("sto", creator.suggestionPrefix);
assertEquals(List.of("1", "16"), executor.onTabComplete(
player, command, "quests", new String[] {"create", "stone", "1"}
));
assertTrue(executor.onTabComplete(
mock(org.bukkit.command.CommandSender.class), command, "quests", new String[] {""}
).isEmpty());
}
private static QuestCommand command(RecordingCreator creator) {
return new QuestCommand(creator, Clock.fixed(NOW, ZoneOffset.UTC));
}
private static final class RecordingCreator implements QuestCreationGateway {
private Player player;
private String material;
private int quantity;
private Instant createdAt;
private int calls;
private String suggestionPrefix;
@Override
public Quest create(
Player player, String requestedMaterial, int requestedAmount, Instant createdAt
) {
this.player = player;
material = requestedMaterial;
quantity = requestedAmount;
this.createdAt = createdAt;
calls++;
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)
);
}
@Override
public List<String> suggestBlockMaterials(String prefix) {
suggestionPrefix = prefix;
return List.of("STONE", "STONE_BRICKS");
}
}
}
@@ -0,0 +1,138 @@
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.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class QuestCreationControllerTest {
private static final EscrowItem EXACT_REWARD = new EscrowItem(
"DIAMOND_SWORD", 1, "opaque-safe-item-stack-data"
);
@Test
void validatesBeforeRemovingAndEscrowsRemovedRewardOnSuccess() throws Exception {
MemoryQuestRepository repository = new MemoryQuestRepository(false);
RecordingRewardInventory inventory = new RecordingRewardInventory(EXACT_REWARD);
QuestCreationController controller = controller(repository, inventory);
Player player = player();
assertThrows(IllegalArgumentException.class,
() -> controller.create(player, "not-a-block", 1, Instant.EPOCH));
assertThrows(IllegalArgumentException.class,
() -> controller.create(player, "stone", 0, Instant.EPOCH));
assertEquals(0, inventory.removeCount);
Quest quest = controller.create(player, "stone", 64, Instant.EPOCH);
assertEquals(1, inventory.removeCount);
assertFalse(inventory.rolledBack);
assertEquals(List.of(EXACT_REWARD), quest.reward());
assertEquals(quest, repository.state.quests().get(quest.id()));
}
@Test
void persistenceFailureRestoresRemovedRewardAndCreatesNothing() throws Exception {
MemoryQuestRepository repository = new MemoryQuestRepository(true);
RecordingRewardInventory inventory = new RecordingRewardInventory(EXACT_REWARD);
QuestCreationController controller = controller(repository, inventory);
assertThrows(IOException.class,
() -> controller.create(player(), "stone", 4, Instant.EPOCH));
assertTrue(inventory.rolledBack);
assertTrue(repository.state.quests().isEmpty());
assertTrue(controllerState(repository).quests().isEmpty());
}
@Test
void missingHeldRewardCreatesNothing() throws Exception {
MemoryQuestRepository repository = new MemoryQuestRepository(false);
HeldRewardInventory inventory = ignored -> {
throw new IllegalArgumentException("Hold the reward stack in your main hand");
};
QuestCreationController controller = controller(repository, inventory);
assertThrows(IllegalArgumentException.class,
() -> controller.create(player(), "stone", 1, Instant.EPOCH));
assertTrue(repository.state.quests().isEmpty());
}
private static QuestCreationController controller(
MemoryQuestRepository repository, HeldRewardInventory inventory
) throws IOException {
BlockMaterialCatalog catalog = new BlockMaterialCatalog() {
@Override
public Optional<String> normalizeBlock(String input) {
return "stone".equalsIgnoreCase(input) ? Optional.of("STONE") : Optional.empty();
}
@Override
public List<String> suggest(String prefix) {
return List.of("STONE");
}
};
return new QuestCreationController(new QuestService(repository), catalog, inventory);
}
private static Player player() {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(
UUID.fromString("00000000-0000-0000-0000-000000000001")
);
when(player.getName()).thenReturn("Issuer");
return player;
}
private static QuestState controllerState(MemoryQuestRepository repository) throws IOException {
return new QuestService(repository).state();
}
private static final class RecordingRewardInventory implements HeldRewardInventory {
private final EscrowItem reward;
private int removeCount;
private boolean rolledBack;
private RecordingRewardInventory(EscrowItem reward) {
this.reward = reward;
}
@Override
public RemovedReward remove(Player player) {
removeCount++;
return new RemovedReward() {
@Override public EscrowItem item() { return reward; }
@Override public void rollback() { rolledBack = true; }
};
}
}
private static final class MemoryQuestRepository implements QuestRepository {
private QuestState state = QuestState.empty();
private final boolean failSave;
private MemoryQuestRepository(boolean failSave) {
this.failSave = failSave;
}
@Override public QuestState load() { return state; }
@Override
public void save(QuestState state) throws IOException {
if (failSave) {
throw new IOException("disk full");
}
this.state = state;
}
}
}
@@ -0,0 +1,95 @@
package games.dmg.spigotquestboard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
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.time.temporal.ChronoUnit;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class QuestServiceTest {
@Test
void createsSevenDayQuestWithEscrowedRewards() throws Exception {
MemoryQuestRepository repository = new MemoryQuestRepository();
QuestService service = new QuestService(repository);
Instant createdAt = Instant.parse("2026-09-05T00:00:00Z");
EscrowItem reward = new EscrowItem("DIAMOND", 3, null);
Quest quest = service.create(
UUID.fromString("00000000-0000-0000-0000-000000000001"),
"Issuer", "STONE", 64, List.of(reward), createdAt
);
assertEquals(createdAt.plus(7, ChronoUnit.DAYS), quest.expiresAt());
assertEquals(List.of(reward), quest.reward());
assertEquals(createdAt, quest.createdAt());
assertEquals("STONE", quest.requestedMaterial());
assertEquals(64, quest.requestedAmount());
assertEquals("Issuer", quest.issuerName());
assertEquals(quest, repository.state.quests().get(quest.id()));
}
@Test
void createsUniqueIdentifiers() throws Exception {
MemoryQuestRepository repository = new MemoryQuestRepository();
QuestService service = new QuestService(repository);
UUID issuer = UUID.fromString("00000000-0000-0000-0000-000000000001");
List<EscrowItem> reward = List.of(new EscrowItem("DIAMOND", 1, null));
Quest first = service.create(issuer, "Issuer", "STONE", 1, reward, Instant.EPOCH);
Quest second = service.create(issuer, "Issuer", "DIRT", 2, reward, Instant.EPOCH);
assertNotEquals(first.id(), second.id());
assertEquals(2, service.state().quests().size());
}
@Test
void rejectsInvalidInputWithoutSaving() throws Exception {
MemoryQuestRepository repository = new MemoryQuestRepository();
QuestService service = new QuestService(repository);
UUID issuer = UUID.randomUUID();
List<EscrowItem> reward = List.of(new EscrowItem("DIAMOND", 1, null));
assertThrows(IllegalArgumentException.class,
() -> service.create(issuer, "Issuer", "AIR", 1, reward, Instant.EPOCH));
assertThrows(IllegalArgumentException.class,
() -> service.create(issuer, "Issuer", "STONE", 0, reward, Instant.EPOCH));
assertThrows(IllegalArgumentException.class,
() -> service.create(issuer, "Issuer", "STONE", 1, List.of(), Instant.EPOCH));
assertTrue(repository.state.quests().isEmpty());
assertEquals(0, repository.saveCount);
}
@Test
void persistenceFailureDoesNotPublishQuest() throws Exception {
QuestRepository repository = new QuestRepository() {
@Override public QuestState load() { return QuestState.empty(); }
@Override public void save(QuestState state) throws IOException {
throw new IOException("disk full");
}
};
QuestService service = new QuestService(repository);
assertThrows(IOException.class, () -> service.create(
UUID.randomUUID(), "Issuer", "STONE", 1,
List.of(new EscrowItem("DIAMOND", 1, null)), Instant.EPOCH
));
assertTrue(service.state().quests().isEmpty());
}
private static final class MemoryQuestRepository implements QuestRepository {
private QuestState state = QuestState.empty();
private int saveCount;
@Override public QuestState load() { return state; }
@Override public void save(QuestState state) {
this.state = state;
saveCount++;
}
}
}
@@ -0,0 +1,63 @@
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.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class YamlQuestRepositoryTest {
@TempDir Path temporaryDirectory;
@Test
void missingFileLoadsEmptyState() throws Exception {
YamlQuestRepository repository = new YamlQuestRepository(
temporaryDirectory.resolve("quests.yml")
);
assertTrue(repository.load().quests().isEmpty());
}
@Test
void roundTripsQuestAndOpaqueExactItemData() throws Exception {
Path path = temporaryDirectory.resolve("quests.yml");
YamlQuestRepository repository = new YamlQuestRepository(path);
String itemData = Base64.getEncoder().encodeToString(new byte[] {0, 1, 2, 3, 127, -1});
UUID id = UUID.fromString("00000000-0000-0000-0000-000000000010");
Instant createdAt = Instant.parse("2026-09-05T03:00:00Z");
Quest quest = new Quest(
id,
UUID.fromString("00000000-0000-0000-0000-000000000001"),
"Issuer",
"STONE",
64,
List.of(new EscrowItem("DIAMOND_SWORD", 1, itemData)),
createdAt,
Instant.parse("2026-09-12T03:00:00Z")
);
repository.save(new QuestState(Map.of(id, quest)));
assertEquals(new QuestState(Map.of(id, quest)), repository.load());
String yaml = Files.readString(path);
assertTrue(yaml.contains("created-at: '2026-09-05T03:00:00Z'"));
assertTrue(yaml.contains(itemData));
}
@Test
void malformedStateIsRejectedRatherThanPartiallyLoaded() throws Exception {
Path path = temporaryDirectory.resolve("quests.yml");
Files.writeString(path, "quests:\n- id: not-a-uuid\n");
assertThrows(IOException.class, () -> new YamlQuestRepository(path).load());
}
}