feat(gear): add Tyrant legacy tools and weapons
Release / release (push) Successful in 2m56s
CI / build (push) Successful in 1m12s

This commit is contained in:
dmg
2026-09-09 22:02:59 -04:00
parent 553db666e1
commit 0c4a95801a
27 changed files with 411 additions and 44 deletions
@@ -36,8 +36,9 @@ final class TyrantArmorServiceTest {
assertEquals(before, result.state());
}
@Test
void rejectsIncompleteUnlocksNoChoicesNonTyrantsAndPausedGames() {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.EnumSource(TyrantArmorPiece.class)
void rejectsIncompleteUnlocksNoChoicesNonTyrantsAndPausedGames(TyrantArmorPiece piece) {
GameState complete = game(0, allUnlocks(), Set.of());
GameState incomplete = game(1, Set.of(TyrantUnlock.ASSASSIN), Set.of());
GameState paused = new GameState(
@@ -48,14 +49,32 @@ final class TyrantArmorServiceTest {
);
assertEquals(ArmorClaimStatus.NO_CHOICES,
service.claim(complete, TYRANT, TyrantArmorPiece.HELMET).status());
service.claim(complete, TYRANT, piece).status());
assertEquals(ArmorClaimStatus.UNLOCKS_INCOMPLETE,
service.claim(incomplete, TYRANT, TyrantArmorPiece.HELMET).status());
service.claim(incomplete, TYRANT, piece).status());
assertEquals(ArmorClaimStatus.NOT_TYRANT,
service.claim(game(1, allUnlocks(), Set.of()), OTHER,
TyrantArmorPiece.HELMET).status());
service.claim(game(1, allUnlocks(), Set.of()), OTHER, piece).status());
assertEquals(ArmorClaimStatus.GAME_NOT_RUNNING,
service.claim(paused, TYRANT, TyrantArmorPiece.HELMET).status());
service.claim(paused, TYRANT, piece).status());
}
@Test
void nineIndependentRewardsCanBePurchasedToolsFirstAndNeverRepeated() {
GameState state = game(12, allUnlocks(), Set.of());
String[] order = {"AXE", "PICKAXE", "SWORD", "HOE", "SHOVEL",
"BOOTS", "HELMET", "LEGGINGS", "CHESTPLATE"};
for (String name : order) {
TyrantArmorPiece piece = TyrantArmorPiece.valueOf(name);
ArmorClaimResult claim = service.claim(state, TYRANT, piece);
assertEquals(ArmorClaimStatus.CLAIMED, claim.status());
assertEquals(state.unspentChoices() - 1, claim.state().unspentChoices());
state = claim.state();
assertEquals(ArmorClaimStatus.ALREADY_CLAIMED,
service.claim(state, TYRANT, piece).status());
assertEquals(state, service.claim(state, TYRANT, piece).state());
}
assertEquals(9, state.claimedArmor().size());
assertEquals(3, state.unspentChoices());
}
private static Set<TyrantUnlock> allUnlocks() {
@@ -7,6 +7,21 @@ import org.bukkit.Material;
import org.junit.jupiter.api.Test;
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"
})
void toolsHaveExactApprovedEnchantments(String name, int sharpness, int efficiency) {
TyrantArmorSpec spec = TyrantArmorSpec.forPiece(TyrantArmorPiece.valueOf(name), "Alex");
assertEquals(Material.valueOf("NETHERITE_" + name), spec.material());
assertEquals(0, 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());
}
@Test
void everyPieceIsNetheriteWithNamedLoreAndApprovedEnchantments() {
assertEquals(Material.NETHERITE_HELMET,
@@ -101,8 +101,9 @@ final class TyrantCommandTest {
&& message.contains("ASSASSIN")));
}
@Test
void fullyUnlockedTyrantCanClaimNamedArmorIntoInventory() {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.EnumSource(TyrantArmorPiece.class)
void fullyUnlockedTyrantCanClaimNamedArmorIntoInventory(TyrantArmorPiece piece) {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
@@ -121,25 +122,26 @@ final class TyrantCommandTest {
when(tyrant.getInventory()).thenReturn(inventory);
ItemStack reward = mock(ItemStack.class);
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
when(factory.create(TyrantArmorPiece.HELMET, "Alex")).thenReturn(reward);
when(factory.create(piece, "Alex")).thenReturn(reward);
TyrantCommand command = new TyrantCommand(
manager, new TyrantProgressionService(), factory
);
command.onCommand(
tyrant, mock(Command.class), "tyrant", new String[] {"armor", "helmet"}
tyrant, mock(Command.class), "tyrant", new String[] {piece.isArmor() ? "armor" : "gear", piece.commandName()}
);
verify(manager).replaceState(org.mockito.ArgumentMatchers.argThat(state ->
state.game().unspentChoices() == 1
&& state.game().claimedArmor().equals(Set.of(TyrantArmorPiece.HELMET))
&& state.game().claimedArmor().equals(Set.of(piece))
));
verify(inventory).setItem(4, reward);
verify(manager).saveIfDirty();
}
@Test
void fullInventoryDoesNotConsumeChoiceOrCreateArmor() {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.EnumSource(TyrantArmorPiece.class)
void fullInventoryDoesNotConsumeChoiceOrCreateArmor(TyrantArmorPiece piece) {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
@@ -161,7 +163,7 @@ final class TyrantCommandTest {
);
command.onCommand(
tyrant, mock(Command.class), "tyrant", new String[] {"armor", "boots"}
tyrant, mock(Command.class), "tyrant", new String[] {piece.isArmor() ? "armor" : "gear", piece.commandName()}
);
verify(manager, org.mockito.Mockito.never()).replaceState(any());
@@ -0,0 +1,73 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.util.EnumSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.command.Command;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.CsvSource;
final class TyrantGearCommandSafetyTest {
@ParameterizedTest
@CsvSource({"gear,helmet", "armor,axe", "gear,unknown", "gear,chest"})
void malformedAndCrossCategoryRequestsDoNotMutateState(String category, String name) {
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[] {category, name});
verify(manager, never()).replaceState(any());
verify(factory, never()).create(any(), any());
}
@ParameterizedTest
@EnumSource(value = TyrantArmorPiece.class, names = {"AXE", "PICKAXE", "SWORD", "HOE", "SHOVEL"})
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()
);
AtomicReference<PersistentState> state = new AtomicReference<>(new PersistentState(game, Map.of()));
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.snapshot()).thenAnswer(invocation -> state.get());
org.mockito.Mockito.doAnswer(invocation -> {
LifecycleState replacement = invocation.getArgument(0);
state.set(new PersistentState(replacement.game(), replacement.players()));
return null;
}).when(manager).replaceState(any());
Player player = mock(Player.class);
PlayerInventory inventory = mock(PlayerInventory.class);
when(player.getUniqueId()).thenReturn(id);
when(player.getName()).thenReturn("Alex");
when(player.getInventory()).thenReturn(inventory);
when(inventory.firstEmpty()).thenReturn(0);
ItemStack item = mock(ItemStack.class);
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
when(factory.create(piece, "Alex")).thenReturn(item);
TyrantCommand command = new TyrantCommand(manager, new TyrantProgressionService(), factory);
String[] args = {"GEAR", piece.name()};
command.onCommand(player, mock(Command.class), "tyrant", args);
command.onCommand(player, mock(Command.class), "tyrant", args);
verify(inventory).setItem(0, item);
verify(factory).create(piece, "Alex");
verify(manager).saveIfDirty();
assertEquals(1, state.get().game().unspentChoices());
assertEquals(Set.of(piece), state.get().game().claimedArmor());
}
}
@@ -0,0 +1,59 @@
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.time.Duration;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class TyrantLegacyRewardPanelTest {
@Test
void allNineRewardsFitInDistinctSlotsWithoutOverwritingNavigation() {
int[] slots = BukkitTyrantControlPanelRenderer.legacyRewardSlots();
assertEquals(9, slots.length);
assertEquals(TyrantArmorPiece.values().length, slots.length);
assertEquals(9, Arrays.stream(slots).distinct().count());
assertTrue(Arrays.stream(slots).allMatch(slot -> slot >= 0 && slot < 27 && slot != 22));
}
@Test
void confirmationShowsExactEnchantmentsAndCostForEveryReward() {
for (TyrantArmorPiece piece : TyrantArmorPiece.values()) {
TyrantArmorSpec spec = TyrantArmorSpec.forPiece(piece, "Alex");
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")));
}
}
@Test
void toolAvailabilityDoesNotRequireArmorAndClaimedToolsBecomeUnavailable() {
UUID tyrant = UUID.randomUUID();
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrant), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
6, 9, EnumSet.allOf(TyrantUnlock.class), Set.of()
);
TyrantArmorService service = new TyrantArmorService();
for (TyrantArmorPiece piece : TyrantArmorPiece.values()) {
TyrantControlPanelModel before = model(game);
assertTrue(before.armorAvailable(piece));
game = service.claim(game, tyrant, piece).state();
assertFalse(model(game).armorAvailable(piece));
}
}
private static TyrantControlPanelModel model(GameState game) {
return TyrantControlPanelModel.create(game,
PlayerState.newPlayer(game.tyrantId().orElseThrow(), "Alex"), Map.of(),
java.time.Instant.EPOCH);
}
}
@@ -59,7 +59,7 @@ final class TyrantSuccessionServiceTest {
GameLifecycle.RUNNING, Optional.of(OLD_TYRANT), Optional.of(OLD_VIGILANTE),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
5, 2, Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH),
Set.of(TyrantArmorPiece.CHESTPLATE)
java.util.EnumSet.allOf(TyrantArmorPiece.class)
);
Map<UUID, PlayerState> players = Map.of(
OLD_TYRANT, active(new PlayerState(
@@ -41,6 +41,15 @@ final class TyrantTabCompleterTest {
assertTrue(root.containsAll(List.of(
"menu", "status", "buy", "armor", "assign", "item"
)));
assertTrue(root.contains("gear"));
assertEquals(List.of("axe", "hoe", "pickaxe", "shovel", "sword"),
completer.onTabComplete(player, command, "tyrant", new String[] {"gear", ""}));
assertEquals(List.of("shovel", "sword"),
completer.onTabComplete(player, command, "tyrant", new String[] {"GEAR", "S"}));
assertEquals(List.of(), completer.onTabComplete(
player, command, "tyrant", new String[] {"gear", "axe", ""}));
assertEquals(List.of("boots", "chestplate", "helmet", "leggings"),
completer.onTabComplete(player, command, "tyrant", new String[] {"armor", ""}));
assertFalse(root.contains("SomePlayer"));
assertEquals(List.of("roster_intelligence"), buy);
assertEquals(List.of("chestplate"), armor);
@@ -57,6 +57,35 @@ final class YamlTyrantStateRepositoryTest {
assertEquals(expected, repository.load());
}
@Test
void legacyArmorLoadsWithoutGrantingToolsAndMixedClaimsSurviveRestart() throws Exception {
Path stateFile = temporaryDirectory.resolve("state.yml");
Files.writeString(stateFile, """
game:
lifecycle: RUNNING
tyrant-id: 11111111-1111-1111-1111-111111111111
tyrant-level: 6
unspent-choices: 9
purchases: [ASSASSIN, FIXER, TAMER, ROSTER_INTELLIGENCE, RESISTANCE, STRENGTH]
claimed-armor: [HELMET, BOOTS]
""");
YamlTyrantStateRepository repository = new YamlTyrantStateRepository(stateFile);
GameState loaded = repository.load().game();
assertEquals(Set.of(TyrantArmorPiece.HELMET, TyrantArmorPiece.BOOTS), loaded.claimedArmor());
TyrantArmorService service = new TyrantArmorService();
for (TyrantArmorPiece piece : TyrantArmorPiece.values()) {
if (!piece.isArmor()) {
ArmorClaimResult result = service.claim(loaded, loaded.tyrantId().orElseThrow(), piece);
assertEquals(ArmorClaimStatus.CLAIMED, result.status());
loaded = result.state();
}
}
repository.save(new PersistentState(loaded, Map.of()));
assertEquals(loaded, new YamlTyrantStateRepository(stateFile).load().game());
Files.writeString(stateFile, "game: {}\n");
assertEquals(Set.of(), new YamlTyrantStateRepository(stateFile).load().game().claimedArmor());
}
@Test
void preservesUnknownFieldsForRetainedState() throws Exception {
UUID playerId = UUID.fromString("11111111-1111-1111-1111-111111111111");