feat(armor): add Tyrant legacy rewards
Release / release (push) Successful in 2m39s
CI / build (push) Successful in 1m10s

This commit is contained in:
dmg
2026-09-04 22:54:11 -04:00
parent e5de6b76e7
commit 553db666e1
37 changed files with 673 additions and 22 deletions
@@ -0,0 +1,4 @@
package games.dmg.spigottyrant;
public record ArmorClaimResult(GameState state, ArmorClaimStatus status) {
}
@@ -0,0 +1,10 @@
package games.dmg.spigottyrant;
public enum ArmorClaimStatus {
CLAIMED,
GAME_NOT_RUNNING,
NOT_TYRANT,
UNLOCKS_INCOMPLETE,
NO_CHOICES,
ALREADY_CLAIMED
}
@@ -0,0 +1,22 @@
package games.dmg.spigottyrant;
import java.util.Objects;
import org.bukkit.ChatColor;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
public final class BukkitTyrantArmorItemFactory implements TyrantArmorItemFactory {
@Override
public ItemStack create(TyrantArmorPiece piece, String tyrantName) {
TyrantArmorSpec spec = TyrantArmorSpec.forPiece(piece, tyrantName);
ItemStack item = new ItemStack(spec.material());
ItemMeta meta = Objects.requireNonNull(item.getItemMeta(), "Armor metadata is unavailable");
meta.setDisplayName(ChatColor.GOLD + spec.displayName());
meta.setLore(spec.lore().stream().map(line -> ChatColor.GRAY + line).toList());
meta.addEnchant(Enchantment.PROTECTION, spec.protectionLevel(), true);
meta.addEnchant(Enchantment.MENDING, spec.mendingLevel(), true);
item.setItemMeta(meta);
return item;
}
}
@@ -52,6 +52,14 @@ public final class BukkitTyrantControlPanelRenderer
String intelligence = intelligenceText(model);
inventory.setItem(16, item(Material.SPYGLASS, ChatColor.LIGHT_PURPLE + "Roster Intelligence",
List.of(intelligence, "Click to activate when ready.")));
inventory.setItem(22, item(Material.NETHERITE_CHESTPLATE,
ChatColor.GOLD + "Legacy Armor", List.of(
model.allStandardUnlocksPurchased()
? "Choose a once-per-reign armor reward."
: "Purchase all six standard unlocks first.",
"Claimed: " + (model.claimedArmor().isEmpty()
? "none" : model.claimedArmor())
)));
player.openInventory(inventory);
}
@@ -83,6 +91,10 @@ public final class BukkitTyrantControlPanelRenderer
case ASSIGN_CONFIRMATION -> clickAssignmentConfirmation(
player, event.getRawSlot(), holder.tyrantClass(), holder.targetId()
);
case ARMOR -> clickArmor(player, event.getRawSlot());
case ARMOR_CONFIRMATION -> clickArmorConfirmation(
player, event.getRawSlot(), holder.armorPiece()
);
}
}
@@ -93,6 +105,67 @@ public final class BukkitTyrantControlPanelRenderer
openClasses(player);
} else if (slot == 16) {
openAbilities(player);
} else if (slot == 22) {
openArmor(player);
}
}
private void openArmor(Player player) {
TyrantControlPanelModel model = model(player);
MenuHolder holder = new MenuHolder(MenuView.ARMOR, null, null);
Inventory inventory = create(holder, 27, TITLE + " — Legacy Armor");
TyrantArmorPiece[] pieces = TyrantArmorPiece.values();
int[] slots = {10, 12, 14, 16};
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")
));
}
inventory.setItem(22, backItem());
player.openInventory(inventory);
}
private void clickArmor(Player player, int slot) {
if (slot == 22) {
refresh(player);
return;
}
TyrantArmorPiece[] pieces = TyrantArmorPiece.values();
int[] slots = {10, 12, 14, 16};
for (int index = 0; index < slots.length; index++) {
if (slot == slots[index] && model(player).armorAvailable(pieces[index])) {
openArmorConfirmation(player, pieces[index]);
return;
}
}
}
private void openArmorConfirmation(Player player, TyrantArmorPiece piece) {
MenuHolder holder = new MenuHolder(
MenuView.ARMOR_CONFIRMATION, null, null, null, piece
);
Inventory inventory = create(holder, 27, TITLE + " — Confirm Armor");
inventory.setItem(11, item(Material.RED_WOOL, ChatColor.RED + "Cancel", List.of()));
inventory.setItem(13, item(
TyrantArmorSpec.forPiece(piece, player.getName()).material(),
ChatColor.GOLD + "Tyrant's " + player.getName() + " " + readable(piece.name()),
List.of("Protection V", "Mending I", "Cost: 1 choice")
));
inventory.setItem(15, item(Material.LIME_WOOL, ChatColor.GREEN + "Confirm", List.of()));
player.openInventory(inventory);
}
private void clickArmorConfirmation(Player player, int slot, TyrantArmorPiece piece) {
if (slot == 11) {
openArmor(player);
} else if (slot == 15 && piece != null) {
player.performCommand("tyrant armor " + piece.commandName());
openArmor(player);
}
}
@@ -386,7 +459,9 @@ public final class BukkitTyrantControlPanelRenderer
CLASSES,
ABILITIES,
PLAYERS,
ASSIGN_CONFIRMATION
ASSIGN_CONFIRMATION,
ARMOR,
ARMOR_CONFIRMATION
}
private static final class MenuHolder implements InventoryHolder {
@@ -394,6 +469,7 @@ public final class BukkitTyrantControlPanelRenderer
private final TyrantUnlock unlock;
private final TyrantClass tyrantClass;
private final UUID targetId;
private final TyrantArmorPiece armorPiece;
private Inventory inventory;
private MenuHolder(MenuView view, TyrantUnlock unlock, TyrantClass tyrantClass) {
@@ -405,11 +481,22 @@ public final class BukkitTyrantControlPanelRenderer
TyrantUnlock unlock,
TyrantClass tyrantClass,
UUID targetId
) {
this(view, unlock, tyrantClass, targetId, null);
}
private MenuHolder(
MenuView view,
TyrantUnlock unlock,
TyrantClass tyrantClass,
UUID targetId,
TyrantArmorPiece armorPiece
) {
this.view = view;
this.unlock = unlock;
this.tyrantClass = tyrantClass;
this.targetId = targetId;
this.armorPiece = armorPiece;
}
private MenuView view() {
@@ -428,6 +515,10 @@ public final class BukkitTyrantControlPanelRenderer
return targetId;
}
private TyrantArmorPiece armorPiece() {
return armorPiece;
}
@Override
public Inventory getInventory() {
return inventory;
@@ -52,7 +52,8 @@ public final class GameLifecycleService {
current.accumulatedPausedTime(),
current.tyrantLevel(),
current.unspentChoices(),
current.purchases()
current.purchases(),
current.claimedArmor()
);
return new LifecycleState(paused, state.players());
}
@@ -77,7 +78,8 @@ public final class GameLifecycleService {
current.accumulatedPausedTime().plus(pausedFor),
current.tyrantLevel(),
current.unspentChoices(),
current.purchases()
current.purchases(),
current.claimedArmor()
);
Map<UUID, PlayerState> players = new HashMap<>();
for (PlayerState player : state.players().values()) {
@@ -16,7 +16,8 @@ public record GameState(
Duration accumulatedPausedTime,
int tyrantLevel,
int unspentChoices,
Set<TyrantUnlock> purchases
Set<TyrantUnlock> purchases,
Set<TyrantArmorPiece> claimedArmor
) {
public GameState {
lifecycle = lifecycle == null ? GameLifecycle.UNSTARTED : lifecycle;
@@ -28,6 +29,7 @@ public record GameState(
accumulatedPausedTime = accumulatedPausedTime == null
? Duration.ZERO : accumulatedPausedTime;
purchases = purchases == null ? Set.of() : Set.copyOf(purchases);
claimedArmor = claimedArmor == null ? Set.of() : Set.copyOf(claimedArmor);
if (tyrantId.isPresent() && tyrantId.equals(vigilanteId)) {
throw new IllegalArgumentException("Tyrant and Vigilante must be different players");
}
@@ -42,6 +44,24 @@ public record GameState(
}
}
public GameState(
GameLifecycle lifecycle,
Optional<UUID> tyrantId,
Optional<UUID> vigilanteId,
Optional<PendingSelection> pendingTyrant,
Optional<PendingSelection> pendingVigilante,
Optional<Instant> pausedAt,
Duration accumulatedPausedTime,
int tyrantLevel,
int unspentChoices,
Set<TyrantUnlock> purchases
) {
this(
lifecycle, tyrantId, vigilanteId, pendingTyrant, pendingVigilante, pausedAt,
accumulatedPausedTime, tyrantLevel, unspentChoices, purchases, Set.of()
);
}
public static GameState empty() {
return new GameState(
GameLifecycle.UNSTARTED,
@@ -53,6 +73,7 @@ public record GameState(
Duration.ZERO,
0,
0,
Set.of(),
Set.of()
);
}
@@ -99,7 +99,7 @@ public final class RoleMaintenanceService {
return new GameState(
game.lifecycle(), tyrant, vigilante, pendingTyrant, pendingVigilante,
game.pausedAt(), game.accumulatedPausedTime(), game.tyrantLevel(),
game.unspentChoices(), game.purchases()
game.unspentChoices(), game.purchases(), game.claimedArmor()
);
}
}
@@ -189,7 +189,8 @@ public final class TyrantAdminCommand implements CommandExecutor {
+ game.vigilanteId().map(Object::toString).orElse("none"));
sender.sendMessage("Level: " + game.tyrantLevel()
+ ", choices: " + game.unspentChoices()
+ ", purchases: " + game.purchases());
+ ", purchases: " + game.purchases()
+ ", claimed armor: " + game.claimedArmor());
sender.sendMessage("Pending Tyrant: " + game.pendingTyrant());
sender.sendMessage("Pending Vigilante: " + game.pendingVigilante());
sender.sendMessage("Shared role arena: " + arenaLocations.location()
@@ -0,0 +1,8 @@
package games.dmg.spigottyrant;
import org.bukkit.inventory.ItemStack;
@FunctionalInterface
public interface TyrantArmorItemFactory {
ItemStack create(TyrantArmorPiece piece, String tyrantName);
}
@@ -0,0 +1,22 @@
package games.dmg.spigottyrant;
public enum TyrantArmorPiece {
HELMET,
CHESTPLATE,
LEGGINGS,
BOOTS;
public static TyrantArmorPiece fromCommand(String value) {
return switch (value.toLowerCase(java.util.Locale.ROOT)) {
case "helmet" -> HELMET;
case "chestplate", "chest" -> CHESTPLATE;
case "leggings", "legs" -> LEGGINGS;
case "boots", "feet" -> BOOTS;
default -> throw new IllegalArgumentException("Unknown armor piece");
};
}
public String commandName() {
return name().toLowerCase(java.util.Locale.ROOT);
}
}
@@ -0,0 +1,38 @@
package games.dmg.spigottyrant;
import java.util.EnumSet;
import java.util.Set;
import java.util.UUID;
public final class TyrantArmorService {
public ArmorClaimResult claim(GameState state, UUID claimantId, TyrantArmorPiece piece) {
if (state.lifecycle() != GameLifecycle.RUNNING) {
return result(state, ArmorClaimStatus.GAME_NOT_RUNNING);
}
if (state.tyrantId().filter(claimantId::equals).isEmpty()) {
return result(state, ArmorClaimStatus.NOT_TYRANT);
}
if (!state.purchases().containsAll(EnumSet.allOf(TyrantUnlock.class))) {
return result(state, ArmorClaimStatus.UNLOCKS_INCOMPLETE);
}
if (state.unspentChoices() == 0) {
return result(state, ArmorClaimStatus.NO_CHOICES);
}
if (state.claimedArmor().contains(piece)) {
return result(state, ArmorClaimStatus.ALREADY_CLAIMED);
}
Set<TyrantArmorPiece> claimed = EnumSet.noneOf(TyrantArmorPiece.class);
claimed.addAll(state.claimedArmor());
claimed.add(piece);
GameState updated = new GameState(
state.lifecycle(), state.tyrantId(), state.vigilanteId(), state.pendingTyrant(),
state.pendingVigilante(), state.pausedAt(), state.accumulatedPausedTime(),
state.tyrantLevel(), state.unspentChoices() - 1, state.purchases(), claimed
);
return result(updated, ArmorClaimStatus.CLAIMED);
}
private static ArmorClaimResult result(GameState state, ArmorClaimStatus status) {
return new ArmorClaimResult(state, status);
}
}
@@ -0,0 +1,41 @@
package games.dmg.spigottyrant;
import java.util.List;
import org.bukkit.Material;
public record TyrantArmorSpec(
Material material,
String displayName,
List<String> lore,
int protectionLevel,
int mendingLevel
) {
public TyrantArmorSpec {
lore = List.copyOf(lore);
}
public static TyrantArmorSpec forPiece(TyrantArmorPiece piece, String tyrantName) {
Material material = switch (piece) {
case HELMET -> Material.NETHERITE_HELMET;
case CHESTPLATE -> Material.NETHERITE_CHESTPLATE;
case LEGGINGS -> Material.NETHERITE_LEGGINGS;
case BOOTS -> Material.NETHERITE_BOOTS;
};
String pieceName = readable(piece);
return new TyrantArmorSpec(
material,
"Tyrant's " + tyrantName + " " + pieceName,
List.of(
"Forged for Tyrant " + tyrantName + ".",
"A lasting reward from " + tyrantName + "'s reign."
),
5,
1
);
}
private static String readable(TyrantArmorPiece piece) {
String lower = piece.name().toLowerCase(java.util.Locale.ROOT);
return Character.toUpperCase(lower.charAt(0)) + lower.substring(1);
}
}
@@ -22,6 +22,8 @@ public final class TyrantCommand implements CommandExecutor {
private final TyrantAbilityService tyrantAbilities;
private final TyrantControlPanel controlPanel;
private final RoleControlItemService roleControlItems;
private final TyrantArmorService armor = new TyrantArmorService();
private final TyrantArmorItemFactory armorItems;
public TyrantCommand(
TyrantStateManager stateManager,
@@ -63,6 +65,20 @@ public final class TyrantCommand implements CommandExecutor {
);
}
TyrantCommand(
TyrantStateManager stateManager,
TyrantProgressionService progression,
TyrantArmorItemFactory armorItems
) {
this(
stateManager, progression, new ClassAssignmentService(),
new BukkitOnlinePlayerDirectory(), Clock.systemUTC(),
new ParticipationService(Duration.ofDays(7)), null, null, null,
new TyrantAbilityService(Duration.ofHours(24)), TyrantCommand::unavailablePanel,
null, armorItems
);
}
TyrantCommand(
TyrantStateManager stateManager,
TyrantProgressionService progression,
@@ -129,6 +145,28 @@ public final class TyrantCommand implements CommandExecutor {
TyrantAbilityService tyrantAbilities,
TyrantControlPanel controlPanel,
RoleControlItemService roleControlItems
) {
this(
stateManager, progression, assignments, onlinePlayers, clock, participation,
relinquishment, presentation, abilityItems, tyrantAbilities, controlPanel,
roleControlItems, new BukkitTyrantArmorItemFactory()
);
}
public TyrantCommand(
TyrantStateManager stateManager,
TyrantProgressionService progression,
ClassAssignmentService assignments,
OnlinePlayerDirectory onlinePlayers,
Clock clock,
ParticipationService participation,
RoleRelinquishmentService relinquishment,
TyrantPresentation presentation,
AbilityItemService abilityItems,
TyrantAbilityService tyrantAbilities,
TyrantControlPanel controlPanel,
RoleControlItemService roleControlItems,
TyrantArmorItemFactory armorItems
) {
this.stateManager = stateManager;
this.progression = progression;
@@ -142,6 +180,7 @@ public final class TyrantCommand implements CommandExecutor {
this.tyrantAbilities = tyrantAbilities;
this.controlPanel = controlPanel;
this.roleControlItems = roleControlItems;
this.armorItems = armorItems;
}
@Override
@@ -168,6 +207,10 @@ public final class TyrantCommand implements CommandExecutor {
buy(player, arguments[1]);
return true;
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("armor")) {
claimArmor(player, arguments[1]);
return true;
}
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("assign")) {
assign(player, arguments[1], arguments[2]);
return true;
@@ -198,7 +241,7 @@ public final class TyrantCommand implements CommandExecutor {
}
player.sendMessage(
ChatColor.YELLOW
+ "Usage: /tyrant <menu|status|choices|buy|assign|item|intelligence|optout|optin|"
+ "Usage: /tyrant <menu|status|choices|buy|armor|assign|item|intelligence|optout|optin|"
+ "relinquish confirm>"
);
return true;
@@ -220,6 +263,12 @@ public final class TyrantCommand implements CommandExecutor {
}
player.sendMessage(ChatColor.YELLOW + "[" + status + "] " + unlock.name());
}
if (state.purchases().size() == TyrantUnlock.values().length) {
player.sendMessage(ChatColor.GOLD + "Legacy armor claimed: "
+ (state.claimedArmor().isEmpty() ? "none" : state.claimedArmor()));
player.sendMessage(ChatColor.YELLOW
+ "Use /tyrant armor <helmet|chestplate|leggings|boots>.");
}
}
private void buy(Player player, String requestedUnlock) {
@@ -246,6 +295,36 @@ public final class TyrantCommand implements CommandExecutor {
}
}
private void claimArmor(Player player, String requestedPiece) {
TyrantArmorPiece piece;
try {
piece = TyrantArmorPiece.fromCommand(requestedPiece);
} catch (IllegalArgumentException exception) {
player.sendMessage(ChatColor.RED
+ "Unknown armor piece. Use helmet, chestplate, leggings, or boots.");
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()));
return;
}
int inventorySlot = player.getInventory().firstEmpty();
if (inventorySlot < 0) {
player.sendMessage(ChatColor.RED
+ "Your inventory is full; no choice was consumed.");
return;
}
org.bukkit.inventory.ItemStack item = armorItems.create(piece, player.getName());
stateManager.replaceState(new LifecycleState(result.state(), snapshot.players()));
player.getInventory().setItem(inventorySlot, item);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.GREEN + "Claimed " + piece.commandName()
+ " for one choice.");
}
private void assign(Player tyrant, String requestedClass, String targetName) {
TyrantClass tyrantClass;
try {
@@ -10,11 +10,13 @@ public record TyrantControlPanelModel(
int level,
int unspentChoices,
Map<TyrantUnlock, UnlockAvailability> unlocks,
Set<TyrantArmorPiece> claimedArmor,
Map<TyrantClass, String> classHolders,
Duration intelligenceCooldown
) {
public TyrantControlPanelModel {
unlocks = Map.copyOf(unlocks);
claimedArmor = Set.copyOf(claimedArmor);
classHolders = Map.copyOf(classHolders);
}
@@ -25,6 +27,15 @@ public record TyrantControlPanelModel(
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
public boolean allStandardUnlocksPurchased() {
return purchasedUnlocks().size() == TyrantUnlock.values().length;
}
public boolean armorAvailable(TyrantArmorPiece piece) {
return allStandardUnlocksPurchased() && unspentChoices > 0
&& !claimedArmor.contains(piece);
}
public static TyrantControlPanelModel create(
GameState game,
PlayerState tyrant,
@@ -61,7 +72,8 @@ public record TyrantControlPanelModel(
? Duration.between(now, tyrant.cooldownEnds().get(Ability.ROSTER_INTELLIGENCE))
: Duration.ZERO;
return new TyrantControlPanelModel(
game.tyrantLevel(), game.unspentChoices(), unlocks, holders, cooldown
game.tyrantLevel(), game.unspentChoices(), unlocks, game.claimedArmor(),
holders, cooldown
);
}
}
@@ -69,7 +69,7 @@ public final class TyrantProgressionService {
}
public GameState resetReignProgression(GameState state) {
return copyProgress(state, 0, 0, java.util.Set.of());
return copyProgress(state, 0, 0, java.util.Set.of(), java.util.Set.of());
}
private static GameState copyProgress(
@@ -77,6 +77,16 @@ public final class TyrantProgressionService {
int level,
int choices,
java.util.Set<TyrantUnlock> purchases
) {
return copyProgress(state, level, choices, purchases, state.claimedArmor());
}
private static GameState copyProgress(
GameState state,
int level,
int choices,
java.util.Set<TyrantUnlock> purchases,
java.util.Set<TyrantArmorPiece> claimedArmor
) {
return new GameState(
state.lifecycle(),
@@ -88,7 +98,8 @@ public final class TyrantProgressionService {
state.accumulatedPausedTime(),
level,
choices,
purchases
purchases,
claimedArmor
);
}
}
@@ -12,13 +12,16 @@ import org.bukkit.entity.Player;
public final class TyrantTabCompleter implements TabCompleter {
private static final List<String> SUBCOMMANDS = List.of(
"menu", "status", "choices", "buy", "assign", "item", "intelligence",
"menu", "status", "choices", "buy", "armor", "assign", "item", "intelligence",
"optout", "optin", "relinquish"
);
private static final List<String> UNLOCKS = java.util.Arrays.stream(TyrantUnlock.values())
.map(value -> value.name().toLowerCase(java.util.Locale.ROOT))
.toList();
private static final List<String> CLASSES = List.of("assassin", "fixer", "tamer");
private static final List<String> ARMOR = java.util.Arrays.stream(TyrantArmorPiece.values())
.map(TyrantArmorPiece::commandName)
.toList();
private final TyrantStateManager stateManager;
private final OnlinePlayerDirectory onlinePlayers;
@@ -47,6 +50,7 @@ public final class TyrantTabCompleter implements TabCompleter {
if (arguments.length == 2) {
return switch (subcommand) {
case "buy" -> TabSuggestions.matching(arguments[1], UNLOCKS);
case "armor" -> TabSuggestions.matching(arguments[1], ARMOR);
case "assign" -> TabSuggestions.matching(arguments[1], CLASSES);
case "relinquish" -> TabSuggestions.matching(
arguments[1], List.of("confirm")
@@ -15,7 +15,7 @@ public final class VigilanteArenaSuccessionService {
GameState assigned = new GameState(
game.lifecycle(), game.tyrantId(), Optional.of(winnerId), game.pendingTyrant(),
Optional.empty(), game.pausedAt(), game.accumulatedPausedTime(),
game.tyrantLevel(), game.unspentChoices(), game.purchases()
game.tyrantLevel(), game.unspentChoices(), game.purchases(), game.claimedArmor()
);
return new LifecycleState(assigned, state.players());
}
@@ -95,7 +95,8 @@ public final class YamlTyrantStateRepository {
Duration.ofSeconds(nonNegativeLong(yaml, "game.accumulated-paused-seconds")),
nonNegativeInt(yaml, "game.tyrant-level"),
nonNegativeInt(yaml, "game.unspent-choices"),
enumSet(TyrantUnlock.class, yaml.getStringList("game.purchases"))
enumSet(TyrantUnlock.class, yaml.getStringList("game.purchases")),
enumSet(TyrantArmorPiece.class, yaml.getStringList("game.claimed-armor"))
);
} catch (IllegalArgumentException exception) {
return GameState.empty();
@@ -192,6 +193,10 @@ public final class YamlTyrantStateRepository {
yaml.set("game.tyrant-level", game.tyrantLevel());
yaml.set("game.unspent-choices", game.unspentChoices());
yaml.set("game.purchases", game.purchases().stream().map(Enum::name).sorted().toList());
yaml.set(
"game.claimed-armor",
game.claimedArmor().stream().map(Enum::name).sorted().toList()
);
}
private static void savePlayer(YamlConfiguration yaml, PlayerState player) {
+1 -1
View File
@@ -7,7 +7,7 @@ author: dmg.games
commands:
tyrant:
description: View and use Spigot Tyrant game features.
usage: /tyrant <menu|status|choices|buy|assign|item|intelligence|optout|optin|relinquish confirm>
usage: /tyrant <menu|status|choices|buy|armor <helmet|chestplate|leggings|boots>|assign|item|intelligence|optout|optin|relinquish confirm>
vigilante:
description: Manage Vigilante Followers.
usage: /vigilante <menu|item|invite <player>|accept|dismiss <player>|leave>
@@ -0,0 +1,76 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.util.EnumSet;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class TyrantArmorServiceTest {
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID OTHER = UUID.fromString("22222222-2222-2222-2222-222222222222");
private final TyrantArmorService service = new TyrantArmorService();
@Test
void fullyUnlockedTyrantSpendsOneChoiceOnAnUnclaimedPiece() {
GameState before = game(2, allUnlocks(), Set.of());
ArmorClaimResult result = service.claim(before, TYRANT, TyrantArmorPiece.HELMET);
assertEquals(ArmorClaimStatus.CLAIMED, result.status());
assertEquals(1, result.state().unspentChoices());
assertEquals(Set.of(TyrantArmorPiece.HELMET), result.state().claimedArmor());
assertEquals(before.purchases(), result.state().purchases());
}
@Test
void eachPieceCanBeClaimedOnlyOncePerReign() {
GameState before = game(2, allUnlocks(), Set.of(TyrantArmorPiece.BOOTS));
ArmorClaimResult result = service.claim(before, TYRANT, TyrantArmorPiece.BOOTS);
assertEquals(ArmorClaimStatus.ALREADY_CLAIMED, result.status());
assertEquals(before, result.state());
}
@Test
void rejectsIncompleteUnlocksNoChoicesNonTyrantsAndPausedGames() {
GameState complete = game(0, allUnlocks(), Set.of());
GameState incomplete = game(1, Set.of(TyrantUnlock.ASSASSIN), Set.of());
GameState paused = new GameState(
GameLifecycle.PAUSED, complete.tyrantId(), complete.vigilanteId(),
complete.pendingTyrant(), complete.pendingVigilante(),
Optional.of(java.time.Instant.parse("2026-09-04T00:00:00Z")), Duration.ZERO,
1, 1, allUnlocks(), Set.of()
);
assertEquals(ArmorClaimStatus.NO_CHOICES,
service.claim(complete, TYRANT, TyrantArmorPiece.HELMET).status());
assertEquals(ArmorClaimStatus.UNLOCKS_INCOMPLETE,
service.claim(incomplete, TYRANT, TyrantArmorPiece.HELMET).status());
assertEquals(ArmorClaimStatus.NOT_TYRANT,
service.claim(game(1, allUnlocks(), Set.of()), OTHER,
TyrantArmorPiece.HELMET).status());
assertEquals(ArmorClaimStatus.GAME_NOT_RUNNING,
service.claim(paused, TYRANT, TyrantArmorPiece.HELMET).status());
}
private static Set<TyrantUnlock> allUnlocks() {
return EnumSet.allOf(TyrantUnlock.class);
}
private static GameState game(
int choices,
Set<TyrantUnlock> unlocks,
Set<TyrantArmorPiece> claimed
) {
return new GameState(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
6, choices, unlocks, claimed
);
}
}
@@ -0,0 +1,27 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.bukkit.Material;
import org.junit.jupiter.api.Test;
final class TyrantArmorSpecTest {
@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());
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());
}
}
@@ -10,12 +10,16 @@ import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.command.Command;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@@ -97,6 +101,74 @@ final class TyrantCommandTest {
&& message.contains("ASSASSIN")));
}
@Test
void fullyUnlockedTyrantCanClaimNamedArmorIntoInventory() {
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()
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.snapshot()).thenReturn(new PersistentState(
game, Map.of(tyrantId, PlayerState.newPlayer(tyrantId, "Alex"))
));
PlayerInventory inventory = mock(PlayerInventory.class);
when(inventory.firstEmpty()).thenReturn(4);
Player tyrant = mock(Player.class);
when(tyrant.getUniqueId()).thenReturn(tyrantId);
when(tyrant.getName()).thenReturn("Alex");
when(tyrant.getInventory()).thenReturn(inventory);
ItemStack reward = mock(ItemStack.class);
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
when(factory.create(TyrantArmorPiece.HELMET, "Alex")).thenReturn(reward);
TyrantCommand command = new TyrantCommand(
manager, new TyrantProgressionService(), factory
);
command.onCommand(
tyrant, mock(Command.class), "tyrant", new String[] {"armor", "helmet"}
);
verify(manager).replaceState(org.mockito.ArgumentMatchers.argThat(state ->
state.game().unspentChoices() == 1
&& state.game().claimedArmor().equals(Set.of(TyrantArmorPiece.HELMET))
));
verify(inventory).setItem(4, reward);
verify(manager).saveIfDirty();
}
@Test
void fullInventoryDoesNotConsumeChoiceOrCreateArmor() {
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()
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.snapshot()).thenReturn(new PersistentState(
game, Map.of(tyrantId, PlayerState.newPlayer(tyrantId, "Alex"))
));
PlayerInventory inventory = mock(PlayerInventory.class);
when(inventory.firstEmpty()).thenReturn(-1);
Player tyrant = mock(Player.class);
when(tyrant.getUniqueId()).thenReturn(tyrantId);
when(tyrant.getInventory()).thenReturn(inventory);
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
TyrantCommand command = new TyrantCommand(
manager, new TyrantProgressionService(), factory
);
command.onCommand(
tyrant, mock(Command.class), "tyrant", new String[] {"armor", "boots"}
);
verify(manager, org.mockito.Mockito.never()).replaceState(any());
verify(factory, org.mockito.Mockito.never()).create(any(), any());
verify(tyrant).sendMessage(org.mockito.ArgumentMatchers.contains("inventory is full"));
}
@Test
void assigningClassNotifiesTyrantAndNewHolder() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
@@ -1,9 +1,12 @@
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.time.Instant;
import java.util.EnumSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@@ -46,4 +49,24 @@ final class TyrantControlPanelModelTest {
assertEquals("Blade", model.classHolders().get(TyrantClass.ASSASSIN));
assertEquals(Duration.ofSeconds(90), model.intelligenceCooldown());
}
@Test
void armorAvailabilityRequiresAllUnlocksAChoiceAndAnUnclaimedSlot() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
Instant now = Instant.parse("2026-09-04T00:00:00Z");
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(TyrantArmorPiece.HELMET)
);
PlayerState tyrant = PlayerState.newPlayer(tyrantId, "Tyrant");
TyrantControlPanelModel model = TyrantControlPanelModel.create(
game, tyrant, Map.of(tyrantId, tyrant), now
);
assertTrue(model.allStandardUnlocksPurchased());
assertFalse(model.armorAvailable(TyrantArmorPiece.HELMET));
assertTrue(model.armorAvailable(TyrantArmorPiece.CHESTPLATE));
}
}
@@ -50,7 +50,13 @@ final class TyrantProgressionServiceTest {
@Test
void administrativePointGrantAddsOneLevelAndChoicePerInvocation() {
GameState before = runningState(2, 1, Set.of(TyrantUnlock.ASSASSIN));
GameState base = runningState(2, 1, Set.of(TyrantUnlock.ASSASSIN));
GameState before = new GameState(
base.lifecycle(), base.tyrantId(), base.vigilanteId(), base.pendingTyrant(),
base.pendingVigilante(), base.pausedAt(), base.accumulatedPausedTime(),
base.tyrantLevel(), base.unspentChoices(), base.purchases(),
Set.of(TyrantArmorPiece.HELMET)
);
GameState first = service.grantPoint(before);
GameState second = service.grantPoint(first);
@@ -60,6 +66,7 @@ final class TyrantProgressionServiceTest {
assertEquals(before.purchases(), second.purchases());
assertEquals(before.tyrantId(), second.tyrantId());
assertEquals(before.vigilanteId(), second.vigilanteId());
assertEquals(before.claimedArmor(), second.claimedArmor());
}
@Test
@@ -123,17 +130,24 @@ final class TyrantProgressionServiceTest {
@Test
void endingReignClearsLevelChoicesAndPurchases() {
GameState progressed = runningState(
GameState base = runningState(
4,
2,
Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH)
);
GameState progressed = new GameState(
base.lifecycle(), base.tyrantId(), base.vigilanteId(), base.pendingTyrant(),
base.pendingVigilante(), base.pausedAt(), base.accumulatedPausedTime(),
base.tyrantLevel(), base.unspentChoices(), base.purchases(),
Set.of(TyrantArmorPiece.BOOTS)
);
GameState reset = service.resetReignProgression(progressed);
assertEquals(0, reset.tyrantLevel());
assertEquals(0, reset.unspentChoices());
assertEquals(Set.of(), reset.purchases());
assertEquals(Set.of(), reset.claimedArmor());
}
private static GameState runningState(
@@ -30,6 +30,7 @@ final class TyrantSuccessionServiceTest {
assertEquals(0, after.game().tyrantLevel());
assertEquals(1, after.game().unspentChoices());
assertEquals(Set.of(), after.game().purchases());
assertEquals(Set.of(), after.game().claimedArmor());
assertEquals(Optional.empty(), after.game().vigilanteId());
assertEquals(Optional.empty(), after.game().pendingVigilante());
assertEquals(
@@ -57,7 +58,8 @@ final class TyrantSuccessionServiceTest {
GameState game = new GameState(
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)
5, 2, Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH),
Set.of(TyrantArmorPiece.CHESTPLATE)
);
Map<UUID, PlayerState> players = Map.of(
OLD_TYRANT, active(new PlayerState(
@@ -25,6 +25,12 @@ final class TyrantTabCompleterTest {
List<String> buy = completer.onTabComplete(
player, command, "tyrant", new String[] {"buy", "ro"}
);
List<String> armor = completer.onTabComplete(
player, command, "tyrant", new String[] {"armor", "ch"}
);
List<String> armorExtra = completer.onTabComplete(
player, command, "tyrant", new String[] {"armor", "helmet", ""}
);
List<String> assign = completer.onTabComplete(
player, command, "tyrant", new String[] {"assign", "f"}
);
@@ -32,9 +38,13 @@ final class TyrantTabCompleterTest {
player, command, "tyrant", new String[] {"relinquish", "c"}
);
assertTrue(root.containsAll(List.of("menu", "status", "buy", "assign", "item")));
assertTrue(root.containsAll(List.of(
"menu", "status", "buy", "armor", "assign", "item"
)));
assertFalse(root.contains("SomePlayer"));
assertEquals(List.of("roster_intelligence"), buy);
assertEquals(List.of("chestplate"), armor);
assertEquals(List.of(), armorExtra);
assertEquals(List.of("fixer"), assign);
assertEquals(List.of("confirm"), confirm);
}
@@ -33,7 +33,8 @@ final class YamlTyrantStateRepositoryTest {
Duration.ofMinutes(15),
3,
2,
Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH)
Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH),
Set.of(TyrantArmorPiece.HELMET, TyrantArmorPiece.BOOTS)
);
PlayerState player = new PlayerState(
tyrant,