feat(quests): add quest creation and reward escrow
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user