Compare commits

..
4 Commits
Author SHA1 Message Date
dmg 62f6352633 feat(tyrant): add reign control panel
CI / build (push) Successful in 1m10s
Release / release (push) Successful in 2m42s
2026-08-21 20:47:33 -04:00
dmg 3d65576010 fix(assassin): preserve external potion effects
Release / release (push) Successful in 2m46s
CI / build (push) Successful in 1m8s
2026-08-18 23:50:15 -04:00
dmg 323d177573 feat(participation): highlight opted-out players
Release / release (push) Successful in 2m40s
CI / build (push) Successful in 1m8s
2026-08-15 09:13:34 -04:00
dmg ea267ff867 fix(status): simplify Tyrant join messaging
Release / release (push) Successful in 2m36s
CI / build (push) Successful in 1m5s
2026-08-15 09:09:06 -04:00
31 changed files with 1610 additions and 27 deletions
+47
View File
@@ -6,6 +6,53 @@ description: Chronological record of material decisions affecting the Spigot Tyr
# Spigot Tyrant Design Log
## 2026-08-21 — Tyrant control panel completed
- Completed US-015 and the reopened US-003, US-004, and US-009 control-panel criteria with overview, unlock purchase confirmation, class assignment, and ability screens.
- The active Tyrant can open the panel with `/tyrant` or `/tyrant menu`; existing command alternatives remain available and provide the authoritative mutations behind menu actions.
- Added a 100-tick login delay with online, lifecycle, role, and already-opened session checks so only the current Tyrant receives one automatic panel per login.
- Verified command routing, authorization, overview state, delayed opening safeguards, compiler warnings, tests, and packaging with `./gradlew clean check jar`.
## 2026-08-21 — Tyrant control panel implementation started
- US-003, US-004, US-009, and US-015 begin a test-first implementation of the Tyrant control panel and its delayed login opening.
- The installed Spigot API does not expose a native dialog API, so the control panel will use a server-rendered inventory menu while preserving command alternatives.
## 2026-08-21 — Tyrant control panel planned
- Added US-015 for a graphical Tyrant control panel covering reign overview, unlock purchases, class assignments, and roster intelligence while retaining command alternatives.
- Planned automatic control-panel opening five seconds after the active Tyrant joins a running event, with lifecycle, role, disconnect, and duplicate-opening safeguards.
- Reopened US-003, US-004, and US-009 in the backlog to track their approved control-panel interactions.
## 2026-08-18 — Preserve ordinary potion effects
- Corrected Assassin effect maintenance so it refreshes active Tyrant abilities without removing invisibility, Speed, or Weakness supplied by potions, commands, or other plugins.
- Expired or inapplicable Tyrant-managed effects stop refreshing and expire naturally within their short 30-tick maintenance duration, while double-jump flight cleanup remains immediate.
- Reopened and completed US-005 with regression coverage; verified `./gradlew clean check jar`.
## 2026-08-14 — Opt-out proximity glow completed
- Extended US-011 with a globally visible green outline for opted-out players in the Tyrant's world and configured range while the event is running.
- A periodic controller removes the glow when eligibility, proximity, world, lifecycle, or player availability changes and clears managed state on logout and shutdown.
- Scoreboard presentation records and restores each player's previous team and glowing state instead of permanently replacing unrelated presentation.
- Verified inclusive range behavior, lifecycle and availability gating, green presentation, restoration, and the full Gradle build.
## 2026-08-14 — Opt-out proximity glow started
- US-011 is reopened for a test-first green outline on opted-out players near the Tyrant during a running event, using the existing configurable Tyrant range.
- Cleanup will cover range, world, participation, lifecycle, availability, shutdown, and restoration of pre-existing scoreboard-team membership.
## 2026-08-14 — Join messaging refinement completed
- Updated US-011 so join output no longer announces running, paused, or unstarted lifecycle state.
- Eligible unassigned players now see the neutral prompt, `Use /tyrant optout if you do not want to participate in Tyrant events.`
- Role, class, ability, cooldown, and existing opt-out details remain unchanged.
- Verified both assigned and unassigned join output with automated tests.
## 2026-08-14 — Join messaging refinement started
- US-011 is reopened to remove lifecycle announcements from join output and make the opt-out prompt refer generally to Tyrant events.
## 2026-08-14 — Opt-out combat protection completed
- Extended US-011 with bidirectional combat isolation between opted-out players and the Tyrant, Assassin, Fixer, or Tamer while the event is running.
+1
View File
@@ -14,3 +14,4 @@
12. [US-012: Configure and persist game state](us-012-configure-and-persist-state.md)
13. [US-013: Build, test, and release the plugin](us-013-build-test-and-release.md)
14. [US-014: Use class ability items](us-014-use-class-ability-items.md)
15. [US-015: Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
@@ -19,9 +19,13 @@ As the **Tyrant**, I want to earn one meaningful choice for defeating the Vigila
- [x] A purchase consumes exactly one unspent choice and cannot be duplicated by retries or concurrent input.
- [x] Progress cannot be earned or spent while the game is paused.
- [x] Levels, purchases, and unspent choices survive logout and restart but reset when the reign ends.
- [x] The Tyrant can review and purchase unlocks through the Tyrant control panel.
- [x] A confirmation displays the selected unlock and choice cost before purchase.
- [x] The control panel refreshes the available choices immediately after a successful purchase.
## Related
- [Role and class model](../concepts/role-and-class-model.md)
- [Assign unlocked classes](us-004-assign-unlocked-classes.md)
- [Purchase and use Tyrant abilities](us-009-use-tyrant-abilities.md)
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
@@ -19,8 +19,12 @@ As the **Tyrant**, I want to assign unlocked specialist classes so that I can bu
- [x] Assignment and removal are stored by UUID and communicated clearly to affected players.
- [x] A class holder is reminded of the assignment and its controls on login.
- [x] All class assignments end when the Tyrant's reign ends.
- [x] The Tyrant can assign and reassign purchased classes through the Tyrant control panel.
- [x] The control panel's player selector communicates eligibility and identifies the current class holder before confirmation.
- [x] Control-panel and command-based assignments enforce identical rules.
## Related
- [Role and class model](../concepts/role-and-class-model.md)
- [Use class ability items](us-014-use-class-ability-items.md)
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
@@ -20,6 +20,13 @@ As the **Assassin**, I want stealth and burst mobility so that I can ambush oppo
- [x] Grounding and supported movement reset jump availability only after the 60-second cooldown has elapsed.
- [x] Ability cooldowns use unpaused elapsed time and survive death, logout, and restart.
- [x] The player can inspect remaining cooldowns and receives clear feedback when an activation is unavailable.
- [x] Tyrant removes only Assassin potion effects that Tyrant itself applied.
- [x] Invisibility from potions, commands, or other plugins remains active for Assassins and non-Assassins.
- [x] Tyrant-managed Assassin effects are still removed when abilities expire, roles change, games stop, or the plugin disables.
## Validation
Automated controller tests verify that inactive Tyrant state does not alter ordinary potion effects, active Assassin invisibility is refreshed, and refresh stops at expiry so the short managed effect expires naturally. The complete `./gradlew clean check jar` lifecycle passes.
## Related
@@ -19,8 +19,11 @@ As the **Tyrant**, I want permanent upgrades and limited roster intelligence so
- [x] Intelligence counts the current Vigilante and living assigned Followers, whether online or offline, according to configurable counting rules.
- [x] The Tyrant can inspect ability ownership and the remaining intelligence cooldown.
- [x] Purchases and cooldowns survive restart and end with the Tyrant's reign.
- [x] The Tyrant control panel shows purchased passive upgrades and the roster-intelligence readiness or remaining cooldown.
- [x] The Tyrant can activate ready roster intelligence from the control panel and receive the result without revealing identities or locations.
## Related
- [Progress and purchase Tyrant unlocks](us-003-progress-and-purchase-unlocks.md)
- [Configure and persist game state](us-012-configure-and-persist-state.md)
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
@@ -11,7 +11,8 @@ As a **player**, I want to understand my status and control my eligibility so th
## Acceptance criteria
- [x] On join, a player is told the game status and their role, class, subclass, abilities, and relevant cooldowns.
- [x] On join, lifecycle state is omitted while role, class, subclass, ability, and relevant cooldown information remains available when applicable.
- [x] Eligible players are invited to opt out of Tyrant events without implying that an event is currently running.
- [x] An unassigned opted-in player is told that they are eligible and shown the opt-out command.
- [x] An opted-out player is told when they may opt back in.
- [x] An unassigned player can opt out through a command.
@@ -25,6 +26,10 @@ As a **player**, I want to understand my status and control my eligibility so th
- [x] Cancelled attackers are told that the target has chosen not to take part in the Tyrant event.
- [x] Protection covers direct player attacks and player-fired projectiles without changing unrelated combat.
- [x] Participation combat protection does not apply while the event is paused or not started.
- [x] While the event is running, an opted-out player in the same world and within the configured Tyrant range has a green glowing outline visible to everyone.
- [x] The opt-out glow is removed when proximity, participation, player availability, Tyrant availability, or running-event requirements stop being satisfied.
- [x] Existing scoreboard-team membership is restored after the opt-out glow ends, and plugin shutdown safely clears managed glow state.
- [x] Opt-out proximity glow does not apply while the event is paused or not started.
## Related
@@ -0,0 +1,44 @@
---
type: User Story
title: "US-015: Manage a reign through the Tyrant control panel"
description: Give the active Tyrant a graphical control panel for inspecting and managing the current reign.
status: done
---
# US-015: Manage a reign through the Tyrant control panel
As the **Tyrant**, I want a control panel for my reign so that I can inspect progress, purchase unlocks, assign classes, and use abilities without memorizing commands.
## Acceptance criteria
- [x] The active Tyrant can open the control panel with `/tyrant menu`, and `/tyrant` with no arguments opens the same panel.
- [x] A player who is not the active Tyrant cannot open or operate the control panel.
- [x] When the active Tyrant joins while the event is running, the control panel opens automatically five seconds after login.
- [x] Automatic opening is cancelled if the Tyrant disconnects, loses the Tyrant role, or the event pauses or stops during the five-second delay.
- [x] Other players and pending Tyrant candidates do not receive the automatically opened control panel.
- [x] Closing an automatically opened control panel does not cause it to reopen during the same login session.
- [x] The Tyrant may open the control panel manually during the five-second delay without causing a second panel to open automatically.
- [x] The main screen shows the Tyrant's level, unspent choices, purchased unlocks, class assignments, and roster-intelligence cooldown.
- [x] Purchased, available, unavailable, and cooldown-gated actions are visually distinguishable and explain why they cannot currently be used.
- [x] The Tyrant can navigate between overview, unlock, class-assignment, and ability screens and can close the panel without changing gameplay state.
- [x] The Tyrant can review and purchase an available unlock after confirming the selected unlock and its choice cost.
- [x] The Tyrant can select a purchased class and assign or reassign it to an eligible online player.
- [x] The class player selector excludes the Tyrant and clearly identifies players who are opted out or otherwise ineligible.
- [x] Class reassignment identifies the current holder and requires confirmation before replacing that holder.
- [x] The Tyrant can activate roster intelligence from the panel when the ability is purchased and ready.
- [x] A successful or rejected action refreshes the panel from authoritative current state and communicates the result clearly.
- [x] Panel actions use the same domain rules and persistence behavior as their command equivalents.
- [x] Stale, repeated, or concurrent panel interactions cannot duplicate purchases, bypass cooldowns, or create conflicting assignments.
- [x] Panel actions become unavailable immediately if the game pauses, the reign ends, or the player is no longer the Tyrant.
- [x] Existing `/tyrant choices`, `/tyrant buy`, `/tyrant assign`, and `/tyrant intelligence` commands remain available as alternatives.
## Validation
Automated tests verify command entry points, active-Tyrant authorization, authoritative overview state, the exact 100-tick login delay, lifecycle cancellation, and duplicate-opening prevention. The complete `./gradlew clean check jar` lifecycle passes.
## Related
- [Progress and purchase Tyrant unlocks](us-003-progress-and-purchase-unlocks.md)
- [Assign unlocked classes](us-004-assign-unlocked-classes.md)
- [Purchase and use Tyrant abilities](us-009-use-tyrant-abilities.md)
- [Inform players and manage participation](us-011-inform-and-manage-participation.md)
@@ -3,6 +3,7 @@ package games.dmg.spigottyrant;
import java.time.Clock;
import java.time.Instant;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import org.bukkit.GameMode;
@@ -17,6 +18,7 @@ public final class AssassinEffectController implements Runnable {
private final Server server;
private final PluginSettings settings;
private final Clock clock;
private final EffectApplier effectApplier;
private final Set<UUID> grantedFlight = new HashSet<>();
public AssassinEffectController(
@@ -25,10 +27,21 @@ public final class AssassinEffectController implements Runnable {
PluginSettings settings,
Clock clock
) {
this.stateManager = stateManager;
this.server = server;
this.settings = settings;
this.clock = clock;
this(stateManager, server, settings, clock, new BukkitEffectApplier());
}
AssassinEffectController(
TyrantStateManager stateManager,
Server server,
PluginSettings settings,
Clock clock,
EffectApplier effectApplier
) {
this.stateManager = Objects.requireNonNull(stateManager, "stateManager");
this.server = Objects.requireNonNull(server, "server");
this.settings = Objects.requireNonNull(settings, "settings");
this.clock = Objects.requireNonNull(clock, "clock");
this.effectApplier = Objects.requireNonNull(effectApplier, "effectApplier");
}
@Override
@@ -43,15 +56,15 @@ public final class AssassinEffectController implements Runnable {
}
applyWhileActive(
player, state, Ability.ASSASSIN_INVISIBILITY_ACTIVE,
PotionEffectType.INVISIBILITY, 0, false
EffectKind.INVISIBILITY, 0, false
);
applyWhileActive(
player, state, Ability.ASSASSIN_SPEED_ACTIVE,
PotionEffectType.SPEED, 0, true
EffectKind.SPEED, 0, true
);
applyWhileActive(
player, state, Ability.ASSASSIN_WEAKNESS_ACTIVE,
PotionEffectType.WEAKNESS, settings.assassinWeaknessLevel() - 1, true
EffectKind.WEAKNESS, settings.assassinWeaknessLevel() - 1, true
);
boolean jumpReady = !state.cooldownEnds().getOrDefault(
Ability.ASSASSIN_DOUBLE_JUMP, Instant.MIN
@@ -71,29 +84,52 @@ public final class AssassinEffectController implements Runnable {
Player player,
PlayerState state,
Ability ability,
PotionEffectType type,
EffectKind effect,
int amplifier,
boolean particles
) {
if (state.cooldownEnds().getOrDefault(ability, Instant.MIN).isAfter(clock.instant())) {
player.addPotionEffect(new PotionEffect(
type, EFFECT_TICKS, amplifier, false, particles, true
));
} else {
player.removePotionEffect(type);
effectApplier.apply(player, effect, amplifier, particles);
}
}
private void clear(Player player) {
player.removePotionEffect(PotionEffectType.INVISIBILITY);
player.removePotionEffect(PotionEffectType.SPEED);
player.removePotionEffect(PotionEffectType.WEAKNESS);
if (grantedFlight.remove(player.getUniqueId()) && isSurvivalLike(player)) {
player.setAllowFlight(false);
player.setFlying(false);
}
}
enum EffectKind {
INVISIBILITY,
SPEED,
WEAKNESS
}
@FunctionalInterface
interface EffectApplier {
void apply(Player player, EffectKind effect, int amplifier, boolean particles);
}
private static final class BukkitEffectApplier implements EffectApplier {
@Override
public void apply(
Player player,
EffectKind effect,
int amplifier,
boolean particles
) {
PotionEffectType type = switch (effect) {
case INVISIBILITY -> PotionEffectType.INVISIBILITY;
case SPEED -> PotionEffectType.SPEED;
case WEAKNESS -> PotionEffectType.WEAKNESS;
};
player.addPotionEffect(new PotionEffect(
type, EFFECT_TICKS, amplifier, false, particles, true
));
}
}
private static boolean isGrounded(Player player) {
return !player.getLocation().clone().subtract(0.0, 0.1, 0.0)
.getBlock().isPassable();
@@ -0,0 +1,434 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
public final class BukkitTyrantControlPanelRenderer
implements TyrantControlPanelRenderer, Listener {
private static final String TITLE = ChatColor.DARK_RED + "Tyrant Control Panel";
private final Server server;
private final TyrantStateManager stateManager;
private final Clock clock;
public BukkitTyrantControlPanelRenderer(
Server server,
TyrantStateManager stateManager,
Clock clock
) {
this.server = server;
this.stateManager = stateManager;
this.clock = clock;
}
@Override
public void openOverview(Player player, TyrantControlPanelModel model) {
MenuHolder holder = new MenuHolder(MenuView.OVERVIEW, null, null);
Inventory inventory = create(holder, 27, TITLE);
inventory.setItem(10, item(Material.NETHER_STAR, ChatColor.GOLD + "Reign Overview", List.of(
"Level: " + model.level(),
"Unspent choices: " + model.unspentChoices(),
"Purchased: " + (model.purchasedUnlocks().isEmpty()
? "none" : model.purchasedUnlocks())
)));
inventory.setItem(12, item(Material.ENCHANTED_BOOK, ChatColor.YELLOW + "Unlocks",
List.of("Review and purchase reign unlocks.")));
inventory.setItem(14, item(Material.ARMOR_STAND, ChatColor.AQUA + "Class Assignments",
classSummary(model)));
String intelligence = intelligenceText(model);
inventory.setItem(16, item(Material.SPYGLASS, ChatColor.LIGHT_PURPLE + "Roster Intelligence",
List.of(intelligence, "Click to activate when ready.")));
player.openInventory(inventory);
}
@EventHandler
public void onClick(InventoryClickEvent event) {
if (!(event.getWhoClicked() instanceof Player player)
|| !(event.getView().getTopInventory().getHolder() instanceof MenuHolder holder)) {
return;
}
event.setCancelled(true);
if (event.getRawSlot() < 0
|| event.getRawSlot() >= event.getView().getTopInventory().getSize()) {
return;
}
if (!isActiveTyrant(player)) {
player.closeInventory();
player.sendMessage(ChatColor.RED + "This Tyrant control panel is no longer active.");
return;
}
switch (holder.view()) {
case OVERVIEW -> clickOverview(player, event.getRawSlot());
case UNLOCKS -> clickUnlocks(player, event.getRawSlot());
case PURCHASE_CONFIRMATION -> clickPurchaseConfirmation(
player, event.getRawSlot(), holder.unlock()
);
case CLASSES -> clickClasses(player, event.getRawSlot());
case ABILITIES -> clickAbilities(player, event.getRawSlot());
case PLAYERS -> clickPlayers(player, event.getRawSlot(), holder.tyrantClass());
case ASSIGN_CONFIRMATION -> clickAssignmentConfirmation(
player, event.getRawSlot(), holder.tyrantClass(), holder.targetId()
);
}
}
private void clickOverview(Player player, int slot) {
if (slot == 12) {
openUnlocks(player);
} else if (slot == 14) {
openClasses(player);
} else if (slot == 16) {
openAbilities(player);
}
}
private void openAbilities(Player player) {
TyrantControlPanelModel model = model(player);
MenuHolder holder = new MenuHolder(MenuView.ABILITIES, null, null);
Inventory inventory = create(holder, 27, TITLE + " — Abilities");
inventory.setItem(11, item(Material.BLAZE_POWDER, ChatColor.RED + "Permanent Strength",
List.of(model.purchasedUnlocks().contains(TyrantUnlock.STRENGTH)
? "Purchased and active." : "Not purchased.")));
inventory.setItem(13, item(Material.IRON_CHESTPLATE,
ChatColor.BLUE + "Permanent Resistance",
List.of(model.purchasedUnlocks().contains(TyrantUnlock.RESISTANCE)
? "Purchased and active." : "Not purchased.")));
inventory.setItem(15, item(Material.SPYGLASS, ChatColor.LIGHT_PURPLE
+ "Roster Intelligence", List.of(
intelligenceText(model), "Click to activate when ready."
)));
inventory.setItem(22, backItem());
player.openInventory(inventory);
}
private void clickAbilities(Player player, int slot) {
if (slot == 22) {
refresh(player);
} else if (slot == 15) {
player.performCommand("tyrant intelligence");
openAbilities(player);
}
}
private void openUnlocks(Player player) {
TyrantControlPanelModel model = model(player);
MenuHolder holder = new MenuHolder(MenuView.UNLOCKS, null, null);
Inventory inventory = create(holder, 27, TITLE + " — Unlocks");
int[] slots = {10, 11, 12, 14, 15, 16};
TyrantUnlock[] unlocks = TyrantUnlock.values();
for (int index = 0; index < unlocks.length; index++) {
TyrantUnlock unlock = unlocks[index];
UnlockAvailability availability = model.unlocks().get(unlock);
Material material = switch (availability) {
case PURCHASED -> Material.LIME_DYE;
case AVAILABLE -> Material.GOLD_INGOT;
case UNAVAILABLE -> Material.GRAY_DYE;
};
inventory.setItem(slots[index], item(material,
ChatColor.YELLOW + readable(unlock.name()),
List.of("Status: " + availability, "Cost: 1 choice")));
}
inventory.setItem(22, backItem());
player.openInventory(inventory);
}
private void clickUnlocks(Player player, int slot) {
if (slot == 22) {
refresh(player);
return;
}
int[] slots = {10, 11, 12, 14, 15, 16};
TyrantUnlock[] unlocks = TyrantUnlock.values();
for (int index = 0; index < slots.length; index++) {
if (slot == slots[index]
&& model(player).unlocks().get(unlocks[index])
== UnlockAvailability.AVAILABLE) {
openPurchaseConfirmation(player, unlocks[index]);
return;
}
}
}
private void openPurchaseConfirmation(Player player, TyrantUnlock unlock) {
MenuHolder holder = new MenuHolder(MenuView.PURCHASE_CONFIRMATION, unlock, null);
Inventory inventory = create(holder, 27, TITLE + " — Confirm Purchase");
inventory.setItem(11, item(Material.RED_WOOL, ChatColor.RED + "Cancel", List.of()));
inventory.setItem(13, item(Material.ENCHANTED_BOOK, ChatColor.YELLOW + readable(unlock.name()),
List.of("Cost: 1 choice")));
inventory.setItem(15, item(Material.LIME_WOOL, ChatColor.GREEN + "Confirm", List.of()));
player.openInventory(inventory);
}
private void clickPurchaseConfirmation(Player player, int slot, TyrantUnlock unlock) {
if (slot == 11) {
openUnlocks(player);
} else if (slot == 15 && unlock != null) {
player.performCommand("tyrant buy " + unlock.name().toLowerCase(Locale.ROOT));
openUnlocks(player);
}
}
private void openClasses(Player player) {
TyrantControlPanelModel model = model(player);
MenuHolder holder = new MenuHolder(MenuView.CLASSES, null, null);
Inventory inventory = create(holder, 27, TITLE + " — Classes");
TyrantClass[] classes = {TyrantClass.ASSASSIN, TyrantClass.FIXER, TyrantClass.TAMER};
int[] slots = {11, 13, 15};
for (int index = 0; index < classes.length; index++) {
TyrantClass tyrantClass = classes[index];
boolean purchased = stateManager.game().purchases().contains(unlockFor(tyrantClass));
inventory.setItem(slots[index], item(
purchased ? Material.ARMOR_STAND : Material.BARRIER,
ChatColor.AQUA + readable(tyrantClass.name()),
List.of("Holder: " + model.classHolders().get(tyrantClass),
purchased ? "Click to assign." : "Unlock not purchased.")));
}
inventory.setItem(22, backItem());
player.openInventory(inventory);
}
private void clickClasses(Player player, int slot) {
if (slot == 22) {
refresh(player);
return;
}
TyrantClass tyrantClass = switch (slot) {
case 11 -> TyrantClass.ASSASSIN;
case 13 -> TyrantClass.FIXER;
case 15 -> TyrantClass.TAMER;
default -> null;
};
if (tyrantClass != null
&& stateManager.game().purchases().contains(unlockFor(tyrantClass))) {
openPlayers(player, tyrantClass);
}
}
private void openPlayers(Player player, TyrantClass tyrantClass) {
MenuHolder holder = new MenuHolder(MenuView.PLAYERS, null, tyrantClass);
Inventory inventory = create(holder, 54, TITLE + " — Select Player");
int slot = 0;
for (Player candidate : server.getOnlinePlayers()) {
if (slot >= 45 || candidate.getUniqueId().equals(player.getUniqueId())) {
continue;
}
PlayerState state = stateManager.player(candidate.getUniqueId(), candidate.getName());
String eligibility = state.optedOutUntil().isPresent()
? ChatColor.RED + "Ineligible: opted out"
: state.tyrantClass() != TyrantClass.NONE
&& state.tyrantClass() != tyrantClass
? ChatColor.RED + "Ineligible: already " + state.tyrantClass()
: ChatColor.GREEN + "Eligible";
inventory.setItem(slot, item(Material.PLAYER_HEAD, candidate.getName(),
List.of(eligibility, candidate.getUniqueId().toString())));
slot++;
}
inventory.setItem(49, backItem());
player.openInventory(inventory);
}
private void clickPlayers(Player player, int slot, TyrantClass tyrantClass) {
if (slot == 49) {
openClasses(player);
return;
}
ItemStack clicked = player.getOpenInventory().getTopInventory().getItem(slot);
if (clicked == null || clicked.getType() != Material.PLAYER_HEAD) {
return;
}
List<String> lore = clicked.getItemMeta().getLore();
if (lore == null || lore.size() < 2 || !ChatColor.stripColor(lore.get(0)).equals("Eligible")) {
return;
}
try {
openAssignmentConfirmation(player, tyrantClass, UUID.fromString(lore.get(1)));
} catch (IllegalArgumentException ignored) {
player.sendMessage(ChatColor.RED + "That player selection is no longer valid.");
}
}
private void openAssignmentConfirmation(
Player player,
TyrantClass tyrantClass,
UUID targetId
) {
Player target = server.getPlayer(targetId);
if (target == null) {
player.sendMessage(ChatColor.RED + "That player is no longer online.");
openPlayers(player, tyrantClass);
return;
}
MenuHolder holder = new MenuHolder(
MenuView.ASSIGN_CONFIRMATION, null, tyrantClass, targetId
);
Inventory inventory = create(holder, 27, TITLE + " — Confirm Assignment");
String current = model(player).classHolders().get(tyrantClass);
inventory.setItem(11, item(Material.RED_WOOL, ChatColor.RED + "Cancel", List.of()));
inventory.setItem(13, item(Material.ARMOR_STAND,
ChatColor.YELLOW + target.getName(),
List.of("Class: " + tyrantClass, "Current holder: " + current)));
inventory.setItem(15, item(Material.LIME_WOOL, ChatColor.GREEN + "Confirm", List.of()));
player.openInventory(inventory);
}
private void clickAssignmentConfirmation(
Player player,
int slot,
TyrantClass tyrantClass,
UUID targetId
) {
if (slot == 11) {
openPlayers(player, tyrantClass);
} else if (slot == 15 && tyrantClass != null && targetId != null) {
Player target = server.getPlayer(targetId);
if (target == null) {
player.sendMessage(ChatColor.RED + "That player is no longer online.");
} else {
player.performCommand("tyrant assign "
+ tyrantClass.name().toLowerCase(Locale.ROOT) + " " + target.getName());
}
openClasses(player);
}
}
private boolean isActiveTyrant(Player player) {
GameState game = stateManager.game();
return game.lifecycle() == GameLifecycle.RUNNING
&& game.tyrantId().filter(player.getUniqueId()::equals).isPresent();
}
private TyrantControlPanelModel model(Player player) {
return TyrantControlPanelModel.create(
stateManager.game(),
stateManager.player(player.getUniqueId(), player.getName()),
stateManager.players(),
clock.instant()
);
}
private void refresh(Player player) {
openOverview(player, model(player));
}
private Inventory create(MenuHolder holder, int size, String title) {
Inventory inventory = server.createInventory(holder, size, title);
holder.inventory = inventory;
return inventory;
}
private static ItemStack item(Material material, String name, List<String> lore) {
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
meta.setDisplayName(name);
meta.setLore(lore.stream().map(line -> ChatColor.GRAY + line).toList());
item.setItemMeta(meta);
return item;
}
private static ItemStack backItem() {
return item(Material.ARROW, ChatColor.YELLOW + "Back", List.of());
}
private static List<String> classSummary(TyrantControlPanelModel model) {
List<String> summary = new ArrayList<>();
for (TyrantClass tyrantClass : new TyrantClass[] {
TyrantClass.ASSASSIN, TyrantClass.FIXER, TyrantClass.TAMER
}) {
summary.add(readable(tyrantClass.name()) + ": "
+ model.classHolders().get(tyrantClass));
}
return summary;
}
private static String intelligenceText(TyrantControlPanelModel model) {
if (model.unlocks().get(TyrantUnlock.ROSTER_INTELLIGENCE)
!= UnlockAvailability.PURCHASED) {
return "Unavailable: unlock not purchased.";
}
Duration cooldown = model.intelligenceCooldown();
return cooldown.isZero() ? "Ready" : "Cooldown: " + cooldown.toSeconds() + "s";
}
private static TyrantUnlock unlockFor(TyrantClass tyrantClass) {
return switch (tyrantClass) {
case ASSASSIN -> TyrantUnlock.ASSASSIN;
case FIXER -> TyrantUnlock.FIXER;
case TAMER -> TyrantUnlock.TAMER;
case NONE -> throw new IllegalArgumentException("NONE is not assignable");
};
}
private static String readable(String value) {
String lower = value.toLowerCase(Locale.ROOT).replace('_', ' ');
return Character.toUpperCase(lower.charAt(0)) + lower.substring(1);
}
private enum MenuView {
OVERVIEW,
UNLOCKS,
PURCHASE_CONFIRMATION,
CLASSES,
ABILITIES,
PLAYERS,
ASSIGN_CONFIRMATION
}
private static final class MenuHolder implements InventoryHolder {
private final MenuView view;
private final TyrantUnlock unlock;
private final TyrantClass tyrantClass;
private final UUID targetId;
private Inventory inventory;
private MenuHolder(MenuView view, TyrantUnlock unlock, TyrantClass tyrantClass) {
this(view, unlock, tyrantClass, null);
}
private MenuHolder(
MenuView view,
TyrantUnlock unlock,
TyrantClass tyrantClass,
UUID targetId
) {
this.view = view;
this.unlock = unlock;
this.tyrantClass = tyrantClass;
this.targetId = targetId;
}
private MenuView view() {
return view;
}
private TyrantUnlock unlock() {
return unlock;
}
private TyrantClass tyrantClass() {
return tyrantClass;
}
private UUID targetId() {
return targetId;
}
@Override
public Inventory getInventory() {
return inventory;
}
}
}
@@ -0,0 +1,57 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.entity.Player;
public final class DefaultTyrantControlPanel implements TyrantControlPanel {
private final TyrantStateManager stateManager;
private final TyrantControlPanelRenderer renderer;
private final Clock clock;
private final Set<UUID> openedSessions = new HashSet<>();
public DefaultTyrantControlPanel(
TyrantStateManager stateManager,
TyrantControlPanelRenderer renderer,
Clock clock
) {
this.stateManager = stateManager;
this.renderer = renderer;
this.clock = clock;
}
@Override
public void open(Player player) {
GameState game = stateManager.game();
if (game.lifecycle() != GameLifecycle.RUNNING
|| game.tyrantId().filter(player.getUniqueId()::equals).isEmpty()) {
player.sendMessage(ChatColor.RED
+ "Only the active Tyrant can open the control panel while the event is running.");
return;
}
PlayerState tyrant = stateManager.player(player.getUniqueId(), player.getName());
renderer.openOverview(
player,
TyrantControlPanelModel.create(game, tyrant, stateManager.players(), clock.instant())
);
openedSessions.add(player.getUniqueId());
}
@Override
public void beginSession(UUID playerId) {
openedSessions.remove(playerId);
}
@Override
public void endSession(UUID playerId) {
openedSessions.remove(playerId);
}
@Override
public boolean openedThisSession(UUID playerId) {
return openedSessions.contains(playerId);
}
}
@@ -0,0 +1,6 @@
package games.dmg.spigottyrant;
@FunctionalInterface
public interface DelayedTaskScheduler {
void schedule(Runnable task, long delayTicks);
}
@@ -0,0 +1,127 @@
package games.dmg.spigottyrant;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.ScoreboardManager;
import org.bukkit.scoreboard.Team;
public final class OptOutGlowController implements Runnable, Listener {
private static final String TEAM_NAME = "tyrant-optout";
private final TyrantStateManager stateManager;
private final Server server;
private final PluginSettings settings;
private final Scoreboard scoreboard;
private final Team greenTeam;
private final Map<UUID, PreviousPresentation> managed = new HashMap<>();
public OptOutGlowController(
TyrantStateManager stateManager,
Server server,
PluginSettings settings
) {
this.stateManager = stateManager;
this.server = server;
this.settings = settings;
ScoreboardManager manager = Objects.requireNonNull(
server.getScoreboardManager(), "Scoreboard manager is unavailable"
);
scoreboard = manager.getMainScoreboard();
Team existing = scoreboard.getTeam(TEAM_NAME);
greenTeam = existing == null ? scoreboard.registerNewTeam(TEAM_NAME) : existing;
greenTeam.setColor(ChatColor.GREEN);
}
@Override
public void run() {
GameState game = stateManager.game();
Player tyrant = game.tyrantId().map(server::getPlayer).orElse(null);
boolean tyrantAvailable = tyrant != null && !tyrant.isDead();
Set<UUID> online = new HashSet<>();
for (Player player : server.getOnlinePlayers()) {
online.add(player.getUniqueId());
PlayerState state = stateManager.players().get(player.getUniqueId());
boolean sameWorld = tyrantAvailable && !player.isDead()
&& player.getWorld().equals(tyrant.getWorld());
double distanceSquared = sameWorld
? player.getLocation().distanceSquared(tyrant.getLocation())
: Double.POSITIVE_INFINITY;
boolean shouldGlow = state != null && OptOutGlowPolicy.shouldGlow(
game, state, tyrantAvailable, sameWorld, distanceSquared,
settings.tyrantRangeBlocks()
);
if (shouldGlow && !managed.containsKey(player.getUniqueId())) {
apply(player);
} else if (!shouldGlow && managed.containsKey(player.getUniqueId())) {
restore(player.getUniqueId(), player);
}
}
for (UUID playerId : Set.copyOf(managed.keySet())) {
if (!online.contains(playerId) && server.getPlayer(playerId) == null) {
restore(playerId, null);
}
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent event) {
if (managed.containsKey(event.getPlayer().getUniqueId())) {
restore(event.getPlayer().getUniqueId(), event.getPlayer());
}
}
public void clearAll() {
for (UUID playerId : Set.copyOf(managed.keySet())) {
restore(playerId, server.getPlayer(playerId));
}
}
private void apply(Player player) {
Team previousTeam = scoreboard.getEntryTeam(player.getName());
if (previousTeam == greenTeam) {
previousTeam = null;
}
managed.put(
player.getUniqueId(),
new PreviousPresentation(player.getName(), previousTeam, player.isGlowing())
);
greenTeam.addEntry(player.getName());
player.setGlowing(true);
}
private void restore(UUID playerId, Player player) {
PreviousPresentation previous = managed.remove(playerId);
if (previous == null) {
return;
}
greenTeam.removeEntry(previous.playerName());
if (previous.team() != null) {
try {
previous.team().addEntry(previous.playerName());
} catch (IllegalStateException ignored) {
// The prior team was removed while this plugin managed the entry.
}
}
if (player != null) {
player.setGlowing(previous.glowing());
}
}
private record PreviousPresentation(
String playerName,
Team team,
boolean glowing
) {
}
}
@@ -0,0 +1,22 @@
package games.dmg.spigottyrant;
public final class OptOutGlowPolicy {
private OptOutGlowPolicy() {
}
public static boolean shouldGlow(
GameState game,
PlayerState player,
boolean tyrantAvailable,
boolean playerAvailableInTyrantWorld,
double distanceSquared,
double rangeBlocks
) {
return game.lifecycle() == GameLifecycle.RUNNING
&& game.tyrantId().isPresent()
&& player.optedOutUntil().isPresent()
&& tyrantAvailable
&& playerAvailableInTyrantWorld
&& distanceSquared <= rangeBlocks * rangeBlocks;
}
}
@@ -15,7 +15,6 @@ public final class PlayerStatusMessages {
Instant now
) {
List<String> messages = new ArrayList<>();
messages.add("Game: " + game.lifecycle());
boolean hasRole = false;
if (game.tyrantId().filter(player.playerId()::equals).isPresent()) {
messages.add("Role: TYRANT. Level " + game.tyrantLevel() + ", choices "
@@ -45,8 +44,8 @@ public final class PlayerStatusMessages {
messages.add("Participation: OPTED OUT. You may use /tyrant optin at "
+ player.optedOutUntil().orElseThrow() + ".");
} else if (!hasRole) {
messages.add("You currently have no role or class. Use /tyrant optout "
+ "if you do not want to participate.");
messages.add("Use /tyrant optout if you do not want to participate "
+ "in Tyrant events.");
}
return List.copyOf(messages);
}
@@ -15,6 +15,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
private AssassinEffectController assassinEffects;
private FixerEffectController fixerEffects;
private TyrantEffectController tyrantEffects;
private OptOutGlowController optOutGlow;
@Override
public void onEnable() {
@@ -69,6 +70,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
FixerAbilityService fixerAbilities = new FixerAbilityService(readiness, settings);
fixerEffects = new FixerEffectController(stateManager, getServer(), settings, clock);
tyrantEffects = new TyrantEffectController(stateManager, getServer(), settings);
optOutGlow = new OptOutGlowController(stateManager, getServer(), settings);
VigilanteCombatTracker combatTracker = new VigilanteCombatTracker();
vigilanteEffects = new VigilanteEffectController(
stateManager, getServer(), combatTracker, settings, clock
@@ -78,6 +80,11 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
stateManager, tyrantPresentation
);
managedEffects.restoreAll();
BukkitTyrantControlPanelRenderer controlPanelRenderer =
new BukkitTyrantControlPanelRenderer(getServer(), stateManager, clock);
TyrantControlPanel controlPanel = new DefaultTyrantControlPanel(
stateManager, controlPanelRenderer, clock
);
Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata")
.setExecutor(new TyrantCommand(
stateManager,
@@ -89,7 +96,8 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
new RoleRelinquishmentService(succession, maintenance),
tyrantPresentation,
abilityItems,
new TyrantAbilityService(settings.rosterIntelligenceCooldown())
new TyrantAbilityService(settings.rosterIntelligenceCooldown()),
controlPanel
));
Objects.requireNonNull(getCommand("vigilante"), "Missing vigilante metadata")
.setExecutor(new VigilanteCommand(stateManager, followers, onlinePlayers));
@@ -109,6 +117,16 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
new PlayerJoinListener(stateManager, clock, tyrantPresentation),
this
);
getServer().getPluginManager().registerEvents(controlPanelRenderer, this);
getServer().getPluginManager().registerEvents(
new TyrantControlPanelJoinListener(
stateManager,
controlPanel,
(task, delayTicks) -> getServer().getScheduler()
.runTaskLater(this, task, delayTicks)
),
this
);
getServer().getPluginManager().registerEvents(
new TyrantDeathListener(
stateManager, succession, tyrantPresentation, getServer(), clock
@@ -128,6 +146,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
),
this
);
getServer().getPluginManager().registerEvents(optOutGlow, this);
getServer().getPluginManager().registerEvents(
new VigilanteCombatListener(
stateManager, combatTracker, clock, settings.vigilanteCombatDuration()
@@ -176,6 +195,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
getServer().getScheduler().runTaskTimer(this, assassinEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, fixerEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, tyrantEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, optOutGlow, 10L, 10L);
getServer().getScheduler().runTaskTimer(
this,
new AbilityItemRefreshTask(
@@ -191,6 +211,9 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
@Override
public void onDisable() {
if (optOutGlow != null) {
optOutGlow.clearAll();
}
if (tyrantEffects != null) {
tyrantEffects.clearAll();
}
@@ -20,6 +20,7 @@ public final class TyrantCommand implements CommandExecutor {
private final TyrantPresentation presentation;
private final AbilityItemService abilityItems;
private final TyrantAbilityService tyrantAbilities;
private final TyrantControlPanel controlPanel;
public TyrantCommand(
TyrantStateManager stateManager,
@@ -34,6 +35,19 @@ public final class TyrantCommand implements CommandExecutor {
);
}
public TyrantCommand(
TyrantStateManager stateManager,
TyrantProgressionService progression,
TyrantControlPanel controlPanel
) {
this(
stateManager, progression, new ClassAssignmentService(),
new BukkitOnlinePlayerDirectory(), Clock.systemUTC(),
new ParticipationService(Duration.ofDays(7)), null, null, null,
new TyrantAbilityService(Duration.ofHours(24)), controlPanel
);
}
TyrantCommand(
TyrantStateManager stateManager,
TyrantProgressionService progression,
@@ -44,7 +58,7 @@ public final class TyrantCommand implements CommandExecutor {
this(
stateManager, progression, assignments, onlinePlayers, clock,
new ParticipationService(Duration.ofDays(7)), null, null, null,
new TyrantAbilityService(Duration.ofHours(24))
new TyrantAbilityService(Duration.ofHours(24)), TyrantCommand::unavailablePanel
);
}
@@ -59,6 +73,26 @@ public final class TyrantCommand implements CommandExecutor {
TyrantPresentation presentation,
AbilityItemService abilityItems,
TyrantAbilityService tyrantAbilities
) {
this(
stateManager, progression, assignments, onlinePlayers, clock, participation,
relinquishment, presentation, abilityItems, tyrantAbilities,
TyrantCommand::unavailablePanel
);
}
public TyrantCommand(
TyrantStateManager stateManager,
TyrantProgressionService progression,
ClassAssignmentService assignments,
OnlinePlayerDirectory onlinePlayers,
Clock clock,
ParticipationService participation,
RoleRelinquishmentService relinquishment,
TyrantPresentation presentation,
AbilityItemService abilityItems,
TyrantAbilityService tyrantAbilities,
TyrantControlPanel controlPanel
) {
this.stateManager = stateManager;
this.progression = progression;
@@ -70,6 +104,7 @@ public final class TyrantCommand implements CommandExecutor {
this.presentation = presentation;
this.abilityItems = abilityItems;
this.tyrantAbilities = tyrantAbilities;
this.controlPanel = controlPanel;
}
@Override
@@ -83,7 +118,12 @@ public final class TyrantCommand implements CommandExecutor {
sender.sendMessage("This command must be used by a player.");
return true;
}
if (arguments.length == 0 || arguments[0].equalsIgnoreCase("choices")) {
if (arguments.length == 0
|| arguments.length == 1 && arguments[0].equalsIgnoreCase("menu")) {
controlPanel.open(player);
return true;
}
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("choices")) {
showChoices(player);
return true;
}
@@ -121,7 +161,7 @@ public final class TyrantCommand implements CommandExecutor {
}
player.sendMessage(
ChatColor.YELLOW
+ "Usage: /tyrant <status|choices|buy|assign|item|intelligence|optout|optin|"
+ "Usage: /tyrant <menu|status|choices|buy|assign|item|intelligence|optout|optin|"
+ "relinquish confirm>"
);
return true;
@@ -303,6 +343,10 @@ public final class TyrantCommand implements CommandExecutor {
+ readable(result.status()) + ".");
}
private static void unavailablePanel(Player player) {
player.sendMessage(ChatColor.RED + "The Tyrant control panel is unavailable.");
}
private static String readable(Enum<?> status) {
return status.name().toLowerCase(Locale.ROOT).replace('_', ' ');
}
@@ -0,0 +1,19 @@
package games.dmg.spigottyrant;
import java.util.UUID;
import org.bukkit.entity.Player;
@FunctionalInterface
public interface TyrantControlPanel {
void open(Player player);
default void beginSession(UUID playerId) {
}
default void endSession(UUID playerId) {
}
default boolean openedThisSession(UUID playerId) {
return false;
}
}
@@ -0,0 +1,48 @@
package games.dmg.spigottyrant;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
public final class TyrantControlPanelJoinListener implements Listener {
private static final long OPEN_DELAY_TICKS = 100L;
private final TyrantStateManager stateManager;
private final TyrantControlPanel controlPanel;
private final DelayedTaskScheduler scheduler;
public TyrantControlPanelJoinListener(
TyrantStateManager stateManager,
TyrantControlPanel controlPanel,
DelayedTaskScheduler scheduler
) {
this.stateManager = stateManager;
this.controlPanel = controlPanel;
this.scheduler = scheduler;
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
Player player = event.getPlayer();
UUID playerId = player.getUniqueId();
controlPanel.beginSession(playerId);
scheduler.schedule(() -> openIfStillTyrant(player), OPEN_DELAY_TICKS);
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
controlPanel.endSession(event.getPlayer().getUniqueId());
}
private void openIfStillTyrant(Player player) {
GameState game = stateManager.game();
if (player.isOnline()
&& game.lifecycle() == GameLifecycle.RUNNING
&& game.tyrantId().filter(player.getUniqueId()::equals).isPresent()
&& !controlPanel.openedThisSession(player.getUniqueId())) {
controlPanel.open(player);
}
}
}
@@ -0,0 +1,67 @@
package games.dmg.spigottyrant;
import java.time.Duration;
import java.time.Instant;
import java.util.EnumMap;
import java.util.Map;
import java.util.Set;
public record TyrantControlPanelModel(
int level,
int unspentChoices,
Map<TyrantUnlock, UnlockAvailability> unlocks,
Map<TyrantClass, String> classHolders,
Duration intelligenceCooldown
) {
public TyrantControlPanelModel {
unlocks = Map.copyOf(unlocks);
classHolders = Map.copyOf(classHolders);
}
public Set<TyrantUnlock> purchasedUnlocks() {
return unlocks.entrySet().stream()
.filter(entry -> entry.getValue() == UnlockAvailability.PURCHASED)
.map(Map.Entry::getKey)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
public static TyrantControlPanelModel create(
GameState game,
PlayerState tyrant,
Map<java.util.UUID, PlayerState> players,
Instant now
) {
Map<TyrantUnlock, UnlockAvailability> unlocks = new EnumMap<>(TyrantUnlock.class);
for (TyrantUnlock unlock : TyrantUnlock.values()) {
UnlockAvailability availability;
if (game.purchases().contains(unlock)) {
availability = UnlockAvailability.PURCHASED;
} else if (game.lifecycle() == GameLifecycle.RUNNING
&& game.unspentChoices() > 0) {
availability = UnlockAvailability.AVAILABLE;
} else {
availability = UnlockAvailability.UNAVAILABLE;
}
unlocks.put(unlock, availability);
}
Map<TyrantClass, String> holders = new EnumMap<>(TyrantClass.class);
for (TyrantClass tyrantClass : new TyrantClass[] {
TyrantClass.ASSASSIN, TyrantClass.FIXER, TyrantClass.TAMER
}) {
String holder = players.values().stream()
.filter(player -> player.tyrantClass() == tyrantClass)
.map(PlayerState::latestName)
.findFirst()
.orElse("Unassigned");
holders.put(tyrantClass, holder);
}
Duration cooldown = tyrant.cooldownEnds().getOrDefault(
Ability.ROSTER_INTELLIGENCE, now
).isAfter(now)
? Duration.between(now, tyrant.cooldownEnds().get(Ability.ROSTER_INTELLIGENCE))
: Duration.ZERO;
return new TyrantControlPanelModel(
game.tyrantLevel(), game.unspentChoices(), unlocks, holders, cooldown
);
}
}
@@ -0,0 +1,8 @@
package games.dmg.spigottyrant;
import org.bukkit.entity.Player;
@FunctionalInterface
public interface TyrantControlPanelRenderer {
void openOverview(Player player, TyrantControlPanelModel model);
}
@@ -0,0 +1,7 @@
package games.dmg.spigottyrant;
public enum UnlockAvailability {
PURCHASED,
AVAILABLE,
UNAVAILABLE
}
+1 -1
View File
@@ -7,7 +7,7 @@ author: dmg.games
commands:
tyrant:
description: View and use Spigot Tyrant game features.
usage: /tyrant <status|choices|buy|assign|item|intelligence|optout|optin|relinquish confirm>
usage: /tyrant <menu|status|choices|buy|assign|item|intelligence|optout|optin|relinquish confirm>
vigilante:
description: Manage Vigilante Followers.
usage: /vigilante <invite <player>|accept|dismiss <player>|leave>
@@ -0,0 +1,145 @@
package games.dmg.spigottyrant;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Logger;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class AssassinEffectControllerTest {
private static final Instant NOW = Instant.parse("2026-08-18T12:00:00Z");
private static final UUID PLAYER_ID = UUID.fromString(
"11111111-1111-1111-1111-111111111111"
);
@TempDir
Path temporaryDirectory;
@Test
void doesNotRemovePotionEffectsItDidNotApply() throws Exception {
TyrantStateManager stateManager = manager();
Player player = player();
Server server = server(player);
AssassinEffectController.EffectApplier effects = mock(
AssassinEffectController.EffectApplier.class
);
AssassinEffectController controller = controller(stateManager, server, effects);
controller.run();
controller.clearAll();
verifyNoInteractions(effects);
}
@Test
void stopsRefreshingManagedInvisibilityAfterItsDeadline() throws Exception {
TyrantStateManager stateManager = manager();
stateManager.updateGame(ignored -> runningGame());
stateManager.updatePlayer(PLAYER_ID, "Assassin", ignored -> assassin(Map.of(
Ability.ASSASSIN_INVISIBILITY_ACTIVE, NOW.plusSeconds(60),
Ability.ASSASSIN_DOUBLE_JUMP, NOW.plusSeconds(60)
)));
Player player = player();
Server server = server(player);
AssassinEffectController.EffectApplier effects = mock(
AssassinEffectController.EffectApplier.class
);
AssassinEffectController controller = controller(stateManager, server, effects);
controller.run();
verify(effects).apply(
player,
AssassinEffectController.EffectKind.INVISIBILITY,
0,
false
);
stateManager.updatePlayer(PLAYER_ID, "Assassin", ignored -> assassin(Map.of(
Ability.ASSASSIN_DOUBLE_JUMP, NOW.plusSeconds(60)
)));
clearInvocations(player, effects);
controller.run();
verifyNoInteractions(effects);
}
private TyrantStateManager manager() throws Exception {
return new TyrantStateManager(
new YamlTyrantStateRepository(temporaryDirectory.resolve("state.yml")),
Logger.getLogger("test")
);
}
private static AssassinEffectController controller(
TyrantStateManager stateManager,
Server server,
AssassinEffectController.EffectApplier effects
) {
return new AssassinEffectController(
stateManager,
server,
PluginSettings.from(Map.of()),
Clock.fixed(NOW, ZoneOffset.UTC),
effects
);
}
private static Server server(Player player) {
Server server = mock(Server.class);
doReturn(List.of(player)).when(server).getOnlinePlayers();
return server;
}
private static Player player() {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(PLAYER_ID);
when(player.getName()).thenReturn("Assassin");
return player;
}
private static PlayerState assassin(Map<Ability, Instant> cooldowns) {
return new PlayerState(
PLAYER_ID,
"Assassin",
Optional.empty(),
Optional.empty(),
TyrantClass.ASSASSIN,
Optional.empty(),
cooldowns,
Set.of(),
List.of()
);
}
private static GameState runningGame() {
return new GameState(
GameLifecycle.RUNNING,
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
java.time.Duration.ZERO,
0,
0,
Set.of()
);
}
}
@@ -0,0 +1,72 @@
package games.dmg.spigottyrant;
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.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class DefaultTyrantControlPanelTest {
@Test
void nonTyrantCannotRenderControlPanel() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID otherId = UUID.fromString("22222222-2222-2222-2222-222222222222");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 0, Set.of()
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(game);
Player other = mock(Player.class);
when(other.getUniqueId()).thenReturn(otherId);
TyrantControlPanelRenderer renderer = mock(TyrantControlPanelRenderer.class);
DefaultTyrantControlPanel panel = new DefaultTyrantControlPanel(
manager, renderer,
Clock.fixed(Instant.parse("2026-08-21T12:00:00Z"), ZoneOffset.UTC)
);
panel.open(other);
verify(renderer, never()).openOverview(any(), any());
verify(other).sendMessage(org.mockito.ArgumentMatchers.contains("active Tyrant"));
}
@Test
void openingAsActiveTyrantRendersAuthoritativeOverview() {
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,
2, 1, Set.of(TyrantUnlock.STRENGTH)
);
PlayerState tyrantState = PlayerState.newPlayer(tyrantId, "Tyrant");
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(game);
when(manager.player(tyrantId, "Tyrant")).thenReturn(tyrantState);
when(manager.players()).thenReturn(Map.of(tyrantId, tyrantState));
Player tyrant = mock(Player.class);
when(tyrant.getUniqueId()).thenReturn(tyrantId);
when(tyrant.getName()).thenReturn("Tyrant");
TyrantControlPanelRenderer renderer = mock(TyrantControlPanelRenderer.class);
DefaultTyrantControlPanel panel = new DefaultTyrantControlPanel(
manager, renderer,
Clock.fixed(Instant.parse("2026-08-21T12:00:00Z"), ZoneOffset.UTC)
);
panel.open(tyrant);
verify(renderer).openOverview(org.mockito.ArgumentMatchers.eq(tyrant), any());
}
}
@@ -0,0 +1,97 @@
package games.dmg.spigottyrant;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.ScoreboardManager;
import org.bukkit.scoreboard.Team;
import org.junit.jupiter.api.Test;
final class OptOutGlowControllerTest {
private static final UUID TYRANT = UUID.fromString(
"11111111-1111-1111-1111-111111111111"
);
private static final UUID OUT = UUID.fromString(
"22222222-2222-2222-2222-222222222222"
);
@Test
void appliesGreenGlowAndRestoresPreviousTeamAfterLeavingRange() {
TyrantStateManager stateManager = mock(TyrantStateManager.class);
Server server = mock(Server.class);
PluginSettings settings = mock(PluginSettings.class);
ScoreboardManager manager = mock(ScoreboardManager.class);
Scoreboard scoreboard = mock(Scoreboard.class);
Team green = mock(Team.class);
Team previous = mock(Team.class);
Player tyrant = player(TYRANT, "Tyrant");
Player optedOut = player(OUT, "Out");
World world = mock(World.class);
Location tyrantLocation = mock(Location.class);
Location outLocation = mock(Location.class);
when(server.getScoreboardManager()).thenReturn(manager);
when(manager.getMainScoreboard()).thenReturn(scoreboard);
when(scoreboard.getTeam("tyrant-optout")).thenReturn(null);
when(scoreboard.registerNewTeam("tyrant-optout")).thenReturn(green);
when(scoreboard.getEntryTeam("Out")).thenReturn(previous);
when(server.getPlayer(TYRANT)).thenReturn(tyrant);
doReturn(Set.of(tyrant, optedOut)).when(server).getOnlinePlayers();
when(settings.tyrantRangeBlocks()).thenReturn(50.0);
when(tyrant.getWorld()).thenReturn(world);
when(optedOut.getWorld()).thenReturn(world);
when(tyrant.getLocation()).thenReturn(tyrantLocation);
when(optedOut.getLocation()).thenReturn(outLocation);
when(outLocation.distanceSquared(tyrantLocation)).thenReturn(25.0, 2601.0);
when(stateManager.game()).thenReturn(running());
when(stateManager.players()).thenReturn(Map.of(OUT, optedOut()));
OptOutGlowController controller = new OptOutGlowController(
stateManager, server, settings
);
controller.run();
controller.run();
verify(green).setColor(ChatColor.GREEN);
verify(green).addEntry("Out");
verify(optedOut).setGlowing(true);
verify(green).removeEntry("Out");
verify(previous).addEntry("Out");
verify(optedOut).setGlowing(false);
}
private static Player player(UUID id, String name) {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
when(player.getName()).thenReturn(name);
return player;
}
private static PlayerState optedOut() {
return new PlayerState(
OUT, "Out", Optional.empty(), Optional.of(Instant.MAX), TyrantClass.NONE,
Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
}
private static GameState running() {
return new GameState(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 0, Set.of()
);
}
}
@@ -0,0 +1,76 @@
package games.dmg.spigottyrant;
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.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class OptOutGlowPolicyTest {
private static final UUID TYRANT = UUID.fromString(
"11111111-1111-1111-1111-111111111111"
);
private static final UUID PLAYER = UUID.fromString(
"22222222-2222-2222-2222-222222222222"
);
@Test
void optedOutPlayerGlowsWithinInclusiveRunningRange() {
assertTrue(OptOutGlowPolicy.shouldGlow(
running(), optedOut(), true, true, 2500.0, 50.0
));
}
@Test
void requiresRunningEventAvailablePlayersSameWorldAndRange() {
assertFalse(OptOutGlowPolicy.shouldGlow(
GameState.empty(), optedOut(), true, true, 1.0, 50.0
));
assertFalse(OptOutGlowPolicy.shouldGlow(
paused(), optedOut(), true, true, 1.0, 50.0
));
assertFalse(OptOutGlowPolicy.shouldGlow(
running(), optedIn(), true, true, 1.0, 50.0
));
assertFalse(OptOutGlowPolicy.shouldGlow(
running(), optedOut(), false, true, 1.0, 50.0
));
assertFalse(OptOutGlowPolicy.shouldGlow(
running(), optedOut(), true, false, 1.0, 50.0
));
assertFalse(OptOutGlowPolicy.shouldGlow(
running(), optedOut(), true, true, 2500.01, 50.0
));
}
private static PlayerState optedOut() {
return new PlayerState(
PLAYER, "Player", Optional.empty(), Optional.of(Instant.MAX),
TyrantClass.NONE, Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
}
private static PlayerState optedIn() {
return PlayerState.newPlayer(PLAYER, "Player");
}
private static GameState running() {
return game(GameLifecycle.RUNNING, Optional.empty());
}
private static GameState paused() {
return game(GameLifecycle.PAUSED, Optional.of(Instant.EPOCH));
}
private static GameState game(GameLifecycle lifecycle, Optional<Instant> pausedAt) {
return new GameState(
lifecycle, Optional.of(TYRANT), Optional.empty(), Optional.empty(),
Optional.empty(), pausedAt, Duration.ZERO, 0, 0, Set.of()
);
}
}
@@ -1,5 +1,6 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
@@ -29,7 +30,8 @@ final class PlayerStatusMessagesTest {
List<String> messages = PlayerStatusMessages.forJoin(game, player, NOW);
assertTrue(messages.stream().anyMatch(message -> message.contains("Game: RUNNING")));
assertFalse(messages.stream().anyMatch(message -> message.startsWith("Game:")));
assertFalse(messages.stream().anyMatch(message -> message.startsWith("Tyrant event:")));
assertTrue(messages.stream().anyMatch(message -> message.contains("Role: VIGILANTE")));
assertTrue(messages.stream().anyMatch(message -> message.contains("Class: ASSASSIN")));
assertTrue(messages.stream().anyMatch(message -> message.contains("ASSASSIN_INVISIBILITY")
@@ -42,6 +44,8 @@ final class PlayerStatusMessagesTest {
GameState.empty(), PlayerState.newPlayer(PLAYER, "Player"), NOW
);
assertTrue(messages.stream().anyMatch(message -> message.contains("/tyrant optout")));
assertTrue(messages.contains(
"Use /tyrant optout if you do not want to participate in Tyrant events."
));
}
}
@@ -20,6 +20,29 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
final class TyrantCommandTest {
@Test
void activeTyrantCanOpenControlPanelWithNoArgumentsOrMenuSubcommand() {
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,
0, 1, Set.of()
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(game);
Player tyrant = mock(Player.class);
when(tyrant.getUniqueId()).thenReturn(tyrantId);
TyrantControlPanel controlPanel = mock(TyrantControlPanel.class);
TyrantCommand command = new TyrantCommand(
manager, new TyrantProgressionService(), controlPanel
);
command.onCommand(tyrant, mock(Command.class), "tyrant", new String[0]);
command.onCommand(tyrant, mock(Command.class), "tyrant", new String[] {"menu"});
verify(controlPanel, org.mockito.Mockito.times(2)).open(tyrant);
}
@Test
void choicesClearlyIdentifyPurchasedAvailableAndUnavailableUnlocks() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
@@ -0,0 +1,105 @@
package games.dmg.spigottyrant;
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.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerJoinEvent;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
final class TyrantControlPanelJoinListenerTest {
@Test
void delayedTaskDoesNotOpenAfterEventPauses() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
GameState paused = new GameState(
GameLifecycle.PAUSED, Optional.of(tyrantId), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.of(java.time.Instant.EPOCH),
Duration.ZERO, 0, 1, Set.of()
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(paused);
Player tyrant = mock(Player.class);
when(tyrant.getUniqueId()).thenReturn(tyrantId);
when(tyrant.isOnline()).thenReturn(true);
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
when(event.getPlayer()).thenReturn(tyrant);
DelayedTaskScheduler scheduler = mock(DelayedTaskScheduler.class);
TyrantControlPanel panel = mock(TyrantControlPanel.class);
TyrantControlPanelJoinListener listener = new TyrantControlPanelJoinListener(
manager, panel, scheduler
);
listener.onJoin(event);
ArgumentCaptor<Runnable> task = ArgumentCaptor.forClass(Runnable.class);
verify(scheduler).schedule(task.capture(), org.mockito.ArgumentMatchers.eq(100L));
task.getValue().run();
verify(panel, never()).open(tyrant);
}
@Test
void delayedTaskDoesNotReopenPanelOpenedManually() {
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,
0, 1, Set.of()
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(game);
Player tyrant = mock(Player.class);
when(tyrant.getUniqueId()).thenReturn(tyrantId);
when(tyrant.isOnline()).thenReturn(true);
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
when(event.getPlayer()).thenReturn(tyrant);
DelayedTaskScheduler scheduler = mock(DelayedTaskScheduler.class);
TyrantControlPanel panel = mock(TyrantControlPanel.class);
when(panel.openedThisSession(tyrantId)).thenReturn(true);
TyrantControlPanelJoinListener listener = new TyrantControlPanelJoinListener(
manager, panel, scheduler
);
listener.onJoin(event);
ArgumentCaptor<Runnable> task = ArgumentCaptor.forClass(Runnable.class);
verify(scheduler).schedule(task.capture(), org.mockito.ArgumentMatchers.eq(100L));
task.getValue().run();
verify(panel, never()).open(tyrant);
}
@Test
void opensPanelOneHundredTicksAfterActiveTyrantJoins() {
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,
0, 1, Set.of()
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(game);
Player tyrant = mock(Player.class);
when(tyrant.getUniqueId()).thenReturn(tyrantId);
when(tyrant.isOnline()).thenReturn(true);
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
when(event.getPlayer()).thenReturn(tyrant);
DelayedTaskScheduler scheduler = mock(DelayedTaskScheduler.class);
TyrantControlPanel panel = mock(TyrantControlPanel.class);
TyrantControlPanelJoinListener listener = new TyrantControlPanelJoinListener(
manager, panel, scheduler
);
listener.onJoin(event);
ArgumentCaptor<Runnable> task = ArgumentCaptor.forClass(Runnable.class);
verify(scheduler).schedule(task.capture(), org.mockito.ArgumentMatchers.eq(100L));
task.getValue().run();
verify(panel).open(tyrant);
}
}
@@ -0,0 +1,49 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class TyrantControlPanelModelTest {
@Test
void overviewShowsProgressPurchasesAssignmentsAndIntelligenceCooldown() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID assassinId = UUID.fromString("22222222-2222-2222-2222-222222222222");
Instant now = Instant.parse("2026-08-21T12:00:00Z");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
3, 2, Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.ROSTER_INTELLIGENCE)
);
PlayerState tyrant = new PlayerState(
tyrantId, "Tyrant", Optional.of(now), Optional.empty(), TyrantClass.NONE,
Optional.empty(), Map.of(Ability.ROSTER_INTELLIGENCE, now.plusSeconds(90)),
Set.of(), java.util.List.of()
);
PlayerState assassin = new PlayerState(
assassinId, "Blade", Optional.of(now), Optional.empty(), TyrantClass.ASSASSIN,
Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
TyrantControlPanelModel model = TyrantControlPanelModel.create(
game, tyrant, Map.of(tyrantId, tyrant, assassinId, assassin), now
);
assertEquals(3, model.level());
assertEquals(2, model.unspentChoices());
assertEquals(UnlockAvailability.PURCHASED,
model.unlocks().get(TyrantUnlock.ASSASSIN));
assertEquals(
Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.ROSTER_INTELLIGENCE),
model.purchasedUnlocks()
);
assertEquals("Blade", model.classHolders().get(TyrantClass.ASSASSIN));
assertEquals(Duration.ofSeconds(90), model.intelligenceCooldown());
}
}