feat(vigilante): add follower control panel
This commit is contained in:
@@ -6,6 +6,25 @@ description: Chronological record of material decisions affecting the Spigot Tyr
|
||||
|
||||
# Spigot Tyrant Design Log
|
||||
|
||||
## 2026-08-21 — Vigilante control panel completed
|
||||
|
||||
- Completed US-016 and the reopened US-008 criteria with resistance overview, recruitment, Follower management, and confirmed role-relinquishment screens.
|
||||
- `/vigilante` and `/vigilante menu` open the panel, while existing invitation, acceptance, dismissal, leave, and relinquishment commands remain available.
|
||||
- Added a 100-tick login delay with online, lifecycle, role, and already-opened session checks so only the current Vigilante receives one automatic panel per login.
|
||||
- Pending invitations are visible and idempotent, existing Followers cannot be reinvited, and all menu mutations delegate to authoritative command and domain rules.
|
||||
- Verified command routing, authorization, overview state, invitation rules, delayed opening safeguards, compiler warnings, tests, and packaging with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-21 — Vigilante control panel implementation started
|
||||
|
||||
- US-008 and US-016 begin a test-first implementation of the Vigilante control panel, delayed login opening, resistance overview, recruitment, dismissal, and role relinquishment.
|
||||
- The panel will reuse authoritative Follower and relinquishment rules while preserving all existing command alternatives.
|
||||
|
||||
## 2026-08-21 — Vigilante control panel planned
|
||||
|
||||
- Added US-016 for a graphical Vigilante control panel covering resistance overview, recruitment, Follower management, and confirmed role relinquishment while preserving command alternatives.
|
||||
- Planned automatic control-panel opening five seconds after the active Vigilante joins a running event, with lifecycle, role, disconnect, and duplicate-opening safeguards.
|
||||
- Reopened US-008 in the backlog to track the approved control-panel interactions and nearby-Follower combat contribution display.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -15,3 +15,4 @@
|
||||
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)
|
||||
16. [US-016: Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md)
|
||||
|
||||
@@ -23,8 +23,13 @@ As the **Vigilante**, I want nearby living Followers to strengthen me against th
|
||||
- [x] The deceased Vigilante is excluded when another eligible non-Tyrant candidate exists and is reused only when no such candidate exists.
|
||||
- [x] Followers are cleared rather than transferred when their Vigilante dies or loses the position.
|
||||
- [x] The Tyrant is never eligible to be selected as Vigilante.
|
||||
- [x] The Vigilante can review, invite, and dismiss Followers through the Vigilante control panel.
|
||||
- [x] Control-panel and command-based Follower actions enforce identical eligibility and membership rules.
|
||||
- [x] The control panel communicates the current nearby-Follower combat contribution and configured effect caps.
|
||||
- [x] Relinquishing through the control panel follows normal Vigilante succession and Follower cleanup.
|
||||
|
||||
## Related
|
||||
|
||||
- [Role and class model](../concepts/role-and-class-model.md)
|
||||
- [Handle inactivity and pending selections](us-010-handle-inactivity-and-selection.md)
|
||||
- [Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-016: Manage Followers through the Vigilante control panel"
|
||||
description: Give the active Vigilante a graphical control panel for reviewing and managing the resistance.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-016: Manage Followers through the Vigilante control panel
|
||||
|
||||
As the **Vigilante**, I want a control panel for managing Followers so that I can coordinate the resistance without memorizing commands.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] The active Vigilante can open the control panel with `/vigilante menu`, and `/vigilante` with no arguments opens the same panel.
|
||||
- [x] A player who is not the active Vigilante cannot open or operate the control panel.
|
||||
- [x] The control panel opens automatically five seconds after the active Vigilante joins a running event.
|
||||
- [x] Automatic opening is cancelled if the player disconnects, loses the Vigilante role, or the event pauses or stops during the five-second delay.
|
||||
- [x] Pending Vigilante candidates and other players do not receive the automatically opened control panel.
|
||||
- [x] Manually opening the control panel during the delay prevents a duplicate automatic opening.
|
||||
- [x] Closing the automatically opened control panel does not cause it to reopen during the same login session.
|
||||
- [x] The overview shows current Followers, pending invitations, and the number of nearby Followers currently eligible to contribute combat effects.
|
||||
- [x] Each Follower's online, living, same-world, proximity, and participation status is communicated without exposing information unavailable to the Vigilante.
|
||||
- [x] The control panel shows the current Strength and Resistance contribution and configured caps.
|
||||
- [x] The Vigilante can navigate between overview, recruitment, Follower-management, and role-control screens.
|
||||
- [x] The recruitment screen lists eligible online players and explains why opted-out, Tyrant, Vigilante, or otherwise ineligible players cannot be invited.
|
||||
- [x] The Vigilante can invite an eligible player after confirmation.
|
||||
- [x] Pending outgoing invitations are identified and cannot be duplicated through repeated or concurrent interaction.
|
||||
- [x] Invited players receive the existing invitation notice and may continue using `/vigilante accept`.
|
||||
- [x] The Follower-management screen identifies current Followers and requires confirmation before dismissal.
|
||||
- [x] Successful invitations and dismissals refresh the control panel from authoritative current state and notify affected players.
|
||||
- [x] The role-control screen allows the Vigilante to relinquish the role only after explicit confirmation.
|
||||
- [x] Relinquishing through the control panel uses normal Vigilante replacement and Follower-cleanup rules.
|
||||
- [x] Every action revalidates the running lifecycle and active Vigilante before changing state.
|
||||
- [x] Stale or repeated control-panel interactions cannot invite an ineligible player, duplicate an invitation, or dismiss the wrong Follower.
|
||||
- [x] Existing `/vigilante invite`, `/vigilante accept`, `/vigilante dismiss`, `/vigilante leave`, and `/tyrant relinquish confirm` commands remain available.
|
||||
|
||||
## Validation
|
||||
|
||||
Automated tests verify default and explicit command entry points, active-Vigilante authorization, overview state, invitation deduplication and eligibility, the exact 100-tick login delay, lifecycle cancellation, and duplicate-opening prevention. The complete `./gradlew clean check jar` lifecycle passes.
|
||||
|
||||
## Related
|
||||
|
||||
- [Support the Vigilante and Followers](us-008-support-vigilante-and-followers.md)
|
||||
- [Handle inactivity and pending selections](us-010-handle-inactivity-and-selection.md)
|
||||
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
|
||||
- [Role and class model](../concepts/role-and-class-model.md)
|
||||
@@ -0,0 +1,38 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
public final class BukkitFollowerPresenceProvider implements FollowerPresenceProvider {
|
||||
private final Server server;
|
||||
private final double rangeSquared;
|
||||
|
||||
public BukkitFollowerPresenceProvider(Server server, double rangeBlocks) {
|
||||
this.server = server;
|
||||
this.rangeSquared = rangeBlocks * rangeBlocks;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<UUID, FollowerPresence> inspect(
|
||||
Player vigilante,
|
||||
Map<UUID, PlayerState> players
|
||||
) {
|
||||
Map<UUID, FollowerPresence> result = new HashMap<>();
|
||||
for (PlayerState state : players.values()) {
|
||||
Player follower = server.getPlayer(state.playerId());
|
||||
boolean online = follower != null && follower.isOnline();
|
||||
boolean living = online && !follower.isDead();
|
||||
boolean sameWorld = living && follower.getWorld().equals(vigilante.getWorld());
|
||||
boolean withinRange = sameWorld && follower.getLocation()
|
||||
.distanceSquared(vigilante.getLocation()) <= rangeSquared;
|
||||
result.put(
|
||||
state.playerId(),
|
||||
new FollowerPresence(online, living, sameWorld, withinRange)
|
||||
);
|
||||
}
|
||||
return Map.copyOf(result);
|
||||
}
|
||||
}
|
||||
@@ -255,7 +255,9 @@ public final class BukkitTyrantControlPanelRenderer
|
||||
return;
|
||||
}
|
||||
try {
|
||||
openAssignmentConfirmation(player, tyrantClass, UUID.fromString(lore.get(1)));
|
||||
openAssignmentConfirmation(
|
||||
player, tyrantClass, UUID.fromString(ChatColor.stripColor(lore.get(1)))
|
||||
);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
player.sendMessage(ChatColor.RED + "That player selection is no longer valid.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
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 BukkitVigilanteControlPanelRenderer
|
||||
implements VigilanteControlPanelRenderer, Listener {
|
||||
private static final String TITLE = ChatColor.DARK_BLUE + "Vigilante Control Panel";
|
||||
private final Server server;
|
||||
private final TyrantStateManager stateManager;
|
||||
private final FollowerService followers;
|
||||
private final FollowerPresenceProvider presenceProvider;
|
||||
private final int strengthCap;
|
||||
private final int resistanceCap;
|
||||
|
||||
public BukkitVigilanteControlPanelRenderer(
|
||||
Server server,
|
||||
TyrantStateManager stateManager,
|
||||
FollowerService followers,
|
||||
FollowerPresenceProvider presenceProvider,
|
||||
int strengthCap,
|
||||
int resistanceCap
|
||||
) {
|
||||
this.server = server;
|
||||
this.stateManager = stateManager;
|
||||
this.followers = followers;
|
||||
this.presenceProvider = presenceProvider;
|
||||
this.strengthCap = strengthCap;
|
||||
this.resistanceCap = resistanceCap;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void openOverview(Player player, VigilanteControlPanelModel model) {
|
||||
MenuHolder holder = new MenuHolder(MenuView.OVERVIEW, null);
|
||||
Inventory inventory = create(holder, 27, TITLE);
|
||||
inventory.setItem(10, item(Material.SHIELD, ChatColor.BLUE + "Resistance Overview",
|
||||
List.of(
|
||||
"Followers: " + model.followers().size(),
|
||||
"Pending invitations: " + model.pendingInvitations().size(),
|
||||
"Nearby contributors: " + model.nearbyEligibleFollowers(),
|
||||
"Strength: " + model.strengthLevel() + "/" + model.strengthCap(),
|
||||
"Resistance: " + model.resistanceLevel() + "/" + model.resistanceCap()
|
||||
)));
|
||||
inventory.setItem(12, item(Material.WRITABLE_BOOK, ChatColor.GREEN + "Recruitment",
|
||||
List.of("Invite eligible online players.")));
|
||||
inventory.setItem(14, item(Material.PLAYER_HEAD, ChatColor.AQUA + "Manage Followers",
|
||||
followerSummary(model)));
|
||||
inventory.setItem(16, item(Material.REDSTONE_TORCH, ChatColor.RED + "Role Control",
|
||||
List.of("Relinquish the Vigilante role.")));
|
||||
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 (!isActiveVigilante(player)) {
|
||||
player.closeInventory();
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "This Vigilante control panel is no longer active.");
|
||||
return;
|
||||
}
|
||||
switch (holder.view()) {
|
||||
case OVERVIEW -> clickOverview(player, event.getRawSlot());
|
||||
case RECRUITMENT -> clickRecruitment(player, event.getRawSlot());
|
||||
case INVITE_CONFIRMATION -> clickInviteConfirmation(
|
||||
player, event.getRawSlot(), holder.targetId()
|
||||
);
|
||||
case FOLLOWERS -> clickFollowers(player, event.getRawSlot());
|
||||
case DISMISS_CONFIRMATION -> clickDismissConfirmation(
|
||||
player, event.getRawSlot(), holder.targetId()
|
||||
);
|
||||
case ROLE -> clickRole(player, event.getRawSlot());
|
||||
case RELINQUISH_CONFIRMATION -> clickRelinquishConfirmation(
|
||||
player, event.getRawSlot()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void clickOverview(Player player, int slot) {
|
||||
if (slot == 12) {
|
||||
openRecruitment(player);
|
||||
} else if (slot == 14) {
|
||||
openFollowers(player);
|
||||
} else if (slot == 16) {
|
||||
openRole(player);
|
||||
}
|
||||
}
|
||||
|
||||
private void openRecruitment(Player player) {
|
||||
MenuHolder holder = new MenuHolder(MenuView.RECRUITMENT, null);
|
||||
Inventory inventory = create(holder, 54, TITLE + " — Recruitment");
|
||||
Set<UUID> pending = followers.invitedPlayerIds(player.getUniqueId());
|
||||
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 = recruitmentEligibility(candidate.getUniqueId(), state, pending);
|
||||
inventory.setItem(slot, playerItem(candidate.getName(), candidate.getUniqueId(),
|
||||
eligibility));
|
||||
slot++;
|
||||
}
|
||||
inventory.setItem(49, backItem());
|
||||
player.openInventory(inventory);
|
||||
}
|
||||
|
||||
private String recruitmentEligibility(
|
||||
UUID candidateId,
|
||||
PlayerState state,
|
||||
Set<UUID> pending
|
||||
) {
|
||||
GameState game = stateManager.game();
|
||||
if (pending.contains(candidateId)) {
|
||||
return ChatColor.YELLOW + "Pending invitation";
|
||||
}
|
||||
if (state.optedOutUntil().isPresent()) {
|
||||
return ChatColor.RED + "Ineligible: opted out";
|
||||
}
|
||||
if (game.tyrantId().filter(candidateId::equals).isPresent()) {
|
||||
return ChatColor.RED + "Ineligible: Tyrant";
|
||||
}
|
||||
if (game.vigilanteId().filter(candidateId::equals).isPresent()) {
|
||||
return ChatColor.RED + "Ineligible: Vigilante";
|
||||
}
|
||||
if (state.followerOf().isPresent()) {
|
||||
return ChatColor.RED + "Ineligible: already a Follower";
|
||||
}
|
||||
return ChatColor.GREEN + "Eligible";
|
||||
}
|
||||
|
||||
private void clickRecruitment(Player player, int slot) {
|
||||
if (slot == 49) {
|
||||
refresh(player);
|
||||
return;
|
||||
}
|
||||
selectedPlayer(player, slot, "Eligible")
|
||||
.ifPresent(targetId -> openInviteConfirmation(player, targetId));
|
||||
}
|
||||
|
||||
private void openInviteConfirmation(Player player, UUID targetId) {
|
||||
Player target = server.getPlayer(targetId);
|
||||
if (target == null) {
|
||||
player.sendMessage(ChatColor.RED + "That player is no longer online.");
|
||||
openRecruitment(player);
|
||||
return;
|
||||
}
|
||||
MenuHolder holder = new MenuHolder(MenuView.INVITE_CONFIRMATION, targetId);
|
||||
Inventory inventory = create(holder, 27, TITLE + " — Confirm Invitation");
|
||||
inventory.setItem(11, cancelItem());
|
||||
inventory.setItem(13, item(Material.WRITABLE_BOOK, ChatColor.YELLOW + target.getName(),
|
||||
List.of("Invite this player to become a Follower?")));
|
||||
inventory.setItem(15, confirmItem());
|
||||
player.openInventory(inventory);
|
||||
}
|
||||
|
||||
private void clickInviteConfirmation(Player player, int slot, UUID targetId) {
|
||||
if (slot == 11) {
|
||||
openRecruitment(player);
|
||||
} else if (slot == 15 && targetId != null) {
|
||||
Player target = server.getPlayer(targetId);
|
||||
if (target == null) {
|
||||
player.sendMessage(ChatColor.RED + "That player is no longer online.");
|
||||
} else {
|
||||
player.performCommand("vigilante invite " + target.getName());
|
||||
}
|
||||
openRecruitment(player);
|
||||
}
|
||||
}
|
||||
|
||||
private void openFollowers(Player player) {
|
||||
VigilanteControlPanelModel model = model(player);
|
||||
MenuHolder holder = new MenuHolder(MenuView.FOLLOWERS, null);
|
||||
Inventory inventory = create(holder, 54, TITLE + " — Followers");
|
||||
int slot = 0;
|
||||
for (VigilanteFollowerView follower : model.followers()) {
|
||||
if (slot >= 45) {
|
||||
break;
|
||||
}
|
||||
FollowerPresence presence = follower.presence();
|
||||
List<String> status = List.of(
|
||||
presence.online() ? "Online" : "Offline",
|
||||
presence.living() ? "Living" : "Dead or unavailable",
|
||||
presence.sameWorld() ? "Same world" : "Different world or unavailable",
|
||||
presence.withinRange() ? "Within range" : "Outside range or unavailable",
|
||||
follower.optedOut() ? "Opted out" : "Participating",
|
||||
presence.online() ? ChatColor.GREEN + "Click to dismiss."
|
||||
: ChatColor.RED + "Must be online to dismiss."
|
||||
);
|
||||
inventory.setItem(slot, playerItem(
|
||||
follower.name(), follower.playerId(), status.toString()
|
||||
));
|
||||
slot++;
|
||||
}
|
||||
inventory.setItem(49, backItem());
|
||||
player.openInventory(inventory);
|
||||
}
|
||||
|
||||
private void clickFollowers(Player player, int slot) {
|
||||
if (slot == 49) {
|
||||
refresh(player);
|
||||
return;
|
||||
}
|
||||
selectedPlayer(player, slot, "Online")
|
||||
.filter(targetId -> server.getPlayer(targetId) != null)
|
||||
.ifPresent(targetId -> openDismissConfirmation(player, targetId));
|
||||
}
|
||||
|
||||
private void openDismissConfirmation(Player player, UUID targetId) {
|
||||
Player target = server.getPlayer(targetId);
|
||||
if (target == null) {
|
||||
player.sendMessage(ChatColor.RED + "That Follower is no longer online.");
|
||||
openFollowers(player);
|
||||
return;
|
||||
}
|
||||
MenuHolder holder = new MenuHolder(MenuView.DISMISS_CONFIRMATION, targetId);
|
||||
Inventory inventory = create(holder, 27, TITLE + " — Confirm Dismissal");
|
||||
inventory.setItem(11, cancelItem());
|
||||
inventory.setItem(13, item(Material.PLAYER_HEAD, ChatColor.YELLOW + target.getName(),
|
||||
List.of("Dismiss this Follower?")));
|
||||
inventory.setItem(15, confirmItem());
|
||||
player.openInventory(inventory);
|
||||
}
|
||||
|
||||
private void clickDismissConfirmation(Player player, int slot, UUID targetId) {
|
||||
if (slot == 11) {
|
||||
openFollowers(player);
|
||||
} else if (slot == 15 && targetId != null) {
|
||||
Player target = server.getPlayer(targetId);
|
||||
if (target == null) {
|
||||
player.sendMessage(ChatColor.RED + "That Follower is no longer online.");
|
||||
} else {
|
||||
player.performCommand("vigilante dismiss " + target.getName());
|
||||
}
|
||||
openFollowers(player);
|
||||
}
|
||||
}
|
||||
|
||||
private void openRole(Player player) {
|
||||
MenuHolder holder = new MenuHolder(MenuView.ROLE, null);
|
||||
Inventory inventory = create(holder, 27, TITLE + " — Role Control");
|
||||
inventory.setItem(13, item(Material.REDSTONE_TORCH,
|
||||
ChatColor.RED + "Relinquish Vigilante Role",
|
||||
List.of("Followers will be cleared and a replacement selected.",
|
||||
"Click to review confirmation.")));
|
||||
inventory.setItem(22, backItem());
|
||||
player.openInventory(inventory);
|
||||
}
|
||||
|
||||
private void clickRole(Player player, int slot) {
|
||||
if (slot == 22) {
|
||||
refresh(player);
|
||||
} else if (slot == 13) {
|
||||
MenuHolder holder = new MenuHolder(MenuView.RELINQUISH_CONFIRMATION, null);
|
||||
Inventory inventory = create(holder, 27, TITLE + " — Confirm Relinquishment");
|
||||
inventory.setItem(11, cancelItem());
|
||||
inventory.setItem(13, item(Material.BARRIER, ChatColor.RED + "Relinquish Role",
|
||||
List.of("This clears all Followers and rerolls the Vigilante.")));
|
||||
inventory.setItem(15, confirmItem());
|
||||
player.openInventory(inventory);
|
||||
}
|
||||
}
|
||||
|
||||
private void clickRelinquishConfirmation(Player player, int slot) {
|
||||
if (slot == 11) {
|
||||
openRole(player);
|
||||
} else if (slot == 15) {
|
||||
player.closeInventory();
|
||||
player.performCommand("tyrant relinquish confirm");
|
||||
}
|
||||
}
|
||||
|
||||
private java.util.Optional<UUID> selectedPlayer(
|
||||
Player player,
|
||||
int slot,
|
||||
String requiredStatus
|
||||
) {
|
||||
ItemStack clicked = player.getOpenInventory().getTopInventory().getItem(slot);
|
||||
if (clicked == null || clicked.getType() != Material.PLAYER_HEAD) {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
List<String> lore = clicked.getItemMeta().getLore();
|
||||
if (lore == null || lore.size() < 2
|
||||
|| !ChatColor.stripColor(lore.get(0)).contains(requiredStatus)) {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
try {
|
||||
return java.util.Optional.of(UUID.fromString(
|
||||
ChatColor.stripColor(lore.get(lore.size() - 1))
|
||||
));
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
player.sendMessage(ChatColor.RED + "That player selection is no longer valid.");
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isActiveVigilante(Player player) {
|
||||
GameState game = stateManager.game();
|
||||
return game.lifecycle() == GameLifecycle.RUNNING
|
||||
&& game.vigilanteId().filter(player.getUniqueId()::equals).isPresent();
|
||||
}
|
||||
|
||||
private VigilanteControlPanelModel model(Player player) {
|
||||
Map<UUID, PlayerState> players = stateManager.players();
|
||||
return VigilanteControlPanelModel.create(
|
||||
stateManager.game(), players, followers.invitedPlayerIds(player.getUniqueId()),
|
||||
presenceProvider.inspect(player, players), strengthCap, resistanceCap
|
||||
);
|
||||
}
|
||||
|
||||
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 playerItem(String name, UUID playerId, String status) {
|
||||
return item(Material.PLAYER_HEAD, name, List.of(status, playerId.toString()));
|
||||
}
|
||||
|
||||
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 ItemStack cancelItem() {
|
||||
return item(Material.RED_WOOL, ChatColor.RED + "Cancel", List.of());
|
||||
}
|
||||
|
||||
private static ItemStack confirmItem() {
|
||||
return item(Material.LIME_WOOL, ChatColor.GREEN + "Confirm", List.of());
|
||||
}
|
||||
|
||||
private static List<String> followerSummary(VigilanteControlPanelModel model) {
|
||||
if (model.followers().isEmpty()) {
|
||||
return List.of("No Followers assigned.");
|
||||
}
|
||||
return model.followers().stream()
|
||||
.map(follower -> follower.name() + (follower.contributes() ? " — contributing" : ""))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private enum MenuView {
|
||||
OVERVIEW,
|
||||
RECRUITMENT,
|
||||
INVITE_CONFIRMATION,
|
||||
FOLLOWERS,
|
||||
DISMISS_CONFIRMATION,
|
||||
ROLE,
|
||||
RELINQUISH_CONFIRMATION
|
||||
}
|
||||
|
||||
private static final class MenuHolder implements InventoryHolder {
|
||||
private final MenuView view;
|
||||
private final UUID targetId;
|
||||
private Inventory inventory;
|
||||
|
||||
private MenuHolder(MenuView view, UUID targetId) {
|
||||
this.view = view;
|
||||
this.targetId = targetId;
|
||||
}
|
||||
|
||||
private MenuView view() {
|
||||
return view;
|
||||
}
|
||||
|
||||
private UUID targetId() {
|
||||
return targetId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Inventory getInventory() {
|
||||
return inventory;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
public final class DefaultVigilanteControlPanel implements VigilanteControlPanel {
|
||||
private final TyrantStateManager stateManager;
|
||||
private final FollowerService followers;
|
||||
private final VigilanteControlPanelRenderer renderer;
|
||||
private final FollowerPresenceProvider presenceProvider;
|
||||
private final int strengthCap;
|
||||
private final int resistanceCap;
|
||||
private final Set<UUID> openedSessions = new HashSet<>();
|
||||
|
||||
public DefaultVigilanteControlPanel(
|
||||
TyrantStateManager stateManager,
|
||||
FollowerService followers,
|
||||
VigilanteControlPanelRenderer renderer,
|
||||
FollowerPresenceProvider presenceProvider,
|
||||
int strengthCap,
|
||||
int resistanceCap
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.followers = followers;
|
||||
this.renderer = renderer;
|
||||
this.presenceProvider = presenceProvider;
|
||||
this.strengthCap = strengthCap;
|
||||
this.resistanceCap = resistanceCap;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void open(Player player) {
|
||||
GameState game = stateManager.game();
|
||||
if (game.lifecycle() != GameLifecycle.RUNNING
|
||||
|| game.vigilanteId().filter(player.getUniqueId()::equals).isEmpty()) {
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "Only the active Vigilante can open the control panel while the event is running.");
|
||||
return;
|
||||
}
|
||||
Map<UUID, PlayerState> players = stateManager.players();
|
||||
renderer.openOverview(
|
||||
player,
|
||||
VigilanteControlPanelModel.create(
|
||||
game,
|
||||
players,
|
||||
followers.invitedPlayerIds(player.getUniqueId()),
|
||||
presenceProvider.inspect(player, players),
|
||||
strengthCap,
|
||||
resistanceCap
|
||||
)
|
||||
);
|
||||
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,12 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
public record FollowerPresence(
|
||||
boolean online,
|
||||
boolean living,
|
||||
boolean sameWorld,
|
||||
boolean withinRange
|
||||
) {
|
||||
public boolean contributes() {
|
||||
return online && living && sameWorld && withinRange;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface FollowerPresenceProvider {
|
||||
Map<UUID, FollowerPresence> inspect(Player vigilante, Map<UUID, PlayerState> players);
|
||||
}
|
||||
@@ -22,10 +22,14 @@ public final class FollowerService {
|
||||
}
|
||||
PlayerState target = players.get(targetId);
|
||||
if (target == null || target.optedOutUntil().isPresent()
|
||||
|| target.followerOf().isPresent()
|
||||
|| game.tyrantId().filter(targetId::equals).isPresent()
|
||||
|| game.vigilanteId().filter(targetId::equals).isPresent()) {
|
||||
return result(players, FollowerStatus.INELIGIBLE);
|
||||
}
|
||||
if (vigilanteId.equals(invitations.get(targetId))) {
|
||||
return result(players, FollowerStatus.ALREADY_INVITED);
|
||||
}
|
||||
invitations.put(targetId, vigilanteId);
|
||||
return result(players, FollowerStatus.INVITED);
|
||||
}
|
||||
@@ -77,6 +81,13 @@ public final class FollowerService {
|
||||
return updated(players, withFollower(target, Optional.empty()), FollowerStatus.LEFT);
|
||||
}
|
||||
|
||||
public java.util.Set<UUID> invitedPlayerIds(UUID vigilanteId) {
|
||||
return invitations.entrySet().stream()
|
||||
.filter(entry -> entry.getValue().equals(vigilanteId))
|
||||
.map(Map.Entry::getKey)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
public void clearInvitations() {
|
||||
invitations.clear();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package games.dmg.spigottyrant;
|
||||
|
||||
public enum FollowerStatus {
|
||||
INVITED,
|
||||
ALREADY_INVITED,
|
||||
JOINED,
|
||||
DISMISSED,
|
||||
LEFT,
|
||||
|
||||
@@ -85,6 +85,18 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
||||
TyrantControlPanel controlPanel = new DefaultTyrantControlPanel(
|
||||
stateManager, controlPanelRenderer, clock
|
||||
);
|
||||
FollowerPresenceProvider followerPresence = new BukkitFollowerPresenceProvider(
|
||||
getServer(), settings.followerRangeBlocks()
|
||||
);
|
||||
BukkitVigilanteControlPanelRenderer vigilanteControlPanelRenderer =
|
||||
new BukkitVigilanteControlPanelRenderer(
|
||||
getServer(), stateManager, followers, followerPresence,
|
||||
settings.followerStrengthCap(), settings.followerResistanceCap()
|
||||
);
|
||||
VigilanteControlPanel vigilanteControlPanel = new DefaultVigilanteControlPanel(
|
||||
stateManager, followers, vigilanteControlPanelRenderer, followerPresence,
|
||||
settings.followerStrengthCap(), settings.followerResistanceCap()
|
||||
);
|
||||
Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata")
|
||||
.setExecutor(new TyrantCommand(
|
||||
stateManager,
|
||||
@@ -100,7 +112,9 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
||||
controlPanel
|
||||
));
|
||||
Objects.requireNonNull(getCommand("vigilante"), "Missing vigilante metadata")
|
||||
.setExecutor(new VigilanteCommand(stateManager, followers, onlinePlayers));
|
||||
.setExecutor(new VigilanteCommand(
|
||||
stateManager, followers, onlinePlayers, vigilanteControlPanel
|
||||
));
|
||||
Objects.requireNonNull(getCommand("tyrantadmin"), "Missing tyrantadmin metadata")
|
||||
.setExecutor(new TyrantAdminCommand(
|
||||
stateManager,
|
||||
@@ -118,6 +132,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
||||
this
|
||||
);
|
||||
getServer().getPluginManager().registerEvents(controlPanelRenderer, this);
|
||||
getServer().getPluginManager().registerEvents(vigilanteControlPanelRenderer, this);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new TyrantControlPanelJoinListener(
|
||||
stateManager,
|
||||
@@ -127,6 +142,15 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
||||
),
|
||||
this
|
||||
);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new VigilanteControlPanelJoinListener(
|
||||
stateManager,
|
||||
vigilanteControlPanel,
|
||||
(task, delayTicks) -> getServer().getScheduler()
|
||||
.runTaskLater(this, task, delayTicks)
|
||||
),
|
||||
this
|
||||
);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new TyrantDeathListener(
|
||||
stateManager, succession, tyrantPresentation, getServer(), clock
|
||||
|
||||
@@ -11,15 +11,30 @@ public final class VigilanteCommand implements CommandExecutor {
|
||||
private final TyrantStateManager stateManager;
|
||||
private final FollowerService followers;
|
||||
private final OnlinePlayerDirectory onlinePlayers;
|
||||
private final VigilanteControlPanel controlPanel;
|
||||
|
||||
public VigilanteCommand(
|
||||
TyrantStateManager stateManager,
|
||||
FollowerService followers,
|
||||
OnlinePlayerDirectory onlinePlayers
|
||||
) {
|
||||
this(
|
||||
stateManager, followers, onlinePlayers,
|
||||
player -> player.sendMessage(ChatColor.RED
|
||||
+ "The Vigilante control panel is unavailable.")
|
||||
);
|
||||
}
|
||||
|
||||
public VigilanteCommand(
|
||||
TyrantStateManager stateManager,
|
||||
FollowerService followers,
|
||||
OnlinePlayerDirectory onlinePlayers,
|
||||
VigilanteControlPanel controlPanel
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.followers = followers;
|
||||
this.onlinePlayers = onlinePlayers;
|
||||
this.controlPanel = controlPanel;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -33,6 +48,11 @@ public final class VigilanteCommand implements CommandExecutor {
|
||||
sender.sendMessage("This command must be used by a player.");
|
||||
return true;
|
||||
}
|
||||
if (arguments.length == 0
|
||||
|| arguments.length == 1 && arguments[0].equalsIgnoreCase("menu")) {
|
||||
controlPanel.open(player);
|
||||
return true;
|
||||
}
|
||||
FollowerResult result;
|
||||
Player target = null;
|
||||
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("accept")) {
|
||||
@@ -61,7 +81,7 @@ public final class VigilanteCommand implements CommandExecutor {
|
||||
);
|
||||
} else {
|
||||
player.sendMessage(ChatColor.YELLOW
|
||||
+ "Usage: /vigilante <invite <player>|accept|dismiss <player>|leave>");
|
||||
+ "Usage: /vigilante <menu|invite <player>|accept|dismiss <player>|leave>");
|
||||
return true;
|
||||
}
|
||||
if (result.status() == FollowerStatus.JOINED
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface VigilanteControlPanel {
|
||||
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 VigilanteControlPanelJoinListener implements Listener {
|
||||
private static final long OPEN_DELAY_TICKS = 100L;
|
||||
private final TyrantStateManager stateManager;
|
||||
private final VigilanteControlPanel controlPanel;
|
||||
private final DelayedTaskScheduler scheduler;
|
||||
|
||||
public VigilanteControlPanelJoinListener(
|
||||
TyrantStateManager stateManager,
|
||||
VigilanteControlPanel 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(() -> openIfStillVigilante(player), OPEN_DELAY_TICKS);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onQuit(PlayerQuitEvent event) {
|
||||
controlPanel.endSession(event.getPlayer().getUniqueId());
|
||||
}
|
||||
|
||||
private void openIfStillVigilante(Player player) {
|
||||
GameState game = stateManager.game();
|
||||
if (player.isOnline()
|
||||
&& game.lifecycle() == GameLifecycle.RUNNING
|
||||
&& game.vigilanteId().filter(player.getUniqueId()::equals).isPresent()
|
||||
&& !controlPanel.openedThisSession(player.getUniqueId())) {
|
||||
controlPanel.open(player);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public record VigilanteControlPanelModel(
|
||||
List<VigilanteFollowerView> followers,
|
||||
Set<String> pendingInvitations,
|
||||
int nearbyEligibleFollowers,
|
||||
int strengthLevel,
|
||||
int resistanceLevel,
|
||||
int strengthCap,
|
||||
int resistanceCap
|
||||
) {
|
||||
public VigilanteControlPanelModel {
|
||||
followers = List.copyOf(followers);
|
||||
pendingInvitations = Set.copyOf(pendingInvitations);
|
||||
}
|
||||
|
||||
public static VigilanteControlPanelModel create(
|
||||
GameState game,
|
||||
Map<UUID, PlayerState> players,
|
||||
Set<UUID> pendingInvitationIds,
|
||||
Map<UUID, FollowerPresence> presence,
|
||||
int strengthCap,
|
||||
int resistanceCap
|
||||
) {
|
||||
UUID vigilanteId = game.vigilanteId().orElseThrow();
|
||||
List<VigilanteFollowerView> followers = players.values().stream()
|
||||
.filter(player -> player.followerOf().filter(vigilanteId::equals).isPresent())
|
||||
.map(player -> new VigilanteFollowerView(
|
||||
player.playerId(), player.latestName(), player.optedOutUntil().isPresent(),
|
||||
presence.getOrDefault(
|
||||
player.playerId(), new FollowerPresence(false, false, false, false)
|
||||
)
|
||||
))
|
||||
.sorted(Comparator.comparing(VigilanteFollowerView::name))
|
||||
.toList();
|
||||
Set<String> pending = pendingInvitationIds.stream()
|
||||
.map(players::get)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.map(PlayerState::latestName)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
int nearby = (int) followers.stream()
|
||||
.filter(VigilanteFollowerView::contributes)
|
||||
.count();
|
||||
VigilantePower.Levels levels = VigilantePower.forFollowers(
|
||||
nearby, strengthCap, resistanceCap
|
||||
);
|
||||
return new VigilanteControlPanelModel(
|
||||
followers, pending, nearby, levels.strength(), levels.resistance(),
|
||||
strengthCap, resistanceCap
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface VigilanteControlPanelRenderer {
|
||||
void openOverview(Player player, VigilanteControlPanelModel model);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record VigilanteFollowerView(
|
||||
UUID playerId,
|
||||
String name,
|
||||
boolean optedOut,
|
||||
FollowerPresence presence
|
||||
) {
|
||||
public boolean contributes() {
|
||||
return !optedOut && presence.contributes();
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ commands:
|
||||
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>
|
||||
usage: /vigilante <menu|invite <player>|accept|dismiss <player>|leave>
|
||||
tyrantadmin:
|
||||
description: Administer the Spigot Tyrant game.
|
||||
usage: /tyrantadmin <status|start|pause|resume|reset confirm>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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.Duration;
|
||||
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 DefaultVigilanteControlPanelTest {
|
||||
@Test
|
||||
void activeVigilanteOpensAuthoritativeOverview() {
|
||||
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||
UUID vigilanteId = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||
GameState game = game(tyrantId, vigilanteId);
|
||||
PlayerState vigilanteState = PlayerState.newPlayer(vigilanteId, "Vigilante");
|
||||
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||
when(manager.game()).thenReturn(game);
|
||||
when(manager.players()).thenReturn(Map.of(vigilanteId, vigilanteState));
|
||||
Player vigilante = mock(Player.class);
|
||||
when(vigilante.getUniqueId()).thenReturn(vigilanteId);
|
||||
VigilanteControlPanelRenderer renderer = mock(VigilanteControlPanelRenderer.class);
|
||||
FollowerPresenceProvider presence = mock(FollowerPresenceProvider.class);
|
||||
when(presence.inspect(any(), any())).thenReturn(Map.of());
|
||||
DefaultVigilanteControlPanel panel = new DefaultVigilanteControlPanel(
|
||||
manager, new FollowerService(), renderer, presence, 5, 4
|
||||
);
|
||||
|
||||
panel.open(vigilante);
|
||||
|
||||
verify(renderer).openOverview(org.mockito.ArgumentMatchers.eq(vigilante), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonVigilanteCannotRenderControlPanel() {
|
||||
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||
UUID vigilanteId = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||
Player other = mock(Player.class);
|
||||
when(other.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||
when(manager.game()).thenReturn(game(tyrantId, vigilanteId));
|
||||
VigilanteControlPanelRenderer renderer = mock(VigilanteControlPanelRenderer.class);
|
||||
DefaultVigilanteControlPanel panel = new DefaultVigilanteControlPanel(
|
||||
manager, new FollowerService(), renderer, mock(FollowerPresenceProvider.class), 5, 4
|
||||
);
|
||||
|
||||
panel.open(other);
|
||||
|
||||
verify(renderer, never()).openOverview(any(), any());
|
||||
verify(other).sendMessage(org.mockito.ArgumentMatchers.contains("active Vigilante"));
|
||||
}
|
||||
|
||||
private static GameState game(UUID tyrantId, UUID vigilanteId) {
|
||||
return new GameState(
|
||||
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.of(vigilanteId),
|
||||
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
|
||||
0, 0, Set.of()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
@@ -34,6 +35,32 @@ final class FollowerServiceTest {
|
||||
assertEquals(TyrantClass.FIXER, accepted.players().get(FOLLOWER).tyrantClass());
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedInvitationIsRejectedAndPendingInvitationCanBeInspected() {
|
||||
GameState game = game();
|
||||
PlayerState target = PlayerState.newPlayer(FOLLOWER, "Follower");
|
||||
Map<UUID, PlayerState> players = Map.of(FOLLOWER, target);
|
||||
|
||||
FollowerResult first = service.invite(game, players, VIGILANTE, FOLLOWER);
|
||||
FollowerResult repeated = service.invite(game, players, VIGILANTE, FOLLOWER);
|
||||
|
||||
assertEquals(FollowerStatus.INVITED, first.status());
|
||||
assertEquals(FollowerStatus.ALREADY_INVITED, repeated.status());
|
||||
assertTrue(service.invitedPlayerIds(VIGILANTE).contains(FOLLOWER));
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingFollowerCannotReceiveAnotherInvitation() {
|
||||
GameState game = game();
|
||||
PlayerState follower = withFollower(PlayerState.newPlayer(FOLLOWER, "Follower"));
|
||||
|
||||
FollowerResult result = service.invite(
|
||||
game, Map.of(FOLLOWER, follower), VIGILANTE, FOLLOWER
|
||||
);
|
||||
|
||||
assertEquals(FollowerStatus.INELIGIBLE, result.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void vigilanteCanDismissAndFollowerCanLeave() {
|
||||
GameState game = game();
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class VigilanteCommandTest {
|
||||
@Test
|
||||
void playerCanOpenControlPanelWithNoArgumentsOrMenuSubcommand() {
|
||||
UUID playerId = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
VigilanteControlPanel controlPanel = mock(VigilanteControlPanel.class);
|
||||
VigilanteCommand command = new VigilanteCommand(
|
||||
mock(TyrantStateManager.class),
|
||||
new FollowerService(),
|
||||
mock(OnlinePlayerDirectory.class),
|
||||
controlPanel
|
||||
);
|
||||
|
||||
command.onCommand(player, mock(Command.class), "vigilante", new String[0]);
|
||||
command.onCommand(player, mock(Command.class), "vigilante", new String[] {"menu"});
|
||||
|
||||
verify(controlPanel, times(2)).open(player);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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 VigilanteControlPanelJoinListenerTest {
|
||||
@Test
|
||||
void opensPanelOneHundredTicksAfterActiveVigilanteJoins() {
|
||||
UUID vigilanteId = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||
when(manager.game()).thenReturn(game(GameLifecycle.RUNNING, vigilanteId));
|
||||
Player vigilante = player(vigilanteId);
|
||||
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
|
||||
when(event.getPlayer()).thenReturn(vigilante);
|
||||
DelayedTaskScheduler scheduler = mock(DelayedTaskScheduler.class);
|
||||
VigilanteControlPanel panel = mock(VigilanteControlPanel.class);
|
||||
VigilanteControlPanelJoinListener listener = new VigilanteControlPanelJoinListener(
|
||||
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(vigilante);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotOpenAfterPauseOrWhenAlreadyOpenedManually() {
|
||||
UUID vigilanteId = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||
Player vigilante = player(vigilanteId);
|
||||
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
|
||||
when(event.getPlayer()).thenReturn(vigilante);
|
||||
DelayedTaskScheduler scheduler = mock(DelayedTaskScheduler.class);
|
||||
VigilanteControlPanel panel = mock(VigilanteControlPanel.class);
|
||||
when(panel.openedThisSession(vigilanteId)).thenReturn(true);
|
||||
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||
when(manager.game()).thenReturn(game(GameLifecycle.PAUSED, vigilanteId));
|
||||
VigilanteControlPanelJoinListener listener = new VigilanteControlPanelJoinListener(
|
||||
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(vigilante);
|
||||
}
|
||||
|
||||
private static Player player(UUID id) {
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
return player;
|
||||
}
|
||||
|
||||
private static GameState game(GameLifecycle lifecycle, UUID vigilanteId) {
|
||||
return new GameState(
|
||||
lifecycle,
|
||||
Optional.of(UUID.fromString("11111111-1111-1111-1111-111111111111")),
|
||||
Optional.of(vigilanteId), Optional.empty(), Optional.empty(),
|
||||
lifecycle == GameLifecycle.PAUSED
|
||||
? Optional.of(java.time.Instant.EPOCH) : Optional.empty(),
|
||||
Duration.ZERO, 0, 0, Set.of()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class VigilanteControlPanelModelTest {
|
||||
@Test
|
||||
void overviewReportsFollowersInvitationsAndCappedNearbyPower() {
|
||||
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||
UUID vigilanteId = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||
UUID nearbyId = UUID.fromString("33333333-3333-3333-3333-333333333333");
|
||||
UUID offlineId = UUID.fromString("44444444-4444-4444-4444-444444444444");
|
||||
UUID invitedId = UUID.fromString("55555555-5555-5555-5555-555555555555");
|
||||
GameState game = new GameState(
|
||||
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.of(vigilanteId),
|
||||
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
|
||||
0, 0, Set.of()
|
||||
);
|
||||
PlayerState nearby = follower(nearbyId, "Nearby", vigilanteId);
|
||||
PlayerState offline = follower(offlineId, "Offline", vigilanteId);
|
||||
PlayerState invited = PlayerState.newPlayer(invitedId, "Invited");
|
||||
|
||||
VigilanteControlPanelModel model = VigilanteControlPanelModel.create(
|
||||
game,
|
||||
Map.of(nearbyId, nearby, offlineId, offline, invitedId, invited),
|
||||
Set.of(invitedId),
|
||||
Map.of(
|
||||
nearbyId, new FollowerPresence(true, true, true, true),
|
||||
offlineId, new FollowerPresence(false, false, false, false)
|
||||
),
|
||||
5,
|
||||
4
|
||||
);
|
||||
|
||||
assertEquals(2, model.followers().size());
|
||||
assertEquals(Set.of("Invited"), model.pendingInvitations());
|
||||
assertEquals(1, model.nearbyEligibleFollowers());
|
||||
assertEquals(1, model.strengthLevel());
|
||||
assertEquals(1, model.resistanceLevel());
|
||||
assertEquals(5, model.strengthCap());
|
||||
assertEquals(4, model.resistanceCap());
|
||||
}
|
||||
|
||||
private static PlayerState follower(UUID id, String name, UUID vigilanteId) {
|
||||
PlayerState player = PlayerState.newPlayer(id, name);
|
||||
return new PlayerState(
|
||||
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
|
||||
player.tyrantClass(), Optional.of(vigilanteId), player.cooldownEnds(),
|
||||
player.readyAbilityItems(), player.capturedMobs()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user