diff --git a/design/log.md b/design/log.md index 3043306..a738ac7 100644 --- a/design/log.md +++ b/design/log.md @@ -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. diff --git a/design/user-stories/us-008-support-vigilante-and-followers.md b/design/user-stories/us-008-support-vigilante-and-followers.md index bed6c4c..9fa2574 100644 --- a/design/user-stories/us-008-support-vigilante-and-followers.md +++ b/design/user-stories/us-008-support-vigilante-and-followers.md @@ -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 diff --git a/src/main/java/games/dmg/spigottyrant/FollowerResult.java b/src/main/java/games/dmg/spigottyrant/FollowerResult.java new file mode 100644 index 0000000..8514a29 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/FollowerResult.java @@ -0,0 +1,10 @@ +package games.dmg.spigottyrant; + +import java.util.Map; +import java.util.UUID; + +public record FollowerResult(Map players, FollowerStatus status) { + public FollowerResult { + players = Map.copyOf(players); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/FollowerService.java b/src/main/java/games/dmg/spigottyrant/FollowerService.java new file mode 100644 index 0000000..e2ac503 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/FollowerService.java @@ -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 invitations = new HashMap<>(); + + public FollowerResult invite( + GameState game, + Map 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 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 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 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 players, + PlayerState updated, + FollowerStatus status + ) { + Map replacement = new HashMap<>(players); + replacement.put(updated.playerId(), updated); + return result(replacement, status); + } + + private static FollowerResult result( + Map players, + FollowerStatus status + ) { + return new FollowerResult(players, status); + } + + private static PlayerState withFollower( + PlayerState player, + Optional followerOf + ) { + return new PlayerState( + player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(), + player.tyrantClass(), followerOf, player.cooldownEnds(), + player.readyAbilityItems(), player.capturedMobs() + ); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/FollowerStatus.java b/src/main/java/games/dmg/spigottyrant/FollowerStatus.java new file mode 100644 index 0000000..715b660 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/FollowerStatus.java @@ -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 +} diff --git a/src/main/java/games/dmg/spigottyrant/PluginSettings.java b/src/main/java/games/dmg/spigottyrant/PluginSettings.java index d5529ba..d0e2073 100644 --- a/src/main/java/games/dmg/spigottyrant/PluginSettings.java +++ b/src/main/java/games/dmg/spigottyrant/PluginSettings.java @@ -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), diff --git a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java index a8af3be..847ced8 100644 --- a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java +++ b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java @@ -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(); } diff --git a/src/main/java/games/dmg/spigottyrant/VigilanteCombatListener.java b/src/main/java/games/dmg/spigottyrant/VigilanteCombatListener.java new file mode 100644 index 0000000..30691f6 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/VigilanteCombatListener.java @@ -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); + } + } +} diff --git a/src/main/java/games/dmg/spigottyrant/VigilanteCombatTracker.java b/src/main/java/games/dmg/spigottyrant/VigilanteCombatTracker.java new file mode 100644 index 0000000..7237cb7 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/VigilanteCombatTracker.java @@ -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 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(); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/VigilanteCommand.java b/src/main/java/games/dmg/spigottyrant/VigilanteCommand.java new file mode 100644 index 0000000..90a44e2 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/VigilanteCommand.java @@ -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 |accept|dismiss |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; + } +} diff --git a/src/main/java/games/dmg/spigottyrant/VigilanteDeathListener.java b/src/main/java/games/dmg/spigottyrant/VigilanteDeathListener.java new file mode 100644 index 0000000..c2a5e8f --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/VigilanteDeathListener.java @@ -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 killerId = Optional.ofNullable(event.getEntity().getKiller()) + .map(Player::getUniqueId); + GameState progressed = progression.recordVigilanteDeath(game, killerId); + PersistentState withProgress = new PersistentState( + progressed, stateManager.players() + ); + Set 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)); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/VigilanteEffectController.java b/src/main/java/games/dmg/spigottyrant/VigilanteEffectController.java new file mode 100644 index 0000000..0fae423 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/VigilanteEffectController.java @@ -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 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); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/VigilantePower.java b/src/main/java/games/dmg/spigottyrant/VigilantePower.java new file mode 100644 index 0000000..7273c4a --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/VigilantePower.java @@ -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) { + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 6c4a657..174a7a9 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -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 diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 4891f9c..d7a7392 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -8,6 +8,9 @@ commands: tyrant: description: View and use Spigot Tyrant game features. usage: /tyrant + vigilante: + description: Manage Vigilante Followers. + usage: /vigilante |accept|dismiss |leave> tyrantadmin: description: Administer the Spigot Tyrant game. usage: /tyrantadmin diff --git a/src/test/java/games/dmg/spigottyrant/FollowerServiceTest.java b/src/test/java/games/dmg/spigottyrant/FollowerServiceTest.java new file mode 100644 index 0000000..a764f5d --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/FollowerServiceTest.java @@ -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 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() + ); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/PluginMetadataTest.java b/src/test/java/games/dmg/spigottyrant/PluginMetadataTest.java index 1757657..ee3e52b 100644 --- a/src/test/java/games/dmg/spigottyrant/PluginMetadataTest.java +++ b/src/test/java/games/dmg/spigottyrant/PluginMetadataTest.java @@ -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"); diff --git a/src/test/java/games/dmg/spigottyrant/VigilantePowerTest.java b/src/test/java/games/dmg/spigottyrant/VigilantePowerTest.java new file mode 100644 index 0000000..c8bcead --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/VigilantePowerTest.java @@ -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)); + } +}