Compare commits

..
6 Commits
Author SHA1 Message Date
dmg 3d65576010 fix(assassin): preserve external potion effects
Release / release (push) Successful in 2m46s
CI / build (push) Successful in 1m8s
2026-08-18 23:50:15 -04:00
dmg 323d177573 feat(participation): highlight opted-out players
Release / release (push) Successful in 2m40s
CI / build (push) Successful in 1m8s
2026-08-15 09:13:34 -04:00
dmg ea267ff867 fix(status): simplify Tyrant join messaging
Release / release (push) Successful in 2m36s
CI / build (push) Successful in 1m5s
2026-08-15 09:09:06 -04:00
dmg 24c54a7281 feat(participation): isolate opted-out combat
Release / release (push) Successful in 2m24s
CI / build (push) Successful in 1m4s
2026-08-14 23:33:24 -04:00
dmg fe07c202f9 feat(items): secure bound ability inventory
Release / release (push) Successful in 2m7s
CI / build (push) Successful in 1m0s
2026-08-14 23:21:05 -04:00
dmg 777cbc6b4b feat(tamer): capture and relocate mobs
Release / release (push) Successful in 2m24s
CI / build (push) Successful in 1m16s
2026-08-14 23:18:26 -04:00
30 changed files with 1435 additions and 49 deletions
+61
View File
@@ -6,6 +6,59 @@ description: Chronological record of material decisions affecting the Spigot Tyr
# Spigot Tyrant Design Log
## 2026-08-18 — Preserve ordinary potion effects
- Corrected Assassin effect maintenance so it refreshes active Tyrant abilities without removing invisibility, Speed, or Weakness supplied by potions, commands, or other plugins.
- Expired or inapplicable Tyrant-managed effects stop refreshing and expire naturally within their short 30-tick maintenance duration, while double-jump flight cleanup remains immediate.
- Reopened and completed US-005 with regression coverage; verified `./gradlew clean check jar`.
## 2026-08-14 — Opt-out proximity glow completed
- Extended US-011 with a globally visible green outline for opted-out players in the Tyrant's world and configured range while the event is running.
- A periodic controller removes the glow when eligibility, proximity, world, lifecycle, or player availability changes and clears managed state on logout and shutdown.
- Scoreboard presentation records and restores each player's previous team and glowing state instead of permanently replacing unrelated presentation.
- Verified inclusive range behavior, lifecycle and availability gating, green presentation, restoration, and the full Gradle build.
## 2026-08-14 — Opt-out proximity glow started
- US-011 is reopened for a test-first green outline on opted-out players near the Tyrant during a running event, using the existing configurable Tyrant range.
- Cleanup will cover range, world, participation, lifecycle, availability, shutdown, and restoration of pre-existing scoreboard-team membership.
## 2026-08-14 — Join messaging refinement completed
- Updated US-011 so join output no longer announces running, paused, or unstarted lifecycle state.
- Eligible unassigned players now see the neutral prompt, `Use /tyrant optout if you do not want to participate in Tyrant events.`
- Role, class, ability, cooldown, and existing opt-out details remain unchanged.
- Verified both assigned and unassigned join output with automated tests.
## 2026-08-14 — Join messaging refinement started
- US-011 is reopened to remove lifecycle announcements from join output and make the opt-out prompt refer generally to Tyrant events.
## 2026-08-14 — Opt-out combat protection completed
- Extended US-011 with bidirectional combat isolation between opted-out players and the Tyrant, Assassin, Fixer, or Tamer while the event is running.
- Direct player attacks and player-fired projectiles are cancelled at highest event priority, and the attacker is told that the target has chosen not to participate in the Tyrant event.
- Paused, unstarted, and unrelated combat remains ordinary Minecraft gameplay.
- Verified both attack directions, every Tyrant-side class, projectile attribution, lifecycle gating, and unrelated combat with automated tests.
## 2026-08-14 — Opt-out combat protection started
- US-011 is reopened for test-first running-event combat isolation between opted-out players and the Tyrant or Tyrant-side class holders.
- Paused and unstarted gameplay remains ordinary Minecraft combat; blocked direct and projectile attackers receive a concise Tyrant event participation message.
## 2026-08-14 — Tamer completed
- Completed US-007 with the no-cooldown owner-bound Tamer's Lead, server-generated entity snapshots, durable capture IDs, and one bound Captured Mob item per entity.
- Capture checks inventory capacity before atomically storing state and removing the entity; placement consumes state and item only after safe validated spawning.
- Dropping or dying with a captured mob spawns it immediately, while container and dispenser transfer paths are blocked and invalid placement retains custody.
- Ender Dragons are always forbidden; configured bosses, named mobs, and tamed or owned mobs are denied, and snapshot type is validated before creation.
- Verified snapshot custody state, exact release, Ender Dragon rejection, YAML persistence, configuration, and the full Gradle build.
## 2026-08-14 — Tamer implementation started
- US-007 begins with test-first snapshot custody, no-cooldown capture items, safe placement/drop/death spawning, and duplication-resistant state transitions.
## 2026-08-14 — Tyrant abilities completed
- Completed US-009 with configurable permanent Strength and Resistance purchases continuously restored for the active Tyrant throughout the running reign.
@@ -39,6 +92,14 @@ description: Chronological record of material decisions affecting the Spigot Tyr
- US-005 begins with test-first bound-item invisibility, near-Tyrant duration doubling, and cooldown-gated double-jump effects.
## 2026-08-14 — Bound ability items completed
- Completed US-014 with configurable named standard items carrying hidden owner and ability metadata, interaction cancellation, and owner-validated Assassin, Fixer, and Tamer activation.
- Cooldown items are consumed only on successful use, restored automatically when ready or by `/tyrant item`, deduplicated, and withheld with clear guidance when inventory space is unavailable.
- Drop, pickup, placement, crafting/storage movement, item-frame, dispenser, death, class loss, reign reset, login, and restart paths prevent transfer and duplication.
- Captured-mob custody items use independent IDs, reconcile missing or duplicate inventory items against durable snapshots, and spawn rather than vanish on drop or death.
- Verified readiness consumption and refresh, assignment cleanup, durable metadata state, captured custody, and the full Gradle build.
## 2026-08-14 — Bound ability item implementation started
- US-014 begins with test-first item readiness and recovery, owner metadata, transfer prevention, cooldown redelivery, and Tamer item permanence.
@@ -20,6 +20,13 @@ As the **Assassin**, I want stealth and burst mobility so that I can ambush oppo
- [x] Grounding and supported movement reset jump availability only after the 60-second cooldown has elapsed.
- [x] Ability cooldowns use unpaused elapsed time and survive death, logout, and restart.
- [x] The player can inspect remaining cooldowns and receives clear feedback when an activation is unavailable.
- [x] Tyrant removes only Assassin potion effects that Tyrant itself applied.
- [x] Invisibility from potions, commands, or other plugins remains active for Assassins and non-Assassins.
- [x] Tyrant-managed Assassin effects are still removed when abilities expire, roles change, games stop, or the plugin disables.
## Validation
Automated controller tests verify that inactive Tyrant state does not alter ordinary potion effects, active Assassin invisibility is refreshed, and refresh stops at expiry so the short managed effect expires naturally. The complete `./gradlew clean check jar` lifecycle passes.
## Related
@@ -2,7 +2,7 @@
type: User Story
title: "US-007: Capture and place mobs as a Tamer"
description: Let the Tamer move supported mobs without losing or duplicating them.
status: backlog
status: done
---
# US-007: Capture and place mobs as a Tamer
@@ -11,16 +11,16 @@ As the **Tamer**, I want to capture a mob and release it elsewhere so that I can
## Acceptance criteria
- [ ] The Tamer can use a named fishing rod on a supported targeted mob to capture it with no cooldown.
- [ ] A successful capture atomically removes the original mob and creates one bound captured-mob item.
- [ ] If inventory space is unavailable, capture fails, the mob remains in place, and the Tamer is told how to make room and retry.
- [ ] Using the captured-mob item places the stored mob at a safe valid location and consumes the item only after a successful spawn.
- [ ] Dropping a captured-mob item safely spawns its mob instead of deleting it.
- [ ] Every captured mob carried by a player spawns safely when that player dies.
- [ ] Ender Dragons, boss entities, named mobs, and mobs tamed or owned by another player are denied by default.
- [ ] Capture restrictions are configurable without permitting Ender Dragons.
- [ ] Invalid placement, full inventory, death, logout, restart, plugin disable, containers, and concurrent interaction cannot lose or duplicate a mob.
- [ ] Stored mob data is validated defensively before spawning and cannot execute untrusted serialized behavior.
- [x] The Tamer can use a named fishing rod on a supported targeted mob to capture it with no cooldown.
- [x] A successful capture atomically removes the original mob and creates one bound captured-mob item.
- [x] If inventory space is unavailable, capture fails, the mob remains in place, and the Tamer is told how to make room and retry.
- [x] Using the captured-mob item places the stored mob at a safe valid location and consumes the item only after a successful spawn.
- [x] Dropping a captured-mob item safely spawns its mob instead of deleting it.
- [x] Every captured mob carried by a player spawns safely when that player dies.
- [x] Ender Dragons, boss entities, named mobs, and mobs tamed or owned by another player are denied by default.
- [x] Capture restrictions are configurable without permitting Ender Dragons.
- [x] Invalid placement, full inventory, death, logout, restart, plugin disable, containers, and concurrent interaction cannot lose or duplicate a mob.
- [x] Stored mob data is validated defensively before spawning and cannot execute untrusted serialized behavior.
## Related
@@ -11,7 +11,8 @@ As a **player**, I want to understand my status and control my eligibility so th
## Acceptance criteria
- [x] On join, a player is told the game status and their role, class, subclass, abilities, and relevant cooldowns.
- [x] On join, lifecycle state is omitted while role, class, subclass, ability, and relevant cooldown information remains available when applicable.
- [x] Eligible players are invited to opt out of Tyrant events without implying that an event is currently running.
- [x] An unassigned opted-in player is told that they are eligible and shown the opt-out command.
- [x] An opted-out player is told when they may opt back in.
- [x] An unassigned player can opt out through a command.
@@ -20,6 +21,15 @@ As a **player**, I want to understand my status and control my eligibility so th
- [x] A player cannot opt back in until seven days have elapsed by default.
- [x] The opt-out duration is configurable, uses unpaused elapsed time, and survives restart.
- [x] Participation commands are idempotent and clearly report current status and remaining time.
- [x] While the event is running, damage is cancelled when the Tyrant or an Assassin, Fixer, or Tamer attacks an opted-out player.
- [x] While the event is running, damage is cancelled when an opted-out player attacks the Tyrant or an Assassin, Fixer, or Tamer.
- [x] Cancelled attackers are told that the target has chosen not to take part in the Tyrant event.
- [x] Protection covers direct player attacks and player-fired projectiles without changing unrelated combat.
- [x] Participation combat protection does not apply while the event is paused or not started.
- [x] While the event is running, an opted-out player in the same world and within the configured Tyrant range has a green glowing outline visible to everyone.
- [x] The opt-out glow is removed when proximity, participation, player availability, Tyrant availability, or running-event requirements stop being satisfied.
- [x] Existing scoreboard-team membership is restored after the opt-out glow ends, and plugin shutdown safely clears managed glow state.
- [x] Opt-out proximity glow does not apply while the event is paused or not started.
## Related
@@ -2,7 +2,7 @@
type: User Story
title: "US-014: Use class ability items"
description: Give class holders named bound items that activate abilities without enabling transfer or cooldown bypasses.
status: in-progress
status: done
---
# US-014: Use class ability items
@@ -11,17 +11,17 @@ As a **class holder**, I want a recognizable inventory item for my ability so th
## Acceptance criteria
- [ ] Each item-triggered class ability uses a uniquely named standard Minecraft item with hidden persistent ownership and ability metadata.
- [ ] Right-clicking or swinging the appropriate item activates the ability without also performing an unsafe vanilla action.
- [ ] Another player cannot activate, retain, craft with, place, store, frame, dispense, or otherwise transfer a bound ability item.
- [ ] A cooldown ability item disappears only after successful activation and is restored when the cooldown finishes.
- [ ] The Tamer's named fishing rod has no cooldown and remains available after successful capture or placement.
- [ ] A dropped class ability item vanishes immediately; a dropped captured-mob item instead attempts to spawn its mob safely.
- [ ] `/tyrant item` restores currently available missing class items but cannot bypass cooldowns or create duplicates.
- [ ] Missing ready items are restored on login and cooldown completion when inventory space is available.
- [ ] When delivery fails because the inventory is full, no item is dropped and the player is told both why and how to use `/tyrant item` after making room.
- [ ] Bound class items are removed when a player loses the class or the Tyrant's reign ends.
- [ ] Death, inventory movement, server restart, repeated commands, and concurrent events cannot duplicate a bound item or its captured mob.
- [x] Each item-triggered class ability uses a uniquely named standard Minecraft item with hidden persistent ownership and ability metadata.
- [x] Right-clicking or swinging the appropriate item activates the ability without also performing an unsafe vanilla action.
- [x] Another player cannot activate, retain, craft with, place, store, frame, dispense, or otherwise transfer a bound ability item.
- [x] A cooldown ability item disappears only after successful activation and is restored when the cooldown finishes.
- [x] The Tamer's named fishing rod has no cooldown and remains available after successful capture or placement.
- [x] A dropped class ability item vanishes immediately; a dropped captured-mob item instead attempts to spawn its mob safely.
- [x] `/tyrant item` restores currently available missing class items but cannot bypass cooldowns or create duplicates.
- [x] Missing ready items are restored on login and cooldown completion when inventory space is available.
- [x] When delivery fails because the inventory is full, no item is dropped and the player is told both why and how to use `/tyrant item` after making room.
- [x] Bound class items are removed when a player loses the class or the Tyrant's reign ends.
- [x] Death, inventory movement, server restart, repeated commands, and concurrent events cannot duplicate a bound item or its captured mob.
## Related
@@ -10,17 +10,20 @@ public final class AbilityItemRefreshTask implements Runnable {
private final AbilityItemService items;
private final Server server;
private final Clock clock;
private final CapturedMobInventoryService capturedMobInventory;
public AbilityItemRefreshTask(
TyrantStateManager stateManager,
AbilityReadinessService readiness,
AbilityItemService items,
CapturedMobInventoryService capturedMobInventory,
Server server,
Clock clock
) {
this.stateManager = stateManager;
this.readiness = readiness;
this.items = items;
this.capturedMobInventory = capturedMobInventory;
this.server = server;
this.clock = clock;
}
@@ -40,6 +43,7 @@ public final class AbilityItemRefreshTask implements Runnable {
}
items.removeInvalid(player, after);
items.recover(player, after);
capturedMobInventory.reconcile(player, after);
}
}
}
@@ -3,6 +3,7 @@ package games.dmg.spigottyrant;
import java.time.Clock;
import java.time.Instant;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import org.bukkit.GameMode;
@@ -17,6 +18,7 @@ public final class AssassinEffectController implements Runnable {
private final Server server;
private final PluginSettings settings;
private final Clock clock;
private final EffectApplier effectApplier;
private final Set<UUID> grantedFlight = new HashSet<>();
public AssassinEffectController(
@@ -25,10 +27,21 @@ public final class AssassinEffectController implements Runnable {
PluginSettings settings,
Clock clock
) {
this.stateManager = stateManager;
this.server = server;
this.settings = settings;
this.clock = clock;
this(stateManager, server, settings, clock, new BukkitEffectApplier());
}
AssassinEffectController(
TyrantStateManager stateManager,
Server server,
PluginSettings settings,
Clock clock,
EffectApplier effectApplier
) {
this.stateManager = Objects.requireNonNull(stateManager, "stateManager");
this.server = Objects.requireNonNull(server, "server");
this.settings = Objects.requireNonNull(settings, "settings");
this.clock = Objects.requireNonNull(clock, "clock");
this.effectApplier = Objects.requireNonNull(effectApplier, "effectApplier");
}
@Override
@@ -43,15 +56,15 @@ public final class AssassinEffectController implements Runnable {
}
applyWhileActive(
player, state, Ability.ASSASSIN_INVISIBILITY_ACTIVE,
PotionEffectType.INVISIBILITY, 0, false
EffectKind.INVISIBILITY, 0, false
);
applyWhileActive(
player, state, Ability.ASSASSIN_SPEED_ACTIVE,
PotionEffectType.SPEED, 0, true
EffectKind.SPEED, 0, true
);
applyWhileActive(
player, state, Ability.ASSASSIN_WEAKNESS_ACTIVE,
PotionEffectType.WEAKNESS, settings.assassinWeaknessLevel() - 1, true
EffectKind.WEAKNESS, settings.assassinWeaknessLevel() - 1, true
);
boolean jumpReady = !state.cooldownEnds().getOrDefault(
Ability.ASSASSIN_DOUBLE_JUMP, Instant.MIN
@@ -71,29 +84,52 @@ public final class AssassinEffectController implements Runnable {
Player player,
PlayerState state,
Ability ability,
PotionEffectType type,
EffectKind effect,
int amplifier,
boolean particles
) {
if (state.cooldownEnds().getOrDefault(ability, Instant.MIN).isAfter(clock.instant())) {
player.addPotionEffect(new PotionEffect(
type, EFFECT_TICKS, amplifier, false, particles, true
));
} else {
player.removePotionEffect(type);
effectApplier.apply(player, effect, amplifier, particles);
}
}
private void clear(Player player) {
player.removePotionEffect(PotionEffectType.INVISIBILITY);
player.removePotionEffect(PotionEffectType.SPEED);
player.removePotionEffect(PotionEffectType.WEAKNESS);
if (grantedFlight.remove(player.getUniqueId()) && isSurvivalLike(player)) {
player.setAllowFlight(false);
player.setFlying(false);
}
}
enum EffectKind {
INVISIBILITY,
SPEED,
WEAKNESS
}
@FunctionalInterface
interface EffectApplier {
void apply(Player player, EffectKind effect, int amplifier, boolean particles);
}
private static final class BukkitEffectApplier implements EffectApplier {
@Override
public void apply(
Player player,
EffectKind effect,
int amplifier,
boolean particles
) {
PotionEffectType type = switch (effect) {
case INVISIBILITY -> PotionEffectType.INVISIBILITY;
case SPEED -> PotionEffectType.SPEED;
case WEAKNESS -> PotionEffectType.WEAKNESS;
};
player.addPotionEffect(new PotionEffect(
type, EFFECT_TICKS, amplifier, false, particles, true
));
}
}
private static boolean isGrounded(Player player) {
return !player.getLocation().clone().subtract(0.0, 0.1, 0.0)
.getBlock().isPassable();
@@ -45,6 +45,7 @@ public final class BoundItemListener implements Listener {
if (items.isBoundAbilityItem(event.getCurrentItem())
|| items.isBoundAbilityItem(event.getCursor())) {
if (!(event.getWhoClicked() instanceof Player player)
|| event.isShiftClick()
|| event.getClickedInventory() == null
|| !event.getClickedInventory().equals(player.getInventory())) {
event.setCancelled(true);
@@ -81,11 +81,17 @@ public final class BukkitAbilityItemService implements AbilityItemService {
@Override
public void removeInvalid(Player player, PlayerState state) {
ItemStack[] contents = player.getInventory().getContents();
java.util.Set<Ability> retained = java.util.EnumSet.noneOf(Ability.class);
for (int index = 0; index < contents.length; index++) {
ItemStack item = contents[index];
if (isBoundAbilityItem(item)
&& (owner(item).filter(state.playerId()::equals).isEmpty()
|| ability(item).filter(state.readyAbilityItems()::contains).isEmpty())) {
if (!isBoundAbilityItem(item)) {
continue;
}
Optional<Ability> itemAbility = ability(item);
boolean valid = owner(item).filter(state.playerId()::equals).isPresent()
&& itemAbility.filter(state.readyAbilityItems()::contains).isPresent()
&& itemAbility.filter(retained::add).isPresent();
if (!valid) {
player.getInventory().setItem(index, null);
}
}
@@ -0,0 +1,79 @@
package games.dmg.spigottyrant;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.Plugin;
public final class BukkitCapturedMobItemService implements CapturedMobItemService {
private final NamespacedKey ownerKey;
private final NamespacedKey captureKey;
private final NamespacedKey typeKey;
private final PluginSettings settings;
public BukkitCapturedMobItemService(Plugin plugin, PluginSettings settings) {
ownerKey = new NamespacedKey(plugin, "captured-owner");
captureKey = new NamespacedKey(plugin, "captured-id");
typeKey = new NamespacedKey(plugin, "captured-type");
this.settings = settings;
}
@Override
public ItemStack create(UUID ownerId, UUID captureId, String entityType) {
Material material = Material.matchMaterial(settings.capturedMobItem().material());
if (material == null) {
throw new IllegalStateException("Captured mob item material is unavailable");
}
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
meta.setDisplayName(ChatColor.GOLD + settings.capturedMobItem().name()
+ ": " + entityType);
meta.getPersistentDataContainer().set(
ownerKey, PersistentDataType.STRING, ownerId.toString()
);
meta.getPersistentDataContainer().set(
captureKey, PersistentDataType.STRING, captureId.toString()
);
meta.getPersistentDataContainer().set(
typeKey, PersistentDataType.STRING, entityType
);
item.setItemMeta(meta);
return item;
}
@Override
public boolean isCapturedMob(ItemStack item) {
return owner(item).isPresent() && captureId(item).isPresent();
}
@Override
public Optional<UUID> owner(ItemStack item) {
return uuid(item, ownerKey);
}
@Override
public Optional<UUID> captureId(ItemStack item) {
return uuid(item, captureKey);
}
private static Optional<UUID> uuid(ItemStack item, NamespacedKey key) {
if (item == null || !item.hasItemMeta()) {
return Optional.empty();
}
String value = item.getItemMeta().getPersistentDataContainer()
.get(key, PersistentDataType.STRING);
if (value == null) {
return Optional.empty();
}
try {
return Optional.of(UUID.fromString(value));
} catch (IllegalArgumentException exception) {
return Optional.empty();
}
}
}
@@ -0,0 +1,61 @@
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;
import org.bukkit.inventory.ItemStack;
public final class CapturedMobInventoryService {
private final CapturedMobItemService items;
private final PluginSettings settings;
public CapturedMobInventoryService(
CapturedMobItemService items,
PluginSettings settings
) {
this.items = items;
this.settings = settings;
}
public void reconcile(Player player, PlayerState state) {
Map<UUID, CapturedMob> stored = new java.util.HashMap<>();
for (CapturedMob mob : state.capturedMobs()) {
try {
UUID captureId = UUID.fromString(mob.data().get("capture-id"));
stored.putIfAbsent(captureId, mob);
} catch (IllegalArgumentException | NullPointerException ignored) {
// Invalid custody records are not materialized as items.
}
}
Set<UUID> retained = new HashSet<>();
ItemStack[] contents = player.getInventory().getContents();
for (int index = 0; index < contents.length; index++) {
ItemStack item = contents[index];
if (!items.isCapturedMob(item)) {
continue;
}
UUID captureId = items.captureId(item).orElseThrow();
boolean valid = items.owner(item).filter(state.playerId()::equals).isPresent()
&& stored.containsKey(captureId) && retained.add(captureId);
if (!valid) {
player.getInventory().setItem(index, null);
}
}
for (Map.Entry<UUID, CapturedMob> entry : stored.entrySet()) {
if (retained.contains(entry.getKey())) {
continue;
}
Map<Integer, ItemStack> leftovers = player.getInventory().addItem(
items.create(
state.playerId(), entry.getKey(), entry.getValue().entityType()
)
);
if (!leftovers.isEmpty()) {
player.sendMessage(ChatColor.RED + settings.inventoryFullMessage());
}
}
}
}
@@ -0,0 +1,15 @@
package games.dmg.spigottyrant;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.inventory.ItemStack;
public interface CapturedMobItemService {
ItemStack create(UUID ownerId, UUID captureId, String entityType);
boolean isCapturedMob(ItemStack item);
Optional<UUID> owner(ItemStack item);
Optional<UUID> captureId(ItemStack item);
}
@@ -0,0 +1,53 @@
package games.dmg.spigottyrant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
public final class CapturedMobService {
public PlayerState capture(
PlayerState player,
UUID captureId,
String entityType,
String snapshot
) {
if (player.tyrantClass() != TyrantClass.TAMER) {
throw new IllegalStateException("only a Tamer can capture mobs");
}
if ("ENDER_DRAGON".equalsIgnoreCase(entityType)) {
throw new IllegalArgumentException("Ender Dragons cannot be captured");
}
List<CapturedMob> mobs = new ArrayList<>(player.capturedMobs());
mobs.add(new CapturedMob(entityType, Map.of(
"capture-id", captureId.toString(),
"snapshot", snapshot
)));
return copy(player, mobs);
}
public Optional<CapturedMob> find(PlayerState player, UUID captureId) {
return player.capturedMobs().stream()
.filter(mob -> captureId.toString().equals(mob.data().get("capture-id")))
.findFirst();
}
public PlayerState release(PlayerState player, UUID captureId) {
if (find(player, captureId).isEmpty()) {
return player;
}
List<CapturedMob> mobs = player.capturedMobs().stream()
.filter(mob -> !captureId.toString().equals(mob.data().get("capture-id")))
.toList();
return copy(player, mobs);
}
private static PlayerState copy(PlayerState player, List<CapturedMob> mobs) {
return new PlayerState(
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
player.tyrantClass(), player.followerOf(), player.cooldownEnds(),
player.readyAbilityItems(), mobs
);
}
}
@@ -0,0 +1,127 @@
package games.dmg.spigottyrant;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.ScoreboardManager;
import org.bukkit.scoreboard.Team;
public final class OptOutGlowController implements Runnable, Listener {
private static final String TEAM_NAME = "tyrant-optout";
private final TyrantStateManager stateManager;
private final Server server;
private final PluginSettings settings;
private final Scoreboard scoreboard;
private final Team greenTeam;
private final Map<UUID, PreviousPresentation> managed = new HashMap<>();
public OptOutGlowController(
TyrantStateManager stateManager,
Server server,
PluginSettings settings
) {
this.stateManager = stateManager;
this.server = server;
this.settings = settings;
ScoreboardManager manager = Objects.requireNonNull(
server.getScoreboardManager(), "Scoreboard manager is unavailable"
);
scoreboard = manager.getMainScoreboard();
Team existing = scoreboard.getTeam(TEAM_NAME);
greenTeam = existing == null ? scoreboard.registerNewTeam(TEAM_NAME) : existing;
greenTeam.setColor(ChatColor.GREEN);
}
@Override
public void run() {
GameState game = stateManager.game();
Player tyrant = game.tyrantId().map(server::getPlayer).orElse(null);
boolean tyrantAvailable = tyrant != null && !tyrant.isDead();
Set<UUID> online = new HashSet<>();
for (Player player : server.getOnlinePlayers()) {
online.add(player.getUniqueId());
PlayerState state = stateManager.players().get(player.getUniqueId());
boolean sameWorld = tyrantAvailable && !player.isDead()
&& player.getWorld().equals(tyrant.getWorld());
double distanceSquared = sameWorld
? player.getLocation().distanceSquared(tyrant.getLocation())
: Double.POSITIVE_INFINITY;
boolean shouldGlow = state != null && OptOutGlowPolicy.shouldGlow(
game, state, tyrantAvailable, sameWorld, distanceSquared,
settings.tyrantRangeBlocks()
);
if (shouldGlow && !managed.containsKey(player.getUniqueId())) {
apply(player);
} else if (!shouldGlow && managed.containsKey(player.getUniqueId())) {
restore(player.getUniqueId(), player);
}
}
for (UUID playerId : Set.copyOf(managed.keySet())) {
if (!online.contains(playerId) && server.getPlayer(playerId) == null) {
restore(playerId, null);
}
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent event) {
if (managed.containsKey(event.getPlayer().getUniqueId())) {
restore(event.getPlayer().getUniqueId(), event.getPlayer());
}
}
public void clearAll() {
for (UUID playerId : Set.copyOf(managed.keySet())) {
restore(playerId, server.getPlayer(playerId));
}
}
private void apply(Player player) {
Team previousTeam = scoreboard.getEntryTeam(player.getName());
if (previousTeam == greenTeam) {
previousTeam = null;
}
managed.put(
player.getUniqueId(),
new PreviousPresentation(player.getName(), previousTeam, player.isGlowing())
);
greenTeam.addEntry(player.getName());
player.setGlowing(true);
}
private void restore(UUID playerId, Player player) {
PreviousPresentation previous = managed.remove(playerId);
if (previous == null) {
return;
}
greenTeam.removeEntry(previous.playerName());
if (previous.team() != null) {
try {
previous.team().addEntry(previous.playerName());
} catch (IllegalStateException ignored) {
// The prior team was removed while this plugin managed the entry.
}
}
if (player != null) {
player.setGlowing(previous.glowing());
}
}
private record PreviousPresentation(
String playerName,
Team team,
boolean glowing
) {
}
}
@@ -0,0 +1,22 @@
package games.dmg.spigottyrant;
public final class OptOutGlowPolicy {
private OptOutGlowPolicy() {
}
public static boolean shouldGlow(
GameState game,
PlayerState player,
boolean tyrantAvailable,
boolean playerAvailableInTyrantWorld,
double distanceSquared,
double rangeBlocks
) {
return game.lifecycle() == GameLifecycle.RUNNING
&& game.tyrantId().isPresent()
&& player.optedOutUntil().isPresent()
&& tyrantAvailable
&& playerAvailableInTyrantWorld
&& distanceSquared <= rangeBlocks * rangeBlocks;
}
}
@@ -0,0 +1,64 @@
package games.dmg.spigottyrant;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
public final class ParticipationCombatListener implements Listener {
public static final String OPTED_OUT_MESSAGE =
"That player has chosen not to take part in the Tyrant event.";
private final TyrantStateManager stateManager;
private final ParticipationCombatService combat;
public ParticipationCombatListener(
TyrantStateManager stateManager,
ParticipationCombatService combat
) {
this.stateManager = stateManager;
this.combat = combat;
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onDamage(EntityDamageByEntityEvent event) {
if (!(event.getEntity() instanceof Player target)) {
return;
}
Optional<Player> attacker = attackingPlayer(event);
if (attacker.isEmpty()) {
return;
}
Player source = attacker.orElseThrow();
Map<UUID, PlayerState> players = stateManager.players();
PlayerState attackerState = players.getOrDefault(
source.getUniqueId(),
PlayerState.newPlayer(source.getUniqueId(), source.getName())
);
PlayerState targetState = players.getOrDefault(
target.getUniqueId(),
PlayerState.newPlayer(target.getUniqueId(), target.getName())
);
if (combat.blocks(stateManager.game(), attackerState, targetState)) {
event.setCancelled(true);
source.sendMessage(ChatColor.YELLOW + OPTED_OUT_MESSAGE);
}
}
private static Optional<Player> attackingPlayer(EntityDamageByEntityEvent event) {
if (event.getDamager() instanceof Player player) {
return Optional.of(player);
}
if (event.getDamager() instanceof Projectile projectile
&& projectile.getShooter() instanceof Player player) {
return Optional.of(player);
}
return Optional.empty();
}
}
@@ -0,0 +1,24 @@
package games.dmg.spigottyrant;
public final class ParticipationCombatService {
public boolean blocks(
GameState game,
PlayerState attacker,
PlayerState target
) {
if (game.lifecycle() != GameLifecycle.RUNNING) {
return false;
}
boolean attackerOptedOut = attacker.optedOutUntil().isPresent();
boolean targetOptedOut = target.optedOutUntil().isPresent();
boolean attackerTyrantSide = isTyrantSide(game, attacker);
boolean targetTyrantSide = isTyrantSide(game, target);
return (attackerTyrantSide && targetOptedOut)
|| (attackerOptedOut && targetTyrantSide);
}
private static boolean isTyrantSide(GameState game, PlayerState player) {
return game.tyrantId().filter(player.playerId()::equals).isPresent()
|| player.tyrantClass() != TyrantClass.NONE;
}
}
@@ -15,7 +15,6 @@ public final class PlayerStatusMessages {
Instant now
) {
List<String> messages = new ArrayList<>();
messages.add("Game: " + game.lifecycle());
boolean hasRole = false;
if (game.tyrantId().filter(player.playerId()::equals).isPresent()) {
messages.add("Role: TYRANT. Level " + game.tyrantLevel() + ", choices "
@@ -45,8 +44,8 @@ public final class PlayerStatusMessages {
messages.add("Participation: OPTED OUT. You may use /tyrant optin at "
+ player.optedOutUntil().orElseThrow() + ".");
} else if (!hasRole) {
messages.add("You currently have no role or class. Use /tyrant optout "
+ "if you do not want to participate.");
messages.add("Use /tyrant optout if you do not want to participate "
+ "in Tyrant events.");
}
return List.copyOf(messages);
}
@@ -38,6 +38,7 @@ public record PluginSettings(
AbilityItemSettings assassinItem,
AbilityItemSettings fixerItem,
AbilityItemSettings tamerItem,
AbilityItemSettings capturedMobItem,
String recoveryCommand,
String inventoryFullMessage,
boolean freezeTimersWhilePaused
@@ -76,6 +77,7 @@ public record PluginSettings(
assassinItem = Objects.requireNonNull(assassinItem, "assassinItem");
fixerItem = Objects.requireNonNull(fixerItem, "fixerItem");
tamerItem = Objects.requireNonNull(tamerItem, "tamerItem");
capturedMobItem = Objects.requireNonNull(capturedMobItem, "capturedMobItem");
recoveryCommand = requireText(recoveryCommand, "recovery-command");
inventoryFullMessage = requireText(inventoryFullMessage, "inventory-full-message");
}
@@ -111,6 +113,7 @@ public record PluginSettings(
item(values, "assassin-item", new AbilityItemSettings("STICK", "Assassin Cloak")),
item(values, "fixer-item", new AbilityItemSettings("STICK", "Fixer's Wrench")),
item(values, "tamer-item", new AbilityItemSettings("FISHING_ROD", "Tamer's Lead")),
item(values, "captured-mob-item", new AbilityItemSettings("PAPER", "Captured Mob")),
string(values, "recovery-command", "/tyrant item"),
string(
values,
@@ -11,6 +11,7 @@ public final class PluginSettingsValidator {
validateItem(settings.assassinItem(), "assassin-item");
validateItem(settings.fixerItem(), "fixer-item");
validateItem(settings.tamerItem(), "tamer-item");
validateItem(settings.capturedMobItem(), "captured-mob-item");
for (String entityType : settings.deniedMobTypes()) {
try {
EntityType.valueOf(entityType);
@@ -15,6 +15,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
private AssassinEffectController assassinEffects;
private FixerEffectController fixerEffects;
private TyrantEffectController tyrantEffects;
private OptOutGlowController optOutGlow;
@Override
public void onEnable() {
@@ -53,6 +54,13 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
FollowerService followers = new FollowerService();
AbilityReadinessService readiness = new AbilityReadinessService();
AbilityItemService abilityItems = new BukkitAbilityItemService(this, settings);
CapturedMobItemService capturedMobItems = new BukkitCapturedMobItemService(
this, settings
);
CapturedMobService capturedMobs = new CapturedMobService();
CapturedMobInventoryService capturedMobInventory = new CapturedMobInventoryService(
capturedMobItems, settings
);
AssassinAbilityService assassinAbilities = new AssassinAbilityService(
readiness, settings
);
@@ -62,6 +70,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
FixerAbilityService fixerAbilities = new FixerAbilityService(readiness, settings);
fixerEffects = new FixerEffectController(stateManager, getServer(), settings, clock);
tyrantEffects = new TyrantEffectController(stateManager, getServer(), settings);
optOutGlow = new OptOutGlowController(stateManager, getServer(), settings);
VigilanteCombatTracker combatTracker = new VigilanteCombatTracker();
vigilanteEffects = new VigilanteEffectController(
stateManager, getServer(), combatTracker, settings, clock
@@ -115,6 +124,13 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
),
this
);
getServer().getPluginManager().registerEvents(
new ParticipationCombatListener(
stateManager, new ParticipationCombatService()
),
this
);
getServer().getPluginManager().registerEvents(optOutGlow, this);
getServer().getPluginManager().registerEvents(
new VigilanteCombatListener(
stateManager, combatTracker, clock, settings.vigilanteCombatDuration()
@@ -125,6 +141,13 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
new BoundItemListener(abilityItems),
this
);
getServer().getPluginManager().registerEvents(
new TamerListener(
stateManager, abilityItems, capturedMobItems, capturedMobs,
settings, getServer()
),
this
);
getServer().getPluginManager().registerEvents(
new AssassinItemListener(
stateManager, abilityItems, assassinAbilities, settings, clock
@@ -156,10 +179,12 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
getServer().getScheduler().runTaskTimer(this, assassinEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, fixerEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, tyrantEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, optOutGlow, 10L, 10L);
getServer().getScheduler().runTaskTimer(
this,
new AbilityItemRefreshTask(
stateManager, readiness, abilityItems, getServer(), clock
stateManager, readiness, abilityItems, capturedMobInventory,
getServer(), clock
),
20L,
20L
@@ -170,6 +195,9 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
@Override
public void onDisable() {
if (optOutGlow != null) {
optOutGlow.clearAll();
}
if (tyrantEffects != null) {
tyrantEffects.clearAll();
}
@@ -0,0 +1,249 @@
package games.dmg.spigottyrant;
import java.util.Iterator;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.entity.Entity;
import org.bukkit.entity.EntitySnapshot;
import org.bukkit.entity.ItemFrame;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.entity.Tameable;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.block.BlockDispenseEvent;
import org.bukkit.event.entity.EntityPickupItemEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.player.PlayerDropItemEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.ItemStack;
public final class TamerListener implements Listener {
private final TyrantStateManager stateManager;
private final AbilityItemService abilityItems;
private final CapturedMobItemService capturedItems;
private final CapturedMobService capturedMobs;
private final PluginSettings settings;
private final Server server;
public TamerListener(
TyrantStateManager stateManager,
AbilityItemService abilityItems,
CapturedMobItemService capturedItems,
CapturedMobService capturedMobs,
PluginSettings settings,
Server server
) {
this.stateManager = stateManager;
this.abilityItems = abilityItems;
this.capturedItems = capturedItems;
this.capturedMobs = capturedMobs;
this.settings = settings;
this.server = server;
}
@EventHandler(priority = EventPriority.HIGH)
public void onCapture(PlayerInteractEntityEvent event) {
ItemStack held = event.getPlayer().getInventory().getItem(event.getHand());
if (abilityItems.ability(held).filter(Ability.TAMER_CAPTURE::equals).isEmpty()) {
return;
}
event.setCancelled(true);
Player player = event.getPlayer();
if (abilityItems.owner(held).filter(player.getUniqueId()::equals).isEmpty()) {
player.getInventory().setItem(event.getHand(), null);
return;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
Entity target = event.getRightClicked();
if (!canCapture(state, target)) {
player.sendMessage(ChatColor.RED + "That mob cannot be captured.");
return;
}
EntitySnapshot snapshot = target.createSnapshot();
if (snapshot == null) {
player.sendMessage(ChatColor.RED + "That mob could not be captured safely.");
return;
}
UUID captureId = UUID.randomUUID();
ItemStack capturedItem = capturedItems.create(
player.getUniqueId(), captureId, target.getType().name()
);
Map<Integer, ItemStack> leftovers = player.getInventory().addItem(capturedItem);
if (!leftovers.isEmpty()) {
player.sendMessage(ChatColor.RED + settings.inventoryFullMessage());
return;
}
PlayerState updated = capturedMobs.capture(
state, captureId, target.getType().name(), snapshot.getAsString()
);
stateManager.updatePlayer(
player.getUniqueId(), player.getName(), current -> updated
);
stateManager.saveIfDirty();
target.remove();
player.sendMessage(ChatColor.GREEN + "Captured " + target.getType().name() + ".");
}
@EventHandler(priority = EventPriority.HIGH)
public void onPlace(PlayerInteractEvent event) {
Action action = event.getAction();
if (action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK) {
return;
}
if (!capturedItems.isCapturedMob(event.getItem())) {
return;
}
event.setCancelled(true);
UUID captureId = capturedItems.captureId(event.getItem()).orElseThrow();
if (capturedItems.owner(event.getItem())
.filter(event.getPlayer().getUniqueId()::equals).isEmpty()) {
event.getPlayer().getInventory().setItem(event.getHand(), null);
return;
}
Location location = event.getClickedBlock() == null
? event.getPlayer().getLocation().add(
event.getPlayer().getLocation().getDirection().normalize().multiply(2.0)
)
: event.getClickedBlock().getRelative(event.getBlockFace())
.getLocation().add(0.5, 0.0, 0.5);
if (spawnAndRelease(event.getPlayer(), captureId, location)) {
event.getPlayer().getInventory().setItem(event.getHand(), null);
} else {
event.getPlayer().sendMessage(ChatColor.RED
+ "The mob cannot be placed safely there.");
}
}
@EventHandler(priority = EventPriority.HIGH)
public void onDrop(PlayerDropItemEvent event) {
ItemStack item = event.getItemDrop().getItemStack();
if (!capturedItems.isCapturedMob(item)) {
return;
}
UUID captureId = capturedItems.captureId(item).orElseThrow();
if (capturedItems.owner(item).filter(event.getPlayer().getUniqueId()::equals).isEmpty()
|| !spawnAndRelease(
event.getPlayer(), captureId, event.getItemDrop().getLocation()
)) {
event.setCancelled(true);
return;
}
event.getItemDrop().remove();
}
@EventHandler(priority = EventPriority.HIGH)
public void onDeath(PlayerDeathEvent event) {
Iterator<ItemStack> drops = event.getDrops().iterator();
while (drops.hasNext()) {
ItemStack item = drops.next();
if (!capturedItems.isCapturedMob(item)) {
continue;
}
UUID captureId = capturedItems.captureId(item).orElseThrow();
if (spawnAndRelease(event.getEntity(), captureId, event.getEntity().getLocation())) {
drops.remove();
}
}
}
@EventHandler
public void onInventoryClick(InventoryClickEvent event) {
if (capturedItems.isCapturedMob(event.getCurrentItem())
|| capturedItems.isCapturedMob(event.getCursor())) {
if (!(event.getWhoClicked() instanceof Player player)
|| event.isShiftClick()
|| event.getClickedInventory() == null
|| !event.getClickedInventory().equals(player.getInventory())) {
event.setCancelled(true);
}
}
}
@EventHandler
public void onFrame(PlayerInteractEntityEvent event) {
if (event.getRightClicked() instanceof ItemFrame
&& capturedItems.isCapturedMob(
event.getPlayer().getInventory().getItem(event.getHand())
)) {
event.setCancelled(true);
}
}
@EventHandler
public void onDispense(BlockDispenseEvent event) {
if (capturedItems.isCapturedMob(event.getItem())) {
event.setCancelled(true);
}
}
@EventHandler
public void onPickup(EntityPickupItemEvent event) {
if (!(event.getEntity() instanceof Player player)
|| !capturedItems.isCapturedMob(event.getItem().getItemStack())) {
return;
}
if (capturedItems.owner(event.getItem().getItemStack())
.filter(player.getUniqueId()::equals).isEmpty()) {
event.setCancelled(true);
}
}
private boolean canCapture(PlayerState player, Entity target) {
if (stateManager.game().lifecycle() != GameLifecycle.RUNNING
|| player.tyrantClass() != TyrantClass.TAMER
|| !(target instanceof LivingEntity)
|| target instanceof Player
|| target.getType().name().equals("ENDER_DRAGON")
|| settings.deniedMobTypes().contains(target.getType().name())
|| target.getCustomName() != null) {
return false;
}
return !(target instanceof Tameable tameable) || !tameable.isTamed();
}
private boolean spawnAndRelease(Player player, UUID captureId, Location location) {
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
Optional<CapturedMob> stored = capturedMobs.find(state, captureId);
if (stored.isEmpty() || !safe(location)) {
return false;
}
CapturedMob mob = stored.orElseThrow();
if ("ENDER_DRAGON".equals(mob.entityType())
|| settings.deniedMobTypes().contains(mob.entityType())) {
return false;
}
try {
EntitySnapshot snapshot = server.getEntityFactory().createEntitySnapshot(
mob.data().get("snapshot")
);
if (!snapshot.getEntityType().name().equals(mob.entityType())) {
return false;
}
snapshot.createEntity(location);
} catch (IllegalArgumentException | IllegalStateException exception) {
return false;
}
PlayerState released = capturedMobs.release(state, captureId);
stateManager.updatePlayer(
player.getUniqueId(), player.getName(), current -> released
);
stateManager.saveIfDirty();
return true;
}
private static boolean safe(Location location) {
return location.getWorld() != null
&& location.getBlock().isPassable()
&& location.clone().add(0.0, 1.0, 0.0).getBlock().isPassable()
&& location.getWorld().getWorldBorder().isInside(location);
}
}
+3
View File
@@ -35,6 +35,9 @@ fixer-item:
tamer-item:
material: FISHING_ROD
name: Tamer's Lead
captured-mob-item:
material: PAPER
name: Captured Mob
denied-mob-types:
- ENDER_DRAGON
- WITHER
@@ -0,0 +1,145 @@
package games.dmg.spigottyrant;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Logger;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class AssassinEffectControllerTest {
private static final Instant NOW = Instant.parse("2026-08-18T12:00:00Z");
private static final UUID PLAYER_ID = UUID.fromString(
"11111111-1111-1111-1111-111111111111"
);
@TempDir
Path temporaryDirectory;
@Test
void doesNotRemovePotionEffectsItDidNotApply() throws Exception {
TyrantStateManager stateManager = manager();
Player player = player();
Server server = server(player);
AssassinEffectController.EffectApplier effects = mock(
AssassinEffectController.EffectApplier.class
);
AssassinEffectController controller = controller(stateManager, server, effects);
controller.run();
controller.clearAll();
verifyNoInteractions(effects);
}
@Test
void stopsRefreshingManagedInvisibilityAfterItsDeadline() throws Exception {
TyrantStateManager stateManager = manager();
stateManager.updateGame(ignored -> runningGame());
stateManager.updatePlayer(PLAYER_ID, "Assassin", ignored -> assassin(Map.of(
Ability.ASSASSIN_INVISIBILITY_ACTIVE, NOW.plusSeconds(60),
Ability.ASSASSIN_DOUBLE_JUMP, NOW.plusSeconds(60)
)));
Player player = player();
Server server = server(player);
AssassinEffectController.EffectApplier effects = mock(
AssassinEffectController.EffectApplier.class
);
AssassinEffectController controller = controller(stateManager, server, effects);
controller.run();
verify(effects).apply(
player,
AssassinEffectController.EffectKind.INVISIBILITY,
0,
false
);
stateManager.updatePlayer(PLAYER_ID, "Assassin", ignored -> assassin(Map.of(
Ability.ASSASSIN_DOUBLE_JUMP, NOW.plusSeconds(60)
)));
clearInvocations(player, effects);
controller.run();
verifyNoInteractions(effects);
}
private TyrantStateManager manager() throws Exception {
return new TyrantStateManager(
new YamlTyrantStateRepository(temporaryDirectory.resolve("state.yml")),
Logger.getLogger("test")
);
}
private static AssassinEffectController controller(
TyrantStateManager stateManager,
Server server,
AssassinEffectController.EffectApplier effects
) {
return new AssassinEffectController(
stateManager,
server,
PluginSettings.from(Map.of()),
Clock.fixed(NOW, ZoneOffset.UTC),
effects
);
}
private static Server server(Player player) {
Server server = mock(Server.class);
doReturn(List.of(player)).when(server).getOnlinePlayers();
return server;
}
private static Player player() {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(PLAYER_ID);
when(player.getName()).thenReturn("Assassin");
return player;
}
private static PlayerState assassin(Map<Ability, Instant> cooldowns) {
return new PlayerState(
PLAYER_ID,
"Assassin",
Optional.empty(),
Optional.empty(),
TyrantClass.ASSASSIN,
Optional.empty(),
cooldowns,
Set.of(),
List.of()
);
}
private static GameState runningGame() {
return new GameState(
GameLifecycle.RUNNING,
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
java.time.Duration.ZERO,
0,
0,
Set.of()
);
}
}
@@ -0,0 +1,46 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class CapturedMobServiceTest {
private final CapturedMobService service = new CapturedMobService();
@Test
void captureAddsSnapshotAndSuccessfulReleaseRemovesExactlyThatMob() {
UUID captureId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
PlayerState tamer = tamer();
PlayerState captured = service.capture(
tamer, captureId, "ZOMBIE", "{id:\"minecraft:zombie\"}"
);
CapturedMob mob = service.find(captured, captureId).orElseThrow();
PlayerState released = service.release(captured, captureId);
assertEquals("ZOMBIE", mob.entityType());
assertEquals("{id:\"minecraft:zombie\"}", mob.data().get("snapshot"));
assertEquals(java.util.List.of(), released.capturedMobs());
}
@Test
void enderDragonCanNeverBeCapturedAndMissingReleaseDoesNotMutate() {
assertThrows(IllegalArgumentException.class, () -> service.capture(
tamer(), UUID.randomUUID(), "ENDER_DRAGON", "snapshot"
));
assertEquals(tamer(), service.release(tamer(), UUID.randomUUID()));
}
private static PlayerState tamer() {
return new PlayerState(
UUID.fromString("11111111-1111-1111-1111-111111111111"), "Tamer",
Optional.empty(), Optional.empty(), TyrantClass.TAMER, Optional.empty(),
Map.of(), Set.of(Ability.TAMER_CAPTURE), java.util.List.of()
);
}
}
@@ -0,0 +1,97 @@
package games.dmg.spigottyrant;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.ScoreboardManager;
import org.bukkit.scoreboard.Team;
import org.junit.jupiter.api.Test;
final class OptOutGlowControllerTest {
private static final UUID TYRANT = UUID.fromString(
"11111111-1111-1111-1111-111111111111"
);
private static final UUID OUT = UUID.fromString(
"22222222-2222-2222-2222-222222222222"
);
@Test
void appliesGreenGlowAndRestoresPreviousTeamAfterLeavingRange() {
TyrantStateManager stateManager = mock(TyrantStateManager.class);
Server server = mock(Server.class);
PluginSettings settings = mock(PluginSettings.class);
ScoreboardManager manager = mock(ScoreboardManager.class);
Scoreboard scoreboard = mock(Scoreboard.class);
Team green = mock(Team.class);
Team previous = mock(Team.class);
Player tyrant = player(TYRANT, "Tyrant");
Player optedOut = player(OUT, "Out");
World world = mock(World.class);
Location tyrantLocation = mock(Location.class);
Location outLocation = mock(Location.class);
when(server.getScoreboardManager()).thenReturn(manager);
when(manager.getMainScoreboard()).thenReturn(scoreboard);
when(scoreboard.getTeam("tyrant-optout")).thenReturn(null);
when(scoreboard.registerNewTeam("tyrant-optout")).thenReturn(green);
when(scoreboard.getEntryTeam("Out")).thenReturn(previous);
when(server.getPlayer(TYRANT)).thenReturn(tyrant);
doReturn(Set.of(tyrant, optedOut)).when(server).getOnlinePlayers();
when(settings.tyrantRangeBlocks()).thenReturn(50.0);
when(tyrant.getWorld()).thenReturn(world);
when(optedOut.getWorld()).thenReturn(world);
when(tyrant.getLocation()).thenReturn(tyrantLocation);
when(optedOut.getLocation()).thenReturn(outLocation);
when(outLocation.distanceSquared(tyrantLocation)).thenReturn(25.0, 2601.0);
when(stateManager.game()).thenReturn(running());
when(stateManager.players()).thenReturn(Map.of(OUT, optedOut()));
OptOutGlowController controller = new OptOutGlowController(
stateManager, server, settings
);
controller.run();
controller.run();
verify(green).setColor(ChatColor.GREEN);
verify(green).addEntry("Out");
verify(optedOut).setGlowing(true);
verify(green).removeEntry("Out");
verify(previous).addEntry("Out");
verify(optedOut).setGlowing(false);
}
private static Player player(UUID id, String name) {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
when(player.getName()).thenReturn(name);
return player;
}
private static PlayerState optedOut() {
return new PlayerState(
OUT, "Out", Optional.empty(), Optional.of(Instant.MAX), TyrantClass.NONE,
Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
}
private static GameState running() {
return new GameState(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 0, Set.of()
);
}
}
@@ -0,0 +1,76 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class OptOutGlowPolicyTest {
private static final UUID TYRANT = UUID.fromString(
"11111111-1111-1111-1111-111111111111"
);
private static final UUID PLAYER = UUID.fromString(
"22222222-2222-2222-2222-222222222222"
);
@Test
void optedOutPlayerGlowsWithinInclusiveRunningRange() {
assertTrue(OptOutGlowPolicy.shouldGlow(
running(), optedOut(), true, true, 2500.0, 50.0
));
}
@Test
void requiresRunningEventAvailablePlayersSameWorldAndRange() {
assertFalse(OptOutGlowPolicy.shouldGlow(
GameState.empty(), optedOut(), true, true, 1.0, 50.0
));
assertFalse(OptOutGlowPolicy.shouldGlow(
paused(), optedOut(), true, true, 1.0, 50.0
));
assertFalse(OptOutGlowPolicy.shouldGlow(
running(), optedIn(), true, true, 1.0, 50.0
));
assertFalse(OptOutGlowPolicy.shouldGlow(
running(), optedOut(), false, true, 1.0, 50.0
));
assertFalse(OptOutGlowPolicy.shouldGlow(
running(), optedOut(), true, false, 1.0, 50.0
));
assertFalse(OptOutGlowPolicy.shouldGlow(
running(), optedOut(), true, true, 2500.01, 50.0
));
}
private static PlayerState optedOut() {
return new PlayerState(
PLAYER, "Player", Optional.empty(), Optional.of(Instant.MAX),
TyrantClass.NONE, Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
}
private static PlayerState optedIn() {
return PlayerState.newPlayer(PLAYER, "Player");
}
private static GameState running() {
return game(GameLifecycle.RUNNING, Optional.empty());
}
private static GameState paused() {
return game(GameLifecycle.PAUSED, Optional.of(Instant.EPOCH));
}
private static GameState game(GameLifecycle lifecycle, Optional<Instant> pausedAt) {
return new GameState(
lifecycle, Optional.of(TYRANT), Optional.empty(), Optional.empty(),
Optional.empty(), pausedAt, Duration.ZERO, 0, 0, Set.of()
);
}
}
@@ -0,0 +1,75 @@
package games.dmg.spigottyrant;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.junit.jupiter.api.Test;
final class ParticipationCombatListenerTest {
private static final UUID TYRANT = UUID.fromString(
"11111111-1111-1111-1111-111111111111"
);
private static final UUID OUT = UUID.fromString(
"22222222-2222-2222-2222-222222222222"
);
@Test
void cancelsPlayerProjectileAndMessagesTheAttacker() {
TyrantStateManager manager = mock(TyrantStateManager.class);
Player attacker = player(TYRANT, "Tyrant");
Player target = player(OUT, "Out");
Projectile projectile = mock(Projectile.class);
EntityDamageByEntityEvent event = mock(EntityDamageByEntityEvent.class);
when(projectile.getShooter()).thenReturn(attacker);
when(event.getDamager()).thenReturn(projectile);
when(event.getEntity()).thenReturn(target);
when(manager.game()).thenReturn(running());
when(manager.players()).thenReturn(Map.of(
TYRANT, state(TYRANT, TyrantClass.NONE, false),
OUT, state(OUT, TyrantClass.NONE, true)
));
ParticipationCombatListener listener = new ParticipationCombatListener(
manager, new ParticipationCombatService()
);
listener.onDamage(event);
verify(event).setCancelled(true);
verify(attacker).sendMessage(ChatColor.YELLOW
+ ParticipationCombatListener.OPTED_OUT_MESSAGE);
}
private static Player player(UUID id, String name) {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
when(player.getName()).thenReturn(name);
return player;
}
private static PlayerState state(UUID id, TyrantClass role, boolean optedOut) {
return new PlayerState(
id, id.toString(), Optional.empty(),
optedOut ? Optional.of(Instant.parse("2026-08-21T00:00:00Z")) : Optional.empty(),
role, Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
}
private static GameState running() {
return new GameState(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 0, Set.of()
);
}
}
@@ -0,0 +1,90 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class ParticipationCombatServiceTest {
private static final UUID TYRANT = UUID.fromString(
"11111111-1111-1111-1111-111111111111"
);
private static final UUID OUTSIDER = UUID.fromString(
"22222222-2222-2222-2222-222222222222"
);
private static final UUID SUPPORTER = UUID.fromString(
"33333333-3333-3333-3333-333333333333"
);
private final ParticipationCombatService service = new ParticipationCombatService();
@Test
void blocksDamageInBothDirectionsBetweenOptedOutPlayerAndTyrantSide() {
PlayerState optedOut = player(OUTSIDER, TyrantClass.NONE, true);
PlayerState tyrant = player(TYRANT, TyrantClass.NONE, false);
PlayerState supporter = player(SUPPORTER, TyrantClass.ASSASSIN, false);
assertTrue(service.blocks(running(), tyrant, optedOut));
assertTrue(service.blocks(running(), optedOut, tyrant));
assertTrue(service.blocks(running(), supporter, optedOut));
assertTrue(service.blocks(running(), optedOut, supporter));
}
@Test
void recognizesEveryTyrantSideClassAsSupport() {
PlayerState optedOut = player(OUTSIDER, TyrantClass.NONE, true);
assertTrue(service.blocks(
running(), player(SUPPORTER, TyrantClass.ASSASSIN, false), optedOut
));
assertTrue(service.blocks(
running(), player(SUPPORTER, TyrantClass.FIXER, false), optedOut
));
assertTrue(service.blocks(
running(), player(SUPPORTER, TyrantClass.TAMER, false), optedOut
));
}
@Test
void leavesPausedUnstartedAndUnrelatedCombatAlone() {
PlayerState optedOut = player(OUTSIDER, TyrantClass.NONE, true);
PlayerState tyrant = player(TYRANT, TyrantClass.NONE, false);
PlayerState ordinary = player(SUPPORTER, TyrantClass.NONE, false);
assertFalse(service.blocks(paused(), tyrant, optedOut));
assertFalse(service.blocks(GameState.empty(), tyrant, optedOut));
assertFalse(service.blocks(running(), tyrant, ordinary));
assertFalse(service.blocks(running(), ordinary, optedOut));
}
private static PlayerState player(UUID id, TyrantClass role, boolean optedOut) {
return new PlayerState(
id, id.toString(), Optional.empty(),
optedOut ? Optional.of(Instant.parse("2026-08-21T00:00:00Z")) : Optional.empty(),
role, Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
}
private static GameState running() {
return game(GameLifecycle.RUNNING, Optional.empty());
}
private static GameState paused() {
return game(
GameLifecycle.PAUSED,
Optional.of(Instant.parse("2026-08-14T00:00:00Z"))
);
}
private static GameState game(GameLifecycle lifecycle, Optional<Instant> pausedAt) {
return new GameState(
lifecycle, Optional.of(TYRANT), Optional.empty(), Optional.empty(),
Optional.empty(), pausedAt, Duration.ZERO, 0, 0, Set.of()
);
}
}
@@ -1,5 +1,6 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
@@ -29,7 +30,8 @@ final class PlayerStatusMessagesTest {
List<String> messages = PlayerStatusMessages.forJoin(game, player, NOW);
assertTrue(messages.stream().anyMatch(message -> message.contains("Game: RUNNING")));
assertFalse(messages.stream().anyMatch(message -> message.startsWith("Game:")));
assertFalse(messages.stream().anyMatch(message -> message.startsWith("Tyrant event:")));
assertTrue(messages.stream().anyMatch(message -> message.contains("Role: VIGILANTE")));
assertTrue(messages.stream().anyMatch(message -> message.contains("Class: ASSASSIN")));
assertTrue(messages.stream().anyMatch(message -> message.contains("ASSASSIN_INVISIBILITY")
@@ -42,6 +44,8 @@ final class PlayerStatusMessagesTest {
GameState.empty(), PlayerState.newPlayer(PLAYER, "Player"), NOW
);
assertTrue(messages.stream().anyMatch(message -> message.contains("/tyrant optout")));
assertTrue(messages.contains(
"Use /tyrant optout if you do not want to participate in Tyrant events."
));
}
}