feat(tyrant): add reign control panel
This commit is contained in:
@@ -6,6 +6,24 @@ 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
@@ -80,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,
|
||||
@@ -91,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));
|
||||
@@ -111,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user