feat(gear): add final unbreakable Elytra reward
Release / release (push) Successful in 2m51s
CI / build (push) Successful in 1m10s

This commit is contained in:
dmg
2026-09-09 22:28:40 -04:00
parent 0c4a95801a
commit afd8a03f1b
29 changed files with 494 additions and 61 deletions
@@ -5,6 +5,7 @@ public enum ArmorClaimStatus {
GAME_NOT_RUNNING,
NOT_TYRANT,
UNLOCKS_INCOMPLETE,
COLLECTION_INCOMPLETE,
NO_CHOICES,
ALREADY_CLAIMED
}
@@ -23,7 +23,10 @@ public final class BukkitTyrantArmorItemFactory implements TyrantArmorItemFactor
if (spec.efficiencyLevel() > 0) {
meta.addEnchant(Enchantment.EFFICIENCY, spec.efficiencyLevel(), true);
}
meta.addEnchant(Enchantment.MENDING, spec.mendingLevel(), true);
if (spec.mendingLevel() > 0) {
meta.addEnchant(Enchantment.MENDING, spec.mendingLevel(), true);
}
meta.setUnbreakable(spec.unbreakable());
item.setItemMeta(meta);
return item;
}
@@ -55,7 +55,7 @@ public final class BukkitTyrantControlPanelRenderer
inventory.setItem(22, item(Material.NETHERITE_CHESTPLATE,
ChatColor.GOLD + "Legacy Rewards", List.of(
model.allStandardUnlocksPurchased()
? "Choose a once-per-reign armor, tool, or weapon."
? "Choose legacy gear; complete the collection for Elytra."
: "Purchase all six standard unlocks first.",
"Claimed: " + (model.claimedArmor().isEmpty()
? "none" : model.claimedArmor())
@@ -118,12 +118,10 @@ public final class BukkitTyrantControlPanelRenderer
int[] slots = legacyRewardSlots();
for (int index = 0; index < pieces.length; index++) {
TyrantArmorPiece piece = pieces[index];
String status = model.claimedArmor().contains(piece) ? "CLAIMED"
: model.armorAvailable(piece) ? "AVAILABLE" : "LOCKED";
inventory.setItem(slots[index], item(
TyrantArmorSpec.forPiece(piece, player.getName()).material(),
ChatColor.GOLD + readable(piece.name()),
List.of("Status: " + status, "Cost: 1 choice")
model.armorLore(piece)
));
}
inventory.setItem(22, backItem());
@@ -146,7 +144,7 @@ public final class BukkitTyrantControlPanelRenderer
}
static int[] legacyRewardSlots() {
return new int[] {9, 10, 11, 12, 13, 14, 15, 16, 17};
return new int[] {9, 10, 11, 12, 13, 14, 15, 16, 17, 4};
}
private void openArmorConfirmation(Player player, TyrantArmorPiece piece) {
@@ -10,7 +10,14 @@ public enum TyrantArmorPiece {
PICKAXE,
SWORD,
HOE,
SHOVEL;
SHOVEL,
ELYTRA;
/** Explicitly excludes the final reward and any future reward additions. */
public static java.util.Set<TyrantArmorPiece> regularRewards() {
return java.util.Set.of(HELMET, CHESTPLATE, LEGGINGS, BOOTS,
AXE, PICKAXE, SWORD, HOE, SHOVEL);
}
public boolean isArmor() {
return switch (this) {
@@ -26,6 +33,7 @@ public enum TyrantArmorPiece {
case "sword" -> SWORD;
case "hoe" -> HOE;
case "shovel" -> SHOVEL;
case "elytra" -> ELYTRA;
default -> throw new IllegalArgumentException("Unknown gear piece");
};
}
@@ -21,6 +21,10 @@ public final class TyrantArmorService {
if (state.claimedArmor().contains(piece)) {
return result(state, ArmorClaimStatus.ALREADY_CLAIMED);
}
if (piece == TyrantArmorPiece.ELYTRA
&& !state.claimedArmor().containsAll(TyrantArmorPiece.regularRewards())) {
return result(state, ArmorClaimStatus.COLLECTION_INCOMPLETE);
}
Set<TyrantArmorPiece> claimed = EnumSet.noneOf(TyrantArmorPiece.class);
claimed.addAll(state.claimedArmor());
claimed.add(piece);
@@ -10,7 +10,8 @@ public record TyrantArmorSpec(
int protectionLevel,
int mendingLevel,
int sharpnessLevel,
int efficiencyLevel
int efficiencyLevel,
boolean unbreakable
) {
public TyrantArmorSpec {
lore = List.copyOf(lore);
@@ -27,19 +28,26 @@ public record TyrantArmorSpec(
case SWORD -> Material.NETHERITE_SWORD;
case HOE -> Material.NETHERITE_HOE;
case SHOVEL -> Material.NETHERITE_SHOVEL;
case ELYTRA -> Material.ELYTRA;
};
String pieceName = readable(piece);
boolean elytra = piece == TyrantArmorPiece.ELYTRA;
java.util.ArrayList<String> lore = new java.util.ArrayList<>(List.of(
"Forged for Tyrant " + tyrantName + ".",
"A lasting reward from " + tyrantName + "'s reign."
));
if (elytra) {
lore.add("Earned by completing the Tyrant's legacy collection.");
}
return new TyrantArmorSpec(
material,
"Tyrant's " + tyrantName + " " + pieceName,
List.of(
"Forged for Tyrant " + tyrantName + ".",
"A lasting reward from " + tyrantName + "'s reign."
),
lore,
piece.isArmor() ? 5 : 0,
1,
elytra ? 0 : 1,
piece == TyrantArmorPiece.AXE || piece == TyrantArmorPiece.SWORD ? 6 : 0,
!piece.isArmor() && piece != TyrantArmorPiece.SWORD ? 6 : 0
!piece.isArmor() && piece != TyrantArmorPiece.SWORD && !elytra ? 6 : 0,
elytra
);
}
@@ -54,7 +62,12 @@ public record TyrantArmorSpec(
if (efficiencyLevel > 0) {
lines.add("Efficiency VI");
}
lines.add("Mending I");
if (mendingLevel > 0) {
lines.add("Mending I");
}
if (unbreakable) {
lines.add("Unbreakable");
}
lines.add("Cost: 1 choice");
return List.copyOf(lines);
}
@@ -272,7 +272,7 @@ public final class TyrantCommand implements CommandExecutor {
+ (state.claimedArmor().isEmpty() ? "none" : state.claimedArmor()));
player.sendMessage(ChatColor.YELLOW
+ "Use /tyrant armor <helmet|chestplate|leggings|boots> or "
+ "/tyrant gear <axe|pickaxe|sword|hoe|shovel>.");
+ "/tyrant gear <axe|pickaxe|sword|hoe|shovel|elytra>.");
}
}
@@ -308,14 +308,16 @@ public final class TyrantCommand implements CommandExecutor {
} catch (IllegalArgumentException exception) {
player.sendMessage(ChatColor.RED + (armorCommand
? "Unknown armor piece. Use helmet, chestplate, leggings, or boots."
: "Unknown gear piece. Use axe, pickaxe, sword, hoe, or shovel."));
: "Unknown gear piece. Use axe, pickaxe, sword, hoe, shovel, or elytra."));
return;
}
PersistentState snapshot = stateManager.snapshot();
ArmorClaimResult result = armor.claim(snapshot.game(), player.getUniqueId(), piece);
if (result.status() != ArmorClaimStatus.CLAIMED) {
player.sendMessage(ChatColor.RED + "Could not claim " + piece.commandName()
+ ": " + readable(result.status()));
+ ": " + (result.status() == ArmorClaimStatus.COLLECTION_INCOMPLETE
? "claim all nine regular legacy rewards this reign first"
: readable(result.status())));
return;
}
int inventorySlot = player.getInventory().firstEmpty();
@@ -33,7 +33,31 @@ public record TyrantControlPanelModel(
public boolean armorAvailable(TyrantArmorPiece piece) {
return allStandardUnlocksPurchased() && unspentChoices > 0
&& !claimedArmor.contains(piece);
&& !claimedArmor.contains(piece)
&& (piece != TyrantArmorPiece.ELYTRA
|| claimedArmor.containsAll(TyrantArmorPiece.regularRewards()));
}
public java.util.List<String> armorLore(TyrantArmorPiece piece) {
java.util.List<String> lines = new java.util.ArrayList<>();
lines.add("Status: " + (claimedArmor.contains(piece) ? "CLAIMED"
: armorAvailable(piece) ? "AVAILABLE" : "LOCKED"));
lines.add("Cost: 1 choice");
if (piece == TyrantArmorPiece.ELYTRA) {
lines.add("Unbreakable");
long regularClaims = TyrantArmorPiece.regularRewards().stream()
.filter(claimedArmor::contains).count();
if (regularClaims < TyrantArmorPiece.regularRewards().size()) {
lines.add("Claim all nine regular legacy rewards first (" + regularClaims + "/9).");
}
}
if (!allStandardUnlocksPurchased()) {
lines.add("Purchase all six standard unlocks first.");
}
if (unspentChoices == 0) {
lines.add("Earn another choice first.");
}
return java.util.List.copyOf(lines);
}
public static TyrantControlPanelModel create(
@@ -0,0 +1,45 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.junit.jupiter.api.Test;
import org.mockito.MockedConstruction;
final class BukkitTyrantArmorItemFactoryTest {
@Test
void elytraReceivesGoldNameGrayLoreAndUnbreakabilityWithoutAnyEnchantments() {
ItemMeta meta = mock(ItemMeta.class);
try (MockedConstruction<ItemStack> items = mockConstruction(ItemStack.class, (item, context) -> {
assertEquals(List.of(Material.ELYTRA), context.arguments());
when(item.getItemMeta()).thenReturn(meta);
})) {
ItemStack item = new BukkitTyrantArmorItemFactory().create(
TyrantArmorPiece.valueOf("ELYTRA"), "Alex"
);
assertSame(items.constructed().get(0), item);
verify(meta).setDisplayName(ChatColor.GOLD + "Tyrant's Alex Elytra");
verify(meta).setLore(List.of(
ChatColor.GRAY + "Forged for Tyrant Alex.",
ChatColor.GRAY + "A lasting reward from Alex's reign.",
ChatColor.GRAY + "Earned by completing the Tyrant's legacy collection."
));
verify(meta).setUnbreakable(true);
verify(meta, never()).addEnchant(any(), anyInt(), anyBoolean());
verify(item).setItemMeta(meta);
}
}
}
@@ -77,6 +77,33 @@ final class TyrantArmorServiceTest {
assertEquals(3, state.unspentChoices());
}
@Test
void finalElytraCostsOneChoiceAfterExactlyNineRegularClaims() {
assertEquals(9, TyrantArmorPiece.regularRewards().size());
org.junit.jupiter.api.Assertions.assertFalse(
TyrantArmorPiece.regularRewards().contains(TyrantArmorPiece.ELYTRA));
GameState before = game(2, allUnlocks(), TyrantArmorPiece.regularRewards());
ArmorClaimResult result = service.claim(before, TYRANT, TyrantArmorPiece.ELYTRA);
assertEquals(ArmorClaimStatus.CLAIMED, result.status());
assertEquals(1, result.state().unspentChoices());
assertEquals(10, result.state().claimedArmor().size());
assertEquals(ArmorClaimStatus.ALREADY_CLAIMED,
service.claim(result.state(), TYRANT, TyrantArmorPiece.ELYTRA).status());
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.EnumSource(value = TyrantArmorPiece.class,
names = "ELYTRA", mode = org.junit.jupiter.params.provider.EnumSource.Mode.EXCLUDE)
void everyRegularRewardIsRequiredButCanBeBoughtWithoutElytra(TyrantArmorPiece missing) {
Set<TyrantArmorPiece> claims = EnumSet.copyOf(TyrantArmorPiece.regularRewards());
claims.remove(missing);
GameState before = game(2, allUnlocks(), claims);
ArmorClaimResult rejected = service.claim(before, TYRANT, TyrantArmorPiece.ELYTRA);
assertEquals(ArmorClaimStatus.COLLECTION_INCOMPLETE, rejected.status());
assertEquals(before, rejected.state());
assertEquals(ArmorClaimStatus.CLAIMED, service.claim(before, TYRANT, missing).status());
}
private static Set<TyrantUnlock> allUnlocks() {
return EnumSet.allOf(TyrantUnlock.class);
}
@@ -1,42 +1,68 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.bukkit.Material;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
final class TyrantArmorSpecTest {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource({
"AXE,6,6", "PICKAXE,0,6", "SWORD,6,0", "HOE,0,6", "SHOVEL,0,6"
@ParameterizedTest
@CsvSource({
"HELMET,Helmet,5,0,0", "CHESTPLATE,Chestplate,5,0,0",
"LEGGINGS,Leggings,5,0,0", "BOOTS,Boots,5,0,0",
"AXE,Axe,0,6,6", "PICKAXE,Pickaxe,0,0,6", "SWORD,Sword,0,6,0",
"HOE,Hoe,0,0,6", "SHOVEL,Shovel,0,0,6"
})
void toolsHaveExactApprovedEnchantments(String name, int sharpness, int efficiency) {
void nineRegularRewardsKeepTheirExactSpecifications(
String name, String displayPiece, int protection, int sharpness, int efficiency
) {
TyrantArmorSpec spec = TyrantArmorSpec.forPiece(TyrantArmorPiece.valueOf(name), "Alex");
assertEquals(Material.valueOf("NETHERITE_" + name), spec.material());
assertEquals(0, spec.protectionLevel());
assertEquals("Tyrant's Alex " + displayPiece, spec.displayName());
assertEquals(List.of("Forged for Tyrant Alex.", "A lasting reward from Alex's reign."), spec.lore());
assertEquals(protection, spec.protectionLevel());
assertEquals(sharpness, spec.sharpnessLevel());
assertEquals(efficiency, spec.efficiencyLevel());
assertEquals(1, spec.mendingLevel());
assertTrue(spec.displayName().startsWith("Tyrant's Alex "));
assertEquals(TyrantArmorSpec.forPiece(TyrantArmorPiece.HELMET, "Alex").lore(), spec.lore());
assertFalse(spec.unbreakable());
List<String> expectedConfirmation = protection > 0
? List.of("Protection V", "Mending I", "Cost: 1 choice")
: sharpness > 0
? efficiency > 0
? List.of("Sharpness VI", "Efficiency VI", "Mending I", "Cost: 1 choice")
: List.of("Sharpness VI", "Mending I", "Cost: 1 choice")
: List.of("Efficiency VI", "Mending I", "Cost: 1 choice");
assertEquals(expectedConfirmation, spec.confirmationLore());
}
@Test
void everyPieceIsNetheriteWithNamedLoreAndApprovedEnchantments() {
assertEquals(Material.NETHERITE_HELMET,
TyrantArmorSpec.forPiece(TyrantArmorPiece.HELMET, "Alex").material());
assertEquals(Material.NETHERITE_CHESTPLATE,
TyrantArmorSpec.forPiece(TyrantArmorPiece.CHESTPLATE, "Alex").material());
assertEquals(Material.NETHERITE_LEGGINGS,
TyrantArmorSpec.forPiece(TyrantArmorPiece.LEGGINGS, "Alex").material());
assertEquals(Material.NETHERITE_BOOTS,
TyrantArmorSpec.forPiece(TyrantArmorPiece.BOOTS, "Alex").material());
void menuHasTenDistinctRewardSlotsWithoutOverlappingBack() {
int[] slots = BukkitTyrantControlPanelRenderer.legacyRewardSlots();
assertEquals(TyrantArmorPiece.values().length, slots.length);
assertEquals(10, java.util.Arrays.stream(slots).distinct().count());
assertTrue(java.util.Arrays.stream(slots).allMatch(slot -> slot >= 0 && slot < 27 && slot != 22));
}
TyrantArmorSpec spec = TyrantArmorSpec.forPiece(TyrantArmorPiece.HELMET, "Alex");
assertEquals("Tyrant's Alex Helmet", spec.displayName());
assertTrue(spec.lore().stream().anyMatch(line -> line.contains("Alex")));
assertEquals(5, spec.protectionLevel());
assertEquals(1, spec.mendingLevel());
@Test
void elytraIsUnbreakableWithoutEnchantmentsAndHasCollectionLore() {
TyrantArmorSpec spec = TyrantArmorSpec.forPiece(TyrantArmorPiece.valueOf("ELYTRA"), "Alex");
assertEquals(Material.ELYTRA, spec.material());
assertEquals("Tyrant's Alex Elytra", spec.displayName());
assertEquals(List.of(
"Forged for Tyrant Alex.",
"A lasting reward from Alex's reign.",
"Earned by completing the Tyrant's legacy collection."
), spec.lore());
assertTrue(spec.unbreakable());
assertEquals(0, spec.protectionLevel());
assertEquals(0, spec.mendingLevel());
assertEquals(0, spec.sharpnessLevel());
assertEquals(0, spec.efficiencyLevel());
assertEquals(List.of("Unbreakable", "Cost: 1 choice"), spec.confirmationLore());
}
}
@@ -103,12 +103,12 @@ final class TyrantCommandTest {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.EnumSource(TyrantArmorPiece.class)
void fullyUnlockedTyrantCanClaimNamedArmorIntoInventory(TyrantArmorPiece piece) {
void eligibleTyrantCanClaimNamedRewardIntoInventoryWithoutPossessingEarlierRewards(TyrantArmorPiece piece) {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
6, 2, EnumSet.allOf(TyrantUnlock.class), Set.of()
6, 2, EnumSet.allOf(TyrantUnlock.class), prerequisiteClaims(piece)
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.snapshot()).thenReturn(new PersistentState(
@@ -133,7 +133,7 @@ final class TyrantCommandTest {
verify(manager).replaceState(org.mockito.ArgumentMatchers.argThat(state ->
state.game().unspentChoices() == 1
&& state.game().claimedArmor().equals(Set.of(piece))
&& state.game().claimedArmor().equals(expectedClaims(piece))
));
verify(inventory).setItem(4, reward);
verify(manager).saveIfDirty();
@@ -141,12 +141,12 @@ final class TyrantCommandTest {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.EnumSource(TyrantArmorPiece.class)
void fullInventoryDoesNotConsumeChoiceOrCreateArmor(TyrantArmorPiece piece) {
void fullInventoryDoesNotConsumeChoiceOrCreateReward(TyrantArmorPiece piece) {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
6, 1, EnumSet.allOf(TyrantUnlock.class), Set.of()
6, 1, EnumSet.allOf(TyrantUnlock.class), prerequisiteClaims(piece)
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.snapshot()).thenReturn(new PersistentState(
@@ -168,9 +168,22 @@ final class TyrantCommandTest {
verify(manager, org.mockito.Mockito.never()).replaceState(any());
verify(factory, org.mockito.Mockito.never()).create(any(), any());
verify(manager, org.mockito.Mockito.never()).saveIfDirty();
verify(inventory, org.mockito.Mockito.never()).setItem(org.mockito.ArgumentMatchers.anyInt(), any());
verify(tyrant).sendMessage(org.mockito.ArgumentMatchers.contains("inventory is full"));
}
private static Set<TyrantArmorPiece> prerequisiteClaims(TyrantArmorPiece piece) {
return piece == TyrantArmorPiece.ELYTRA ? TyrantArmorPiece.regularRewards() : Set.of();
}
private static Set<TyrantArmorPiece> expectedClaims(TyrantArmorPiece piece) {
Set<TyrantArmorPiece> claims = EnumSet.noneOf(TyrantArmorPiece.class);
claims.addAll(prerequisiteClaims(piece));
claims.add(piece);
return claims;
}
@Test
void assigningClassNotifiesTyrantAndNewHolder() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
@@ -50,6 +50,33 @@ final class TyrantControlPanelModelTest {
assertEquals(Duration.ofSeconds(90), model.intelligenceCooldown());
}
@Test
void elytraMenuExplainsMissingPrerequisitesAndRefreshesAfterClaims() {
Map<TyrantUnlock, UnlockAvailability> unlocks = new java.util.EnumMap<>(TyrantUnlock.class);
for (TyrantUnlock unlock : TyrantUnlock.values()) {
unlocks.put(unlock, UnlockAvailability.PURCHASED);
}
TyrantControlPanelModel incomplete = new TyrantControlPanelModel(
16, 1, unlocks, Set.of(), Map.of(), Duration.ZERO);
assertFalse(incomplete.armorAvailable(TyrantArmorPiece.ELYTRA));
assertTrue(incomplete.armorLore(TyrantArmorPiece.ELYTRA).contains(
"Claim all nine regular legacy rewards first (0/9)."));
TyrantControlPanelModel ready = new TyrantControlPanelModel(
16, 1, unlocks, TyrantArmorPiece.regularRewards(), Map.of(), Duration.ZERO);
assertTrue(ready.armorAvailable(TyrantArmorPiece.ELYTRA));
assertTrue(ready.armorLore(TyrantArmorPiece.ELYTRA).contains("Status: AVAILABLE"));
assertTrue(ready.armorLore(TyrantArmorPiece.ELYTRA).contains("Unbreakable"));
TyrantControlPanelModel claimed = new TyrantControlPanelModel(
17, 1, unlocks, EnumSet.allOf(TyrantArmorPiece.class), Map.of(), Duration.ZERO);
assertFalse(claimed.armorAvailable(TyrantArmorPiece.ELYTRA));
assertTrue(claimed.armorLore(TyrantArmorPiece.ELYTRA).contains("Status: CLAIMED"));
TyrantControlPanelModel locked = new TyrantControlPanelModel(
0, 0, Map.of(), Set.of(), Map.of(), Duration.ZERO);
assertTrue(locked.armorLore(TyrantArmorPiece.ELYTRA).contains(
"Purchase all six standard unlocks first."));
assertTrue(locked.armorLore(TyrantArmorPiece.ELYTRA).contains("Earn another choice first."));
}
@Test
void armorAvailabilityRequiresAllUnlocksAChoiceAndAnUnclaimedSlot() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
@@ -24,7 +24,7 @@ import org.junit.jupiter.params.provider.CsvSource;
final class TyrantGearCommandSafetyTest {
@ParameterizedTest
@CsvSource({"gear,helmet", "armor,axe", "gear,unknown", "gear,chest"})
@CsvSource({"gear,helmet", "armor,axe", "armor,elytra", "gear,unknown", "gear,chest"})
void malformedAndCrossCategoryRequestsDoNotMutateState(String category, String name) {
TyrantStateManager manager = mock(TyrantStateManager.class);
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
@@ -35,13 +35,14 @@ final class TyrantGearCommandSafetyTest {
}
@ParameterizedTest
@EnumSource(value = TyrantArmorPiece.class, names = {"AXE", "PICKAXE", "SWORD", "HOE", "SHOVEL"})
@EnumSource(value = TyrantArmorPiece.class, names = {"AXE", "PICKAXE", "SWORD", "HOE", "SHOVEL", "ELYTRA"})
void repeatedCommandsReadAuthoritativeClaimsAndDeliverOnlyOnce(TyrantArmorPiece piece) {
UUID id = UUID.randomUUID();
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(id), Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(), Duration.ZERO,
6, 2, EnumSet.allOf(TyrantUnlock.class), Set.of()
6, 2, EnumSet.allOf(TyrantUnlock.class),
piece == TyrantArmorPiece.ELYTRA ? TyrantArmorPiece.regularRewards() : Set.of()
);
AtomicReference<PersistentState> state = new AtomicReference<>(new PersistentState(game, Map.of()));
TyrantStateManager manager = mock(TyrantStateManager.class);
@@ -68,6 +69,85 @@ final class TyrantGearCommandSafetyTest {
verify(factory).create(piece, "Alex");
verify(manager).saveIfDirty();
assertEquals(1, state.get().game().unspentChoices());
assertEquals(Set.of(piece), state.get().game().claimedArmor());
Set<TyrantArmorPiece> expected = EnumSet.noneOf(TyrantArmorPiece.class);
expected.addAll(game.claimedArmor());
expected.add(piece);
assertEquals(expected, state.get().game().claimedArmor());
}
@ParameterizedTest
@EnumSource(value = TyrantArmorPiece.class, names = "ELYTRA", mode = EnumSource.Mode.EXCLUDE)
void elytraRequiresEveryRegularClaimInTheCurrentReign(TyrantArmorPiece missing) {
Set<TyrantArmorPiece> claims = EnumSet.copyOf(TyrantArmorPiece.regularRewards());
claims.remove(missing);
assertElytraRejected(GameLifecycle.RUNNING, true, 1,
EnumSet.allOf(TyrantUnlock.class), claims);
}
@ParameterizedTest
@EnumSource(TyrantUnlock.class)
void elytraRequiresEveryStandardUnlockEvenWithAllNineClaims(TyrantUnlock missing) {
Set<TyrantUnlock> unlocks = EnumSet.allOf(TyrantUnlock.class);
unlocks.remove(missing);
assertElytraRejected(GameLifecycle.RUNNING, true, 1,
unlocks, TyrantArmorPiece.regularRewards());
}
@ParameterizedTest
@CsvSource({"PAUSED,true,1", "UNSTARTED,true,1", "RUNNING,false,1", "RUNNING,true,0"})
void elytraCannotBypassLifecycleAuthorizationOrChoiceCost(
GameLifecycle lifecycle, boolean activeTyrant, int choices
) {
assertElytraRejected(lifecycle, activeTyrant, choices,
EnumSet.allOf(TyrantUnlock.class), TyrantArmorPiece.regularRewards());
}
@org.junit.jupiter.api.Test
void noCurrentReignClaimsCannotQualifyForElytra() {
assertElytraRejected(GameLifecycle.RUNNING, true, 1,
EnumSet.allOf(TyrantUnlock.class), Set.of());
}
@org.junit.jupiter.api.Test
void extraElytraArgumentsDoNotCreateOrSaveRewards() {
TyrantStateManager manager = mock(TyrantStateManager.class);
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
TyrantCommand command = new TyrantCommand(manager, new TyrantProgressionService(), factory);
command.onCommand(mock(Player.class), mock(Command.class), "tyrant",
new String[] {"gear", "elytra", "confirm"});
verify(manager, never()).replaceState(any());
verify(manager, never()).saveIfDirty();
verify(factory, never()).create(any(), any());
}
private static void assertElytraRejected(
GameLifecycle lifecycle, boolean activeTyrant, int choices,
Set<TyrantUnlock> unlocks, Set<TyrantArmorPiece> claims
) {
UUID id = UUID.randomUUID();
GameState game = new GameState(
lifecycle, Optional.of(id), Optional.empty(), Optional.empty(),
Optional.empty(), lifecycle == GameLifecycle.PAUSED
? Optional.of(java.time.Instant.EPOCH) : Optional.empty(),
Duration.ZERO, 16, choices, unlocks, claims
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.snapshot()).thenReturn(new PersistentState(game, Map.of()));
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(activeTyrant ? id : UUID.randomUUID());
PlayerInventory inventory = mock(PlayerInventory.class);
when(player.getInventory()).thenReturn(inventory);
when(inventory.firstEmpty()).thenReturn(0);
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
TyrantCommand command = new TyrantCommand(manager, new TyrantProgressionService(), factory);
command.onCommand(player, mock(Command.class), "tyrant", new String[] {"gear", "elytra"});
verify(manager, never()).replaceState(any());
verify(manager, never()).saveIfDirty();
verify(factory, never()).create(any(), any());
verify(inventory, never()).setItem(org.mockito.ArgumentMatchers.anyInt(), any());
verify(player).sendMessage(org.mockito.ArgumentMatchers.contains("Could not claim elytra"));
assertEquals(game, manager.snapshot().game());
}
}
@@ -15,11 +15,11 @@ import org.junit.jupiter.api.Test;
final class TyrantLegacyRewardPanelTest {
@Test
void allNineRewardsFitInDistinctSlotsWithoutOverwritingNavigation() {
void allTenRewardsFitInDistinctSlotsWithoutOverwritingNavigation() {
int[] slots = BukkitTyrantControlPanelRenderer.legacyRewardSlots();
assertEquals(9, slots.length);
assertEquals(10, slots.length);
assertEquals(TyrantArmorPiece.values().length, slots.length);
assertEquals(9, Arrays.stream(slots).distinct().count());
assertEquals(10, Arrays.stream(slots).distinct().count());
assertTrue(Arrays.stream(slots).allMatch(slot -> slot >= 0 && slot < 27 && slot != 22));
}
@@ -30,7 +30,9 @@ final class TyrantLegacyRewardPanelTest {
assertEquals(spec.protectionLevel() > 0, spec.confirmationLore().contains("Protection V"));
assertEquals(spec.sharpnessLevel() > 0, spec.confirmationLore().contains("Sharpness VI"));
assertEquals(spec.efficiencyLevel() > 0, spec.confirmationLore().contains("Efficiency VI"));
assertTrue(spec.confirmationLore().containsAll(java.util.List.of("Mending I", "Cost: 1 choice")));
assertEquals(spec.mendingLevel() > 0, spec.confirmationLore().contains("Mending I"));
assertEquals(piece == TyrantArmorPiece.ELYTRA, spec.confirmationLore().contains("Unbreakable"));
assertTrue(spec.confirmationLore().contains("Cost: 1 choice"));
}
}
@@ -43,12 +45,14 @@ final class TyrantLegacyRewardPanelTest {
6, 9, EnumSet.allOf(TyrantUnlock.class), Set.of()
);
TyrantArmorService service = new TyrantArmorService();
for (TyrantArmorPiece piece : TyrantArmorPiece.values()) {
for (TyrantArmorPiece piece : TyrantArmorPiece.regularRewards()) {
TyrantControlPanelModel before = model(game);
assertTrue(before.armorAvailable(piece));
game = service.claim(game, tyrant, piece).state();
assertFalse(model(game).armorAvailable(piece));
}
assertFalse(model(game).armorAvailable(TyrantArmorPiece.ELYTRA));
assertTrue(model(game).armorLore(TyrantArmorPiece.ELYTRA).contains("Earn another choice first."));
}
private static TyrantControlPanelModel model(GameState game) {
@@ -42,8 +42,14 @@ final class TyrantTabCompleterTest {
"menu", "status", "buy", "armor", "assign", "item"
)));
assertTrue(root.contains("gear"));
assertEquals(List.of("axe", "hoe", "pickaxe", "shovel", "sword"),
assertEquals(List.of("axe", "elytra", "hoe", "pickaxe", "shovel", "sword"),
completer.onTabComplete(player, command, "tyrant", new String[] {"gear", ""}));
assertEquals(List.of("elytra"), completer.onTabComplete(
player, command, "tyrant", new String[] {"GEAR", "EL"}));
assertEquals(List.of(), completer.onTabComplete(
player, command, "tyrant", new String[] {"gear", "elytra", ""}));
assertEquals(List.of(), completer.onTabComplete(
player, command, "tyrant", new String[] {"armor", "el"}));
assertEquals(List.of("shovel", "sword"),
completer.onTabComplete(player, command, "tyrant", new String[] {"GEAR", "S"}));
assertEquals(List.of(), completer.onTabComplete(
@@ -73,7 +73,7 @@ final class YamlTyrantStateRepositoryTest {
GameState loaded = repository.load().game();
assertEquals(Set.of(TyrantArmorPiece.HELMET, TyrantArmorPiece.BOOTS), loaded.claimedArmor());
TyrantArmorService service = new TyrantArmorService();
for (TyrantArmorPiece piece : TyrantArmorPiece.values()) {
for (TyrantArmorPiece piece : TyrantArmorPiece.regularRewards()) {
if (!piece.isArmor()) {
ArmorClaimResult result = service.claim(loaded, loaded.tyrantId().orElseThrow(), piece);
assertEquals(ArmorClaimStatus.CLAIMED, result.status());
@@ -86,6 +86,55 @@ final class YamlTyrantStateRepositoryTest {
assertEquals(Set.of(), new YamlTyrantStateRepository(stateFile).load().game().claimedArmor());
}
@Test
void oldNineRewardSaveEnablesFinalClaimAndElytraRoundTripsUnderHistoricalKey() throws Exception {
Path stateFile = temporaryDirectory.resolve("state.yml");
Files.writeString(stateFile, """
game:
lifecycle: RUNNING
tyrant-id: 11111111-1111-1111-1111-111111111111
tyrant-level: 16
unspent-choices: 2
purchases: [ASSASSIN, FIXER, TAMER, ROSTER_INTELLIGENCE, RESISTANCE, STRENGTH]
claimed-armor: [HELMET, CHESTPLATE, LEGGINGS, BOOTS, AXE, PICKAXE, SWORD, HOE, SHOVEL]
""");
YamlTyrantStateRepository repository = new YamlTyrantStateRepository(stateFile);
GameState loaded = repository.load().game();
assertEquals(TyrantArmorPiece.regularRewards(), loaded.claimedArmor());
assertEquals(9, loaded.claimedArmor().size());
TyrantArmorService service = new TyrantArmorService();
ArmorClaimResult claim = service.claim(
loaded, loaded.tyrantId().orElseThrow(), TyrantArmorPiece.ELYTRA
);
assertEquals(ArmorClaimStatus.CLAIMED, claim.status());
assertEquals(1, claim.state().unspentChoices());
assertEquals(java.util.EnumSet.allOf(TyrantArmorPiece.class), claim.state().claimedArmor());
PersistentState expected = new PersistentState(claim.state(), Map.of());
repository.save(expected);
org.bukkit.configuration.file.YamlConfiguration yaml =
org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(stateFile.toFile());
assertEquals(claim.state().claimedArmor().stream().map(Enum::name).sorted().toList(),
yaml.getStringList("game.claimed-armor"));
PersistentState restarted = new YamlTyrantStateRepository(stateFile).load();
assertEquals(expected, restarted);
ArmorClaimResult duplicate = service.claim(
restarted.game(), loaded.tyrantId().orElseThrow(), TyrantArmorPiece.ELYTRA
);
assertEquals(ArmorClaimStatus.ALREADY_CLAIMED, duplicate.status());
assertEquals(restarted.game(), duplicate.state());
LifecycleState nextReign = new TyrantSuccessionService().succeed(
restarted, Optional.of(UUID.fromString("22222222-2222-2222-2222-222222222222"))
);
repository.save(new PersistentState(nextReign.game(), nextReign.players()));
GameState reset = new YamlTyrantStateRepository(stateFile).load().game();
assertEquals(nextReign.game(), reset);
assertEquals(Set.of(), reset.claimedArmor());
assertEquals(Set.of(), reset.purchases());
assertEquals(1, reset.unspentChoices());
}
@Test
void preservesUnknownFieldsForRetainedState() throws Exception {
UUID playerId = UUID.fromString("11111111-1111-1111-1111-111111111111");