feat(vigilante): add followers and combat scaling
Release / release (push) Successful in 3m7s
CI / build (push) Successful in 1m15s

This commit is contained in:
dmg
2026-08-14 23:01:10 -04:00
parent 96c0c0014c
commit 0371337a3e
18 changed files with 612 additions and 13 deletions
+11
View File
@@ -6,6 +6,17 @@ description: Chronological record of material decisions affecting the Spigot Tyr
# Spigot Tyrant Design Log
## 2026-08-14 — Vigilante and Followers completed
- Completed US-008 with `/vigilante` invitation, acceptance, dismissal, and leave flows while preserving independent Tyrant-side class assignments.
- Added Tyrant/Vigilante combat tracking and continuously refreshed Strength and Resistance based on living same-world Followers within the configurable 50-block range, capped at Strength V and Resistance IV by default.
- Every Vigilante death now awards progression only to the Tyrant killer, clears Followers, and applies normal recently-active replacement with previous-holder fallback.
- Verified membership, dual alignment, scaling and caps, role maintenance, command metadata, and death/progression components with the Gradle suite.
## 2026-08-14 — Vigilante and Follower implementation started
- US-008 begins with test-first invitation membership, death reroll and Tyrant reward integration, combat tracking, and proximity-scaled capped effects.
## 2026-08-14 — Participation completed
- Completed US-011 with `/tyrant status`, `optout`, `optin`, and confirmed `relinquish` flows plus comprehensive join messages for lifecycle, roles, classes, abilities, and cooldowns.
@@ -2,7 +2,7 @@
type: User Story
title: "US-008: Support the Vigilante and Followers"
description: Give the Vigilante a replaceable role and combat strength derived from nearby Followers.
status: backlog
status: done
---
# US-008: Support the Vigilante and Followers
@@ -11,18 +11,18 @@ As the **Vigilante**, I want nearby living Followers to strengthen me against th
## Acceptance criteria
- [ ] Exactly one active Vigilante exists while the game is running whenever an eligible player is available.
- [ ] The Vigilante can invite eligible opted-in players to become Followers, and recruitment requires acceptance.
- [ ] The Vigilante can dismiss Followers and Followers can leave voluntarily.
- [ ] While actively fighting the Tyrant, the Vigilante gains one potion level each of Strength and Resistance per living Follower in the same world within 50 blocks.
- [ ] Offline, dead, opted-out, dismissed, or out-of-range Followers do not count.
- [ ] A Vigilante or Follower may simultaneously hold one Tyrant-side class without losing Vigilante-side membership.
- [ ] Effect levels update promptly as combat starts or ends and as Followers move, die, respawn, disconnect, or change assignment.
- [ ] Scaling is capped by validated configuration, defaulting to Strength V and Resistance IV.
- [ ] Every Vigilante death ends the assignment and triggers random replacement regardless of cause.
- [ ] The deceased Vigilante is excluded when another eligible non-Tyrant candidate exists and is reused only when no such candidate exists.
- [ ] Followers are cleared rather than transferred when their Vigilante dies or loses the position.
- [ ] The Tyrant is never eligible to be selected as Vigilante.
- [x] Exactly one active Vigilante exists while the game is running whenever an eligible player is available.
- [x] The Vigilante can invite eligible opted-in players to become Followers, and recruitment requires acceptance.
- [x] The Vigilante can dismiss Followers and Followers can leave voluntarily.
- [x] While actively fighting the Tyrant, the Vigilante gains one potion level each of Strength and Resistance per living Follower in the same world within 50 blocks.
- [x] Offline, dead, opted-out, dismissed, or out-of-range Followers do not count.
- [x] A Vigilante or Follower may simultaneously hold one Tyrant-side class without losing Vigilante-side membership.
- [x] Effect levels update promptly as combat starts or ends and as Followers move, die, respawn, disconnect, or change assignment.
- [x] Scaling is capped by validated configuration, defaulting to Strength V and Resistance IV.
- [x] Every Vigilante death ends the assignment and triggers random replacement regardless of cause.
- [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.
## Related
@@ -0,0 +1,10 @@
package games.dmg.spigottyrant;
import java.util.Map;
import java.util.UUID;
public record FollowerResult(Map<UUID, PlayerState> players, FollowerStatus status) {
public FollowerResult {
players = Map.copyOf(players);
}
}
@@ -0,0 +1,111 @@
package games.dmg.spigottyrant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
public final class FollowerService {
private final Map<UUID, UUID> invitations = new HashMap<>();
public FollowerResult invite(
GameState game,
Map<UUID, PlayerState> players,
UUID vigilanteId,
UUID targetId
) {
if (game.lifecycle() != GameLifecycle.RUNNING) {
return result(players, FollowerStatus.GAME_NOT_RUNNING);
}
if (game.vigilanteId().filter(vigilanteId::equals).isEmpty()) {
return result(players, FollowerStatus.NOT_VIGILANTE);
}
PlayerState target = players.get(targetId);
if (target == null || target.optedOutUntil().isPresent()
|| game.tyrantId().filter(targetId::equals).isPresent()
|| game.vigilanteId().filter(targetId::equals).isPresent()) {
return result(players, FollowerStatus.INELIGIBLE);
}
invitations.put(targetId, vigilanteId);
return result(players, FollowerStatus.INVITED);
}
public FollowerResult accept(
GameState game,
Map<UUID, PlayerState> players,
UUID targetId
) {
UUID invitedBy = invitations.get(targetId);
if (invitedBy == null
|| game.vigilanteId().filter(invitedBy::equals).isEmpty()) {
return result(players, FollowerStatus.NO_INVITATION);
}
PlayerState target = players.get(targetId);
if (target == null || target.optedOutUntil().isPresent()) {
invitations.remove(targetId);
return result(players, FollowerStatus.INELIGIBLE);
}
invitations.remove(targetId);
return updated(players, withFollower(target, Optional.of(invitedBy)), FollowerStatus.JOINED);
}
public FollowerResult dismiss(
GameState game,
Map<UUID, PlayerState> players,
UUID vigilanteId,
UUID targetId
) {
if (game.vigilanteId().filter(vigilanteId::equals).isEmpty()) {
return result(players, FollowerStatus.NOT_VIGILANTE);
}
PlayerState target = players.get(targetId);
if (target == null || target.followerOf().filter(vigilanteId::equals).isEmpty()) {
return result(players, FollowerStatus.NOT_FOLLOWER);
}
return updated(players, withFollower(target, Optional.empty()), FollowerStatus.DISMISSED);
}
public FollowerResult leave(
GameState game,
Map<UUID, PlayerState> players,
UUID targetId
) {
PlayerState target = players.get(targetId);
if (target == null || target.followerOf().isEmpty()) {
return result(players, FollowerStatus.NOT_FOLLOWER);
}
return updated(players, withFollower(target, Optional.empty()), FollowerStatus.LEFT);
}
public void clearInvitations() {
invitations.clear();
}
private static FollowerResult updated(
Map<UUID, PlayerState> players,
PlayerState updated,
FollowerStatus status
) {
Map<UUID, PlayerState> replacement = new HashMap<>(players);
replacement.put(updated.playerId(), updated);
return result(replacement, status);
}
private static FollowerResult result(
Map<UUID, PlayerState> players,
FollowerStatus status
) {
return new FollowerResult(players, status);
}
private static PlayerState withFollower(
PlayerState player,
Optional<UUID> followerOf
) {
return new PlayerState(
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
player.tyrantClass(), followerOf, player.cooldownEnds(),
player.readyAbilityItems(), player.capturedMobs()
);
}
}
@@ -0,0 +1,13 @@
package games.dmg.spigottyrant;
public enum FollowerStatus {
INVITED,
JOINED,
DISMISSED,
LEFT,
GAME_NOT_RUNNING,
NOT_VIGILANTE,
INELIGIBLE,
NO_INVITATION,
NOT_FOLLOWER
}
@@ -28,6 +28,7 @@ public record PluginSettings(
int fixerStrengthLevel,
int fixerNormalHeartRows,
int fixerNearTyrantHeartRows,
Duration vigilanteCombatDuration,
Duration rosterIntelligenceCooldown,
int tyrantStrengthLevel,
int tyrantResistanceLevel,
@@ -65,6 +66,7 @@ public record PluginSettings(
"fixer-near-tyrant-heart-rows must be at least fixer-normal-heart-rows"
);
}
requirePositive(vigilanteCombatDuration, "vigilante-combat-seconds");
requirePositive(rosterIntelligenceCooldown, "roster-intelligence-cooldown-seconds");
requireEffectLevel(tyrantStrengthLevel, "tyrant-strength-level");
requireEffectLevel(tyrantResistanceLevel, "tyrant-resistance-level");
@@ -99,6 +101,7 @@ public record PluginSettings(
integer(values, "fixer-strength-level", 1),
integer(values, "fixer-normal-heart-rows", 2),
integer(values, "fixer-near-tyrant-heart-rows", 3),
duration(values, "vigilante-combat-seconds", Duration.ofSeconds(15)),
duration(values, "roster-intelligence-cooldown-seconds", Duration.ofHours(24)),
integer(values, "tyrant-strength-level", 1),
integer(values, "tyrant-resistance-level", 1),
@@ -11,6 +11,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
private PluginSettings settings;
private TyrantStateManager stateManager;
private TyrantPresentation tyrantPresentation;
private VigilanteEffectController vigilanteEffects;
@Override
public void onEnable() {
@@ -46,6 +47,11 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
random
);
BukkitOnlinePlayerDirectory onlinePlayers = new BukkitOnlinePlayerDirectory();
FollowerService followers = new FollowerService();
VigilanteCombatTracker combatTracker = new VigilanteCombatTracker();
vigilanteEffects = new VigilanteEffectController(
stateManager, getServer(), combatTracker, settings, clock
);
tyrantPresentation = new BukkitTyrantPresentation(getServer());
ManagedRoleEffects managedEffects = new BukkitManagedRoleEffects(
stateManager, tyrantPresentation
@@ -62,6 +68,8 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
new RoleRelinquishmentService(succession, maintenance),
tyrantPresentation
));
Objects.requireNonNull(getCommand("vigilante"), "Missing vigilante metadata")
.setExecutor(new VigilanteCommand(stateManager, followers, onlinePlayers));
Objects.requireNonNull(getCommand("tyrantadmin"), "Missing tyrantadmin metadata")
.setExecutor(new TyrantAdminCommand(
stateManager,
@@ -84,6 +92,19 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
),
this
);
getServer().getPluginManager().registerEvents(
new VigilanteDeathListener(
stateManager, new TyrantProgressionService(), maintenance,
getServer(), clock
),
this
);
getServer().getPluginManager().registerEvents(
new VigilanteCombatListener(
stateManager, combatTracker, clock, settings.vigilanteCombatDuration()
),
this
);
long maintenanceTicks = Math.max(
1L, Math.multiplyExact(settings.selectionRetryInterval().toSeconds(), 20L)
);
@@ -95,12 +116,16 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
maintenanceTicks,
maintenanceTicks
);
getServer().getScheduler().runTaskTimer(this, vigilanteEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
getLogger().info("Spigot Tyrant enabled.");
}
@Override
public void onDisable() {
if (vigilanteEffects != null) {
vigilanteEffects.clear();
}
if (tyrantPresentation != null) {
tyrantPresentation.clear();
}
@@ -0,0 +1,50 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.time.Duration;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
public final class VigilanteCombatListener implements Listener {
private final TyrantStateManager stateManager;
private final VigilanteCombatTracker tracker;
private final Clock clock;
private final Duration combatDuration;
public VigilanteCombatListener(
TyrantStateManager stateManager,
VigilanteCombatTracker tracker,
Clock clock,
Duration combatDuration
) {
this.stateManager = stateManager;
this.tracker = tracker;
this.clock = clock;
this.combatDuration = combatDuration;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDamage(EntityDamageByEntityEvent event) {
if (!(event.getEntity() instanceof Player damaged)
|| !(event.getDamager() instanceof Player attacker)) {
return;
}
GameState game = stateManager.game();
if (game.lifecycle() != GameLifecycle.RUNNING
|| game.tyrantId().isEmpty() || game.vigilanteId().isEmpty()) {
return;
}
UUID first = damaged.getUniqueId();
UUID second = attacker.getUniqueId();
UUID tyrant = game.tyrantId().orElseThrow();
UUID vigilante = game.vigilanteId().orElseThrow();
if ((first.equals(tyrant) && second.equals(vigilante))
|| (first.equals(vigilante) && second.equals(tyrant))) {
tracker.markCombat(clock.instant(), combatDuration);
}
}
}
@@ -0,0 +1,21 @@
package games.dmg.spigottyrant;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
public final class VigilanteCombatTracker {
private Optional<Instant> activeUntil = Optional.empty();
public void markCombat(Instant now, Duration duration) {
activeUntil = Optional.of(now.plus(duration));
}
public boolean isActive(Instant now) {
return activeUntil.filter(deadline -> deadline.isAfter(now)).isPresent();
}
public void clear() {
activeUntil = Optional.empty();
}
}
@@ -0,0 +1,83 @@
package games.dmg.spigottyrant;
import java.util.Locale;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
public final class VigilanteCommand implements CommandExecutor {
private final TyrantStateManager stateManager;
private final FollowerService followers;
private final OnlinePlayerDirectory onlinePlayers;
public VigilanteCommand(
TyrantStateManager stateManager,
FollowerService followers,
OnlinePlayerDirectory onlinePlayers
) {
this.stateManager = stateManager;
this.followers = followers;
this.onlinePlayers = onlinePlayers;
}
@Override
public boolean onCommand(
CommandSender sender,
Command command,
String label,
String[] arguments
) {
if (!(sender instanceof Player player)) {
sender.sendMessage("This command must be used by a player.");
return true;
}
FollowerResult result;
Player target = null;
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("accept")) {
result = followers.accept(stateManager.game(), stateManager.players(),
player.getUniqueId());
} else if (arguments.length == 1 && arguments[0].equalsIgnoreCase("leave")) {
result = followers.leave(stateManager.game(), stateManager.players(),
player.getUniqueId());
} else if (arguments.length == 2
&& (arguments[0].equalsIgnoreCase("invite")
|| arguments[0].equalsIgnoreCase("dismiss"))) {
target = onlinePlayers.findByName(arguments[1]);
if (target == null) {
player.sendMessage(ChatColor.RED + "That player must be online.");
return true;
}
stateManager.player(target.getUniqueId(), target.getName());
result = arguments[0].equalsIgnoreCase("invite")
? followers.invite(
stateManager.game(), stateManager.players(), player.getUniqueId(),
target.getUniqueId()
)
: followers.dismiss(
stateManager.game(), stateManager.players(), player.getUniqueId(),
target.getUniqueId()
);
} else {
player.sendMessage(ChatColor.YELLOW
+ "Usage: /vigilante <invite <player>|accept|dismiss <player>|leave>");
return true;
}
if (result.status() == FollowerStatus.JOINED
|| result.status() == FollowerStatus.DISMISSED
|| result.status() == FollowerStatus.LEFT) {
stateManager.replacePlayers(result.players());
stateManager.saveIfDirty();
}
player.sendMessage(ChatColor.YELLOW + "Follower action: "
+ result.status().name().toLowerCase(Locale.ROOT).replace('_', ' ') + ".");
if (target != null && result.status() == FollowerStatus.INVITED) {
target.sendMessage(ChatColor.YELLOW + player.getName()
+ " invited you to become a Follower. Use /vigilante accept.");
} else if (target != null && result.status() == FollowerStatus.DISMISSED) {
target.sendMessage(ChatColor.YELLOW + "You are no longer a Follower.");
}
return true;
}
}
@@ -0,0 +1,62 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.util.Optional;
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.entity.PlayerDeathEvent;
public final class VigilanteDeathListener implements Listener {
private final TyrantStateManager stateManager;
private final TyrantProgressionService progression;
private final RoleMaintenanceService maintenance;
private final Server server;
private final Clock clock;
public VigilanteDeathListener(
TyrantStateManager stateManager,
TyrantProgressionService progression,
RoleMaintenanceService maintenance,
Server server,
Clock clock
) {
this.stateManager = stateManager;
this.progression = progression;
this.maintenance = maintenance;
this.server = server;
this.clock = clock;
}
@EventHandler(priority = EventPriority.MONITOR)
public void onDeath(PlayerDeathEvent event) {
UUID victimId = event.getEntity().getUniqueId();
GameState game = stateManager.game();
if (game.lifecycle() != GameLifecycle.RUNNING
|| game.vigilanteId().filter(victimId::equals).isEmpty()) {
return;
}
Optional<UUID> killerId = Optional.ofNullable(event.getEntity().getKiller())
.map(Player::getUniqueId);
GameState progressed = progression.recordVigilanteDeath(game, killerId);
PersistentState withProgress = new PersistentState(
progressed, stateManager.players()
);
Set<UUID> online = server.getOnlinePlayers().stream()
.map(Player::getUniqueId)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
LifecycleState rerolled = maintenance.rerollVigilante(
withProgress, Optional.of(victimId), online, clock.instant()
);
stateManager.replaceState(rerolled);
stateManager.saveIfDirty();
String message = ChatColor.YELLOW + "The Vigilante died; a replacement "
+ (rerolled.game().vigilanteId().isPresent() ? "has been selected." : "is pending.");
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
}
}
@@ -0,0 +1,98 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
public final class VigilanteEffectController implements Runnable {
private static final int EFFECT_TICKS = 30;
private final TyrantStateManager stateManager;
private final Server server;
private final VigilanteCombatTracker tracker;
private final PluginSettings settings;
private final Clock clock;
private Optional<UUID> previousVigilante = Optional.empty();
public VigilanteEffectController(
TyrantStateManager stateManager,
Server server,
VigilanteCombatTracker tracker,
PluginSettings settings,
Clock clock
) {
this.stateManager = stateManager;
this.server = server;
this.tracker = tracker;
this.settings = settings;
this.clock = clock;
}
@Override
public void run() {
GameState game = stateManager.game();
if (!previousVigilante.equals(game.vigilanteId())) {
previousVigilante.map(server::getPlayer).ifPresent(this::removeManagedEffects);
previousVigilante = game.vigilanteId();
}
Player vigilante = game.vigilanteId().map(server::getPlayer).orElse(null);
if (vigilante == null || game.lifecycle() != GameLifecycle.RUNNING
|| !tracker.isActive(clock.instant()) || vigilante.isDead()) {
if (vigilante != null) {
removeManagedEffects(vigilante);
}
return;
}
int followers = nearbyLivingFollowers(game.vigilanteId().orElseThrow(), vigilante);
VigilantePower.Levels levels = VigilantePower.forFollowers(
followers, settings.followerStrengthCap(), settings.followerResistanceCap()
);
apply(vigilante, PotionEffectType.STRENGTH, levels.strength());
apply(vigilante, PotionEffectType.RESISTANCE, levels.resistance());
}
public void clear() {
previousVigilante.map(server::getPlayer).ifPresent(this::removeManagedEffects);
previousVigilante = Optional.empty();
tracker.clear();
}
private int nearbyLivingFollowers(UUID vigilanteId, Player vigilante) {
int count = 0;
double rangeSquared = settings.followerRangeBlocks()
* settings.followerRangeBlocks();
for (PlayerState state : stateManager.players().values()) {
if (state.followerOf().filter(vigilanteId::equals).isEmpty()
|| state.optedOutUntil().isPresent()) {
continue;
}
Player follower = server.getPlayer(state.playerId());
if (follower == null || follower.isDead()
|| !follower.getWorld().equals(vigilante.getWorld())) {
continue;
}
if (follower.getLocation().distanceSquared(vigilante.getLocation()) <= rangeSquared) {
count++;
}
}
return count;
}
private static void apply(Player player, PotionEffectType type, int level) {
if (level == 0) {
player.removePotionEffect(type);
return;
}
player.addPotionEffect(new PotionEffect(
type, EFFECT_TICKS, level - 1, false, true, true
));
}
private void removeManagedEffects(Player player) {
player.removePotionEffect(PotionEffectType.STRENGTH);
player.removePotionEffect(PotionEffectType.RESISTANCE);
}
}
@@ -0,0 +1,23 @@
package games.dmg.spigottyrant;
public final class VigilantePower {
private VigilantePower() {
}
public static Levels forFollowers(
int followerCount,
int strengthCap,
int resistanceCap
) {
if (followerCount < 0 || strengthCap < 1 || resistanceCap < 1) {
throw new IllegalArgumentException("follower count and caps are invalid");
}
return new Levels(
Math.min(followerCount, strengthCap),
Math.min(followerCount, resistanceCap)
);
}
public record Levels(int strength, int resistance) {
}
}
+1
View File
@@ -40,6 +40,7 @@ denied-mob-types:
- WITHER
# Tyrant and Vigilante effects
vigilante-combat-seconds: 15
roster-intelligence-cooldown-seconds: 86400
tyrant-strength-level: 1
tyrant-resistance-level: 1
+3
View File
@@ -8,6 +8,9 @@ commands:
tyrant:
description: View and use Spigot Tyrant game features.
usage: /tyrant <status|choices|buy|assign|optout|optin|relinquish confirm>
vigilante:
description: Manage Vigilante Followers.
usage: /vigilante <invite <player>|accept|dismiss <player>|leave>
tyrantadmin:
description: Administer the Spigot Tyrant game.
usage: /tyrantadmin <status|start|pause|resume|reset confirm>
@@ -0,0 +1,70 @@
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 FollowerServiceTest {
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID VIGILANTE = UUID.fromString("22222222-2222-2222-2222-222222222222");
private static final UUID FOLLOWER = UUID.fromString("33333333-3333-3333-3333-333333333333");
private final FollowerService service = new FollowerService();
@Test
void invitationRequiresAcceptanceAndPreservesTyrantClass() {
GameState game = game();
PlayerState target = new PlayerState(
FOLLOWER, "Follower", Optional.empty(), Optional.empty(), TyrantClass.FIXER,
Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
Map<UUID, PlayerState> players = Map.of(FOLLOWER, target);
FollowerResult invited = service.invite(game, players, VIGILANTE, FOLLOWER);
FollowerResult accepted = service.accept(game, invited.players(), FOLLOWER);
assertEquals(FollowerStatus.INVITED, invited.status());
assertEquals(Optional.empty(), invited.players().get(FOLLOWER).followerOf());
assertEquals(FollowerStatus.JOINED, accepted.status());
assertEquals(Optional.of(VIGILANTE), accepted.players().get(FOLLOWER).followerOf());
assertEquals(TyrantClass.FIXER, accepted.players().get(FOLLOWER).tyrantClass());
}
@Test
void vigilanteCanDismissAndFollowerCanLeave() {
GameState game = game();
PlayerState follower = withFollower(PlayerState.newPlayer(FOLLOWER, "Follower"));
FollowerResult dismissed = service.dismiss(
game, Map.of(FOLLOWER, follower), VIGILANTE, FOLLOWER
);
FollowerResult left = service.leave(
game, Map.of(FOLLOWER, follower), FOLLOWER
);
assertEquals(FollowerStatus.DISMISSED, dismissed.status());
assertEquals(Optional.empty(), dismissed.players().get(FOLLOWER).followerOf());
assertEquals(FollowerStatus.LEFT, left.status());
assertEquals(Optional.empty(), left.players().get(FOLLOWER).followerOf());
}
private static GameState game() {
return new GameState(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.of(VIGILANTE),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 0, Set.of()
);
}
private static PlayerState withFollower(PlayerState player) {
return new PlayerState(
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
player.tyrantClass(), Optional.of(VIGILANTE), player.cooldownEnds(),
player.readyAbilityItems(), player.capturedMobs()
);
}
}
@@ -24,6 +24,7 @@ final class PluginMetadataTest {
assertNotNull(commands);
assertNotNull(commands.get("tyrant"));
assertNotNull(commands.get("tyrantadmin"));
assertNotNull(commands.get("vigilante"));
Map<?, ?> permissions = (Map<?, ?>) plugin.get("permissions");
assertNotNull(permissions);
Map<?, ?> admin = (Map<?, ?>) permissions.get("spigottyrant.admin");
@@ -0,0 +1,14 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
final class VigilantePowerTest {
@Test
void oneLevelPerNearbyFollowerIsCappedSafely() {
assertEquals(new VigilantePower.Levels(3, 3), VigilantePower.forFollowers(3, 5, 4));
assertEquals(new VigilantePower.Levels(5, 4), VigilantePower.forFollowers(10, 5, 4));
assertEquals(new VigilantePower.Levels(0, 0), VigilantePower.forFollowers(0, 5, 4));
}
}