From e0a9413cb2d58d750a164d7694aff833b18f4643 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Fri, 14 Aug 2026 18:26:10 -0400 Subject: [PATCH] feat(harvest): run animated auto-harvest operations --- ...05-view-crop-progress-and-notifications.md | 2 +- ...6-administer-player-harvest-progression.md | 2 +- .../spigotharvest/AdminHarvestService.java | 36 ++++ .../spigotharvest/HarvestAdminCommand.java | 184 ++++++++++++++++++ .../dmg/spigotharvest/HarvestCommand.java | 106 ++++++++++ .../dmg/spigotharvest/HarvestFeedback.java | 86 ++++++++ .../games/dmg/spigotharvest/HarvestLevel.java | 25 +++ .../dmg/spigotharvest/HarvestListener.java | 79 ++++++++ .../HarvestOperationManager.java | 180 +++++++++++++++++ .../spigotharvest/HarvestProgressService.java | 33 ++++ .../spigotharvest/HarvestStatusFormatter.java | 36 ++++ .../spigotharvest/SpigotHarvestPlugin.java | 50 ++++- .../AdminHarvestServiceTest.java | 26 +++ .../HarvestStatusFormatterTest.java | 23 +++ 14 files changed, 865 insertions(+), 3 deletions(-) create mode 100644 src/main/java/games/dmg/spigotharvest/AdminHarvestService.java create mode 100644 src/main/java/games/dmg/spigotharvest/HarvestAdminCommand.java create mode 100644 src/main/java/games/dmg/spigotharvest/HarvestCommand.java create mode 100644 src/main/java/games/dmg/spigotharvest/HarvestFeedback.java create mode 100644 src/main/java/games/dmg/spigotharvest/HarvestLevel.java create mode 100644 src/main/java/games/dmg/spigotharvest/HarvestListener.java create mode 100644 src/main/java/games/dmg/spigotharvest/HarvestOperationManager.java create mode 100644 src/main/java/games/dmg/spigotharvest/HarvestProgressService.java create mode 100644 src/main/java/games/dmg/spigotharvest/HarvestStatusFormatter.java create mode 100644 src/test/java/games/dmg/spigotharvest/AdminHarvestServiceTest.java create mode 100644 src/test/java/games/dmg/spigotharvest/HarvestStatusFormatterTest.java diff --git a/design/user-stories/us-005-view-crop-progress-and-notifications.md b/design/user-stories/us-005-view-crop-progress-and-notifications.md index 800b55b..8ecba3a 100644 --- a/design/user-stories/us-005-view-crop-progress-and-notifications.md +++ b/design/user-stories/us-005-view-crop-progress-and-notifications.md @@ -2,7 +2,7 @@ type: User Story title: "US-005: View crop progress and notifications" description: Give players command and boss-bar feedback about each crop's current progression. -status: backlog +status: in-progress --- # US-005: View crop progress and notifications diff --git a/design/user-stories/us-006-administer-player-harvest-progression.md b/design/user-stories/us-006-administer-player-harvest-progression.md index cf6d786..46e5369 100644 --- a/design/user-stories/us-006-administer-player-harvest-progression.md +++ b/design/user-stories/us-006-administer-player-harvest-progression.md @@ -2,7 +2,7 @@ type: User Story title: "US-006: Administer player harvest progression" description: Let administrators inspect, correct, and reset player crop progression safely through commands. -status: backlog +status: in-progress --- # US-006: Administer player harvest progression diff --git a/src/main/java/games/dmg/spigotharvest/AdminHarvestService.java b/src/main/java/games/dmg/spigotharvest/AdminHarvestService.java new file mode 100644 index 0000000..4513a07 --- /dev/null +++ b/src/main/java/games/dmg/spigotharvest/AdminHarvestService.java @@ -0,0 +1,36 @@ +package games.dmg.spigotharvest; + +/** Validated administrator mutations over player progression. */ +public final class AdminHarvestService { + private final HarvestSettings settings; + + public AdminHarvestService(HarvestSettings settings) { + this.settings = settings; + } + + public void setLevel(PlayerHarvestState state, CropType crop, int level) { + if (level < 0 || level > HarvestProgression.MAX_LEVEL) { + throw new IllegalArgumentException("level must be 0 or I through X"); + } + state.progress(crop).restore(level, 0); + } + + public ProgressionUpdate setProgress(PlayerHarvestState state, CropType crop, long amount) { + if (amount < 0) { + throw new IllegalArgumentException("progress cannot be negative"); + } + state.progress(crop).setHarvests(0); + return settings.progression(crop).add(state, crop, amount); + } + + public void reset(PlayerHarvestState state, CropType crop) { + state.progress(crop).restore(0, 0); + } + + public void resetAll(PlayerHarvestState state) { + for (CropType crop : CropType.values()) { + reset(state, crop); + } + state.setBossBarEnabled(true); + } +} diff --git a/src/main/java/games/dmg/spigotharvest/HarvestAdminCommand.java b/src/main/java/games/dmg/spigotharvest/HarvestAdminCommand.java new file mode 100644 index 0000000..9982643 --- /dev/null +++ b/src/main/java/games/dmg/spigotharvest/HarvestAdminCommand.java @@ -0,0 +1,184 @@ +package games.dmg.spigotharvest; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; + +/** Permission-gated harvest progression administration. */ +public final class HarvestAdminCommand implements CommandExecutor, TabCompleter { + private static final String PERMISSION = "spigotharvest.admin"; + private final HarvestStateManager states; + private final HarvestSettings settings; + private final AdminHarvestService service; + private final HarvestOperationManager operations; + + public HarvestAdminCommand( + HarvestStateManager states, + HarvestSettings settings, + HarvestOperationManager operations + ) { + this.states = states; + this.settings = settings; + this.service = new AdminHarvestService(settings); + this.operations = operations; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!sender.hasPermission(PERMISSION)) { + sender.sendMessage(ChatColor.RED + "You do not have permission."); + return true; + } + try { + if (args.length >= 2 && args[0].equalsIgnoreCase("status")) { + return status(sender, args); + } + if (args.length == 4 && args[0].equalsIgnoreCase("setlevel")) { + PlayerHarvestState state = requirePlayer(args[1]); + CropType crop = requireCrop(args[2]); + int level = HarvestLevel.parse(args[3]); + service.setLevel(state, crop, level); + changed(sender, state, crop, "level " + HarvestLevel.format(level)); + return true; + } + if (args.length == 4 && args[0].equalsIgnoreCase("setprogress")) { + PlayerHarvestState state = requirePlayer(args[1]); + CropType crop = requireCrop(args[2]); + long amount = Long.parseLong(args[3]); + service.setProgress(state, crop, amount); + changed(sender, state, crop, "progress " + state.progress(crop).harvests()); + return true; + } + if (args.length == 3 && args[0].equalsIgnoreCase("reset") + && !args[2].equalsIgnoreCase("all")) { + PlayerHarvestState state = requirePlayer(args[1]); + CropType crop = requireCrop(args[2]); + service.reset(state, crop); + changed(sender, state, crop, "reset"); + return true; + } + if (args.length == 4 && args[0].equalsIgnoreCase("reset") + && args[2].equalsIgnoreCase("all") && args[3].equalsIgnoreCase("confirm")) { + PlayerHarvestState state = requirePlayer(args[1]); + service.resetAll(state); + operations.cancel(state.playerId()); + states.save(); + sender.sendMessage(ChatColor.GREEN + "Reset all harvest progression for " + + state.latestName() + "."); + return true; + } + } catch (IllegalArgumentException exception) { + sender.sendMessage(ChatColor.RED + exception.getMessage()); + return true; + } + usage(sender); + return true; + } + + private boolean status(CommandSender sender, String[] args) { + if (args.length != 2 && args.length != 3) { + usage(sender); + return true; + } + PlayerHarvestState state = requirePlayer(args[1]); + if (args.length == 3) { + CropType crop = requireCrop(args[2]); + sender.sendMessage(HarvestStatusFormatter.crop(state, crop, settings.progression(crop))); + } else { + for (String line : HarvestStatusFormatter.summary(state, settings).split("\\n")) { + sender.sendMessage(line); + } + } + sender.sendMessage("Active operation: " + operations.isActive(state.playerId())); + return true; + } + + private void changed( + CommandSender sender, + PlayerHarvestState state, + CropType crop, + String change + ) { + operations.cancel(state.playerId()); + states.save(); + sender.sendMessage(ChatColor.GREEN + "Set " + state.latestName() + " " + + crop.displayName() + " " + change + "."); + } + + private PlayerHarvestState requirePlayer(String name) { + Player online = Bukkit.getPlayerExact(name); + Optional state = online == null + ? states.find(name) : Optional.of(states.stateFor(online)); + return state.orElseThrow(() -> new IllegalArgumentException("Unknown player: " + name)); + } + + private static CropType requireCrop(String value) { + CropType crop = HarvestCommand.parseCrop(value); + if (crop == null) { + throw new IllegalArgumentException("Unknown crop: " + value); + } + return crop; + } + + private static void usage(CommandSender sender) { + sender.sendMessage(ChatColor.RED + "Usage: /harvestadmin " + + " ..."); + } + + @Override + public List onTabComplete( + CommandSender sender, + Command command, + String alias, + String[] args + ) { + if (!sender.hasPermission(PERMISSION)) { + return List.of(); + } + if (args.length == 1) { + return matches(args[0], List.of("status", "setlevel", "setprogress", "reset")); + } + if (args.length == 2) { + List names = new ArrayList<>(); + for (Player player : Bukkit.getOnlinePlayers()) { + names.add(player.getName()); + } + for (PlayerHarvestState state : states.all()) { + if (!names.contains(state.latestName())) { + names.add(state.latestName()); + } + } + return matches(args[1], names); + } + if (args.length == 3) { + List crops = new ArrayList<>(Arrays.stream(CropType.values()) + .map(CropType::displayName).toList()); + if (args[0].equalsIgnoreCase("reset")) { + crops.add("all"); + } + return matches(args[2], crops); + } + if (args.length == 4 && args[0].equalsIgnoreCase("setlevel")) { + return matches(args[3], List.of("0", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X")); + } + if (args.length == 4 && args[0].equalsIgnoreCase("reset") + && args[2].equalsIgnoreCase("all")) { + return matches(args[3], List.of("confirm")); + } + return List.of(); + } + + private static List matches(String prefix, List values) { + String normalized = prefix.toLowerCase(Locale.ROOT); + return values.stream().filter(value -> value.toLowerCase(Locale.ROOT).startsWith(normalized)).toList(); + } +} diff --git a/src/main/java/games/dmg/spigotharvest/HarvestCommand.java b/src/main/java/games/dmg/spigotharvest/HarvestCommand.java new file mode 100644 index 0000000..e1b3a62 --- /dev/null +++ b/src/main/java/games/dmg/spigotharvest/HarvestCommand.java @@ -0,0 +1,106 @@ +package games.dmg.spigotharvest; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +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.command.TabCompleter; +import org.bukkit.entity.Player; + +/** `/harvest` progression status and preference command. */ +public final class HarvestCommand implements CommandExecutor, TabCompleter { + private final HarvestStateManager states; + private final HarvestSettings settings; + private final HarvestFeedback feedback; + + public HarvestCommand( + HarvestStateManager states, + HarvestSettings settings, + HarvestFeedback feedback + ) { + this.states = states; + this.settings = settings; + this.feedback = feedback; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!(sender instanceof Player player)) { + sender.sendMessage("This command requires a player."); + return true; + } + PlayerHarvestState state = states.stateFor(player); + if (args.length == 0 || args.length == 1 && args[0].equalsIgnoreCase("status")) { + for (String line : HarvestStatusFormatter.summary(state, settings).split("\\n")) { + player.sendMessage(ChatColor.GREEN + line); + } + return true; + } + if (args.length == 2 && args[0].equalsIgnoreCase("status")) { + CropType crop = parseCrop(args[1]); + if (crop != null) { + player.sendMessage(ChatColor.GREEN + + HarvestStatusFormatter.crop(state, crop, settings.progression(crop))); + return true; + } + } + if (args.length == 2 && args[0].equalsIgnoreCase("bossbar") + && (args[1].equalsIgnoreCase("enable") || args[1].equalsIgnoreCase("disable"))) { + boolean enabled = args[1].equalsIgnoreCase("enable"); + state.setBossBarEnabled(enabled); + if (!enabled) { + feedback.hide(player.getUniqueId()); + } + states.save(); + player.sendMessage(ChatColor.GREEN + "Harvest progress boss bar " + + (enabled ? "enabled." : "disabled.")); + return true; + } + player.sendMessage(ChatColor.RED + + "Usage: /harvest [status [crop]|bossbar ]"); + return true; + } + + @Override + public List onTabComplete( + CommandSender sender, + Command command, + String alias, + String[] args + ) { + if (args.length == 1) { + return matches(args[0], List.of("status", "bossbar")); + } + if (args.length == 2 && args[0].equalsIgnoreCase("status")) { + return matches(args[1], Arrays.stream(CropType.values()).map(CropType::displayName).toList()); + } + if (args.length == 2 && args[0].equalsIgnoreCase("bossbar")) { + return matches(args[1], List.of("enable", "disable")); + } + return List.of(); + } + + static CropType parseCrop(String value) { + for (CropType crop : CropType.values()) { + if (crop.displayName().equalsIgnoreCase(value)) { + return crop; + } + } + return null; + } + + private static List matches(String prefix, List values) { + String normalized = prefix.toLowerCase(Locale.ROOT); + List matches = new ArrayList<>(); + for (String value : values) { + if (value.startsWith(normalized)) { + matches.add(value); + } + } + return matches; + } +} diff --git a/src/main/java/games/dmg/spigotharvest/HarvestFeedback.java b/src/main/java/games/dmg/spigotharvest/HarvestFeedback.java new file mode 100644 index 0000000..a432768 --- /dev/null +++ b/src/main/java/games/dmg/spigotharvest/HarvestFeedback.java @@ -0,0 +1,86 @@ +package games.dmg.spigotharvest; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.boss.BarColor; +import org.bukkit.boss.BarStyle; +import org.bukkit.boss.BossBar; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; + +/** Player-facing boss-bar progress and one-time unlock titles. */ +public final class HarvestFeedback { + private final JavaPlugin plugin; + private final HarvestSettings settings; + private final Map bars = new HashMap<>(); + private final Map removals = new HashMap<>(); + + public HarvestFeedback(JavaPlugin plugin, HarvestSettings settings) { + this.plugin = plugin; + this.settings = settings; + } + + public void update(Player player, CropType crop, PlayerHarvestState state, ProgressionUpdate update) { + if (update.levelsUnlocked() > 0) { + int cap = settings.progression(crop).capForLevel(update.currentLevel()); + player.sendTitle( + ChatColor.GREEN + crop.displayName() + " Level " + HarvestLevel.format(update.currentLevel()), + ChatColor.GOLD + "Auto-harvest up to " + cap + " crops", + settings.titleFadeInTicks(), + settings.titleStayTicks(), + settings.titleFadeOutTicks() + ); + } + if (!state.bossBarEnabled()) { + hide(player.getUniqueId()); + return; + } + CropProgress progress = state.progress(crop); + long requirement = progress.level() == HarvestProgression.MAX_LEVEL + ? 1 : settings.progression(crop).requirementForLevel(progress.level() + 1); + BossBar bar = bars.computeIfAbsent(player.getUniqueId(), ignored -> { + BossBar created = Bukkit.createBossBar("", BarColor.GREEN, BarStyle.SOLID); + created.addPlayer(player); + return created; + }); + String target = progress.level() == HarvestProgression.MAX_LEVEL + ? "complete" + : progress.harvests() + " / " + requirement; + bar.setTitle(ChatColor.GREEN + crop.displayName() + " " + target); + bar.setProgress(progress.level() == HarvestProgression.MAX_LEVEL + ? 1.0 : Math.max(0.0, Math.min(1.0, (double) progress.harvests() / requirement))); + bar.setVisible(true); + BukkitTask previous = removals.remove(player.getUniqueId()); + if (previous != null) { + previous.cancel(); + } + removals.put(player.getUniqueId(), Bukkit.getScheduler().runTaskLater( + plugin, () -> hide(player.getUniqueId()), settings.bossBarIdleTicks())); + } + + public void hide(UUID playerId) { + BossBar bar = bars.remove(playerId); + if (bar != null) { + bar.removeAll(); + } + BukkitTask task = removals.remove(playerId); + if (task != null && !task.isCancelled()) { + task.cancel(); + } + } + + public void close() { + for (BossBar bar : bars.values()) { + bar.removeAll(); + } + bars.clear(); + for (BukkitTask task : removals.values()) { + task.cancel(); + } + removals.clear(); + } +} diff --git a/src/main/java/games/dmg/spigotharvest/HarvestLevel.java b/src/main/java/games/dmg/spigotharvest/HarvestLevel.java new file mode 100644 index 0000000..bb85aef --- /dev/null +++ b/src/main/java/games/dmg/spigotharvest/HarvestLevel.java @@ -0,0 +1,25 @@ +package games.dmg.spigotharvest; + +/** Roman-numeral presentation and parsing for harvest levels. */ +public final class HarvestLevel { + private static final String[] NAMES = {"0", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"}; + + private HarvestLevel() { + } + + public static String format(int level) { + if (level < 0 || level >= NAMES.length) { + throw new IllegalArgumentException("level must be from 0 through X"); + } + return NAMES[level]; + } + + public static int parse(String value) { + for (int level = 0; level < NAMES.length; level++) { + if (NAMES[level].equalsIgnoreCase(value)) { + return level; + } + } + throw new IllegalArgumentException("level must be 0 or I through X"); + } +} diff --git a/src/main/java/games/dmg/spigotharvest/HarvestListener.java b/src/main/java/games/dmg/spigotharvest/HarvestListener.java new file mode 100644 index 0000000..18c6656 --- /dev/null +++ b/src/main/java/games/dmg/spigotharvest/HarvestListener.java @@ -0,0 +1,79 @@ +package games.dmg.spigotharvest; + +import org.bukkit.block.Block; +import org.bukkit.block.data.Ageable; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.player.PlayerQuitEvent; + +/** Converts eligible normal crop breaks into progression or auto-harvest operations. */ +public final class HarvestListener implements Listener { + private final HarvestStateManager states; + private final HarvestSettings settings; + private final HarvestProgressService progress; + private final HarvestOperationManager operations; + private final HarvestFeedback feedback; + + public HarvestListener( + HarvestStateManager states, + HarvestSettings settings, + HarvestProgressService progress, + HarvestOperationManager operations, + HarvestFeedback feedback + ) { + this.states = states; + this.settings = settings; + this.progress = progress; + this.operations = operations; + this.feedback = feedback; + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onBlockBreak(BlockBreakEvent event) { + Player player = event.getPlayer(); + if (operations.isDispatchingBreakEvent(player.getUniqueId())) { + return; + } + Block block = event.getBlock(); + CropType crop = CropType.fromBlock(block.getType()).orElse(null); + if (crop == null || !(block.getBlockData() instanceof Ageable ageable) + || ageable.getAge() != ageable.getMaximumAge() + || !settings.isEligible(player.getGameMode())) { + return; + } + PlayerHarvestState state = states.stateFor(player); + int level = state.progress(crop).level(); + if (player.isSneaking() || level == 0 || operations.isActive(player.getUniqueId())) { + progress.record(player, crop, 1); + return; + } + int cap = settings.progression(crop).capForLevel(level); + if (operations.start(player, block, crop, cap)) { + event.setCancelled(true); + } else { + progress.record(player, crop, 1); + } + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + UUIDActions.cancel(event.getPlayer(), operations, feedback); + } + + private static final class UUIDActions { + private UUIDActions() { + } + + static void cancel( + Player player, + HarvestOperationManager operations, + HarvestFeedback feedback + ) { + operations.cancel(player.getUniqueId()); + feedback.hide(player.getUniqueId()); + } + } +} diff --git a/src/main/java/games/dmg/spigotharvest/HarvestOperationManager.java b/src/main/java/games/dmg/spigotharvest/HarvestOperationManager.java new file mode 100644 index 0000000..3cb7e64 --- /dev/null +++ b/src/main/java/games/dmg/spigotharvest/HarvestOperationManager.java @@ -0,0 +1,180 @@ +package games.dmg.spigotharvest; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.Ageable; +import org.bukkit.entity.Player; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; + +/** Runs one bounded, animated connected-crop operation per player. */ +public final class HarvestOperationManager { + private final HarvestSettings settings; + private final HarvestProgressService progress; + private final Map operations = new HashMap<>(); + private final Set dispatchingBreakEvent = new HashSet<>(); + private final BukkitTask task; + + public HarvestOperationManager( + JavaPlugin plugin, + HarvestSettings settings, + HarvestProgressService progress + ) { + this.settings = settings; + this.progress = progress; + task = Bukkit.getScheduler().runTaskTimer(plugin, this::tick, 1, 1); + } + + public boolean start(Player player, Block origin, CropType crop, int cap) { + if (operations.containsKey(player.getUniqueId())) { + return false; + } + World world = origin.getWorld(); + BlockPosition start = position(origin); + List crops = ConnectedCropSearch.find( + start, candidate -> isMatureLoaded(world, candidate, crop), cap); + if (crops.isEmpty()) { + return false; + } + operations.put(player.getUniqueId(), new Operation(world.getUID(), crop, crops)); + return true; + } + + public boolean isActive(UUID playerId) { + return operations.containsKey(playerId); + } + + public boolean isDispatchingBreakEvent(UUID playerId) { + return dispatchingBreakEvent.contains(playerId); + } + + public void cancel(UUID playerId) { + operations.remove(playerId); + } + + public void close() { + task.cancel(); + operations.clear(); + dispatchingBreakEvent.clear(); + } + + private void tick() { + Iterator> iterator = operations.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + Player player = Bukkit.getPlayer(entry.getKey()); + if (player == null || !player.isOnline()) { + iterator.remove(); + continue; + } + Operation operation = entry.getValue(); + World world = Bukkit.getWorld(operation.worldId()); + if (world == null) { + iterator.remove(); + continue; + } + int processed = 0; + while (processed < settings.cropsPerTick() && operation.hasNext()) { + harvest(player, world, operation.crop(), operation.next()); + processed++; + } + if (!operation.hasNext()) { + iterator.remove(); + } + } + } + + private void harvest(Player player, World world, CropType crop, BlockPosition position) { + if (!isMatureLoaded(world, position, crop)) { + return; + } + Block block = world.getBlockAt(position.x(), position.y(), position.z()); + BlockBreakEvent event = new BlockBreakEvent(block, player); + dispatchingBreakEvent.add(player.getUniqueId()); + try { + Bukkit.getPluginManager().callEvent(event); + } finally { + dispatchingBreakEvent.remove(player.getUniqueId()); + } + if (event.isCancelled()) { + return; + } + Collection generated = block.getDrops(player.getInventory().getItemInMainHand(), player); + CropDropPlan plan = CropDropPlan.create(crop, generated); + if (!plan.canReplant()) { + return; + } + block.setType(Material.AIR, false); + block.setType(crop.blockMaterial(), false); + if (block.getBlockData() instanceof Ageable replanted) { + replanted.setAge(0); + block.setBlockData(replanted, false); + } + deliver(player, plan.remainingDrops()); + progress.record(player, crop, 1); + } + + private static void deliver(Player player, List drops) { + for (ItemStack item : drops) { + for (ItemStack overflow : player.getInventory().addItem(item).values()) { + player.getWorld().dropItemNaturally(player.getLocation(), overflow); + } + } + } + + private static boolean isMatureLoaded(World world, BlockPosition position, CropType crop) { + if (!world.isChunkLoaded(position.x() >> 4, position.z() >> 4)) { + return false; + } + Block block = world.getBlockAt(position.x(), position.y(), position.z()); + return block.getType() == crop.blockMaterial() + && block.getBlockData() instanceof Ageable ageable + && ageable.getAge() == ageable.getMaximumAge(); + } + + private static BlockPosition position(Block block) { + return new BlockPosition(block.getX(), block.getY(), block.getZ()); + } + + private static final class Operation { + private final UUID worldId; + private final CropType crop; + private final List positions; + private int index; + + Operation(UUID worldId, CropType crop, List positions) { + this.worldId = worldId; + this.crop = crop; + this.positions = new ArrayList<>(positions); + } + + UUID worldId() { + return worldId; + } + + CropType crop() { + return crop; + } + + boolean hasNext() { + return index < positions.size(); + } + + BlockPosition next() { + return positions.get(index++); + } + } +} diff --git a/src/main/java/games/dmg/spigotharvest/HarvestProgressService.java b/src/main/java/games/dmg/spigotharvest/HarvestProgressService.java new file mode 100644 index 0000000..5bfeb45 --- /dev/null +++ b/src/main/java/games/dmg/spigotharvest/HarvestProgressService.java @@ -0,0 +1,33 @@ +package games.dmg.spigotharvest; + +import org.bukkit.entity.Player; + +/** Coordinates state, progression rules, feedback, and durable saves. */ +public final class HarvestProgressService { + private final HarvestStateManager states; + private final HarvestSettings settings; + private final HarvestFeedback feedback; + + public HarvestProgressService( + HarvestStateManager states, + HarvestSettings settings, + HarvestFeedback feedback + ) { + this.states = states; + this.settings = settings; + this.feedback = feedback; + } + + public boolean record(Player player, CropType crop, long amount) { + if (!settings.isEligible(player.getGameMode())) { + return false; + } + PlayerHarvestState state = states.stateFor(player); + ProgressionUpdate update = settings.progression(crop).add(state, crop, amount); + feedback.update(player, crop, state, update); + if (update.levelsUnlocked() > 0) { + states.save(); + } + return true; + } +} diff --git a/src/main/java/games/dmg/spigotharvest/HarvestStatusFormatter.java b/src/main/java/games/dmg/spigotharvest/HarvestStatusFormatter.java new file mode 100644 index 0000000..75264bb --- /dev/null +++ b/src/main/java/games/dmg/spigotharvest/HarvestStatusFormatter.java @@ -0,0 +1,36 @@ +package games.dmg.spigotharvest; + +import java.util.Locale; + +/** Stable player-facing progression text. */ +public final class HarvestStatusFormatter { + private HarvestStatusFormatter() { + } + + public static String summary(PlayerHarvestState state, HarvestSettings settings) { + StringBuilder text = new StringBuilder("Harvest progress (boss bar: ") + .append(state.bossBarEnabled() ? "enabled" : "disabled").append(")"); + for (CropType crop : CropType.values()) { + text.append('\n').append(crop(state, crop, settings.progression(crop))); + } + return text.toString(); + } + + public static String crop( + PlayerHarvestState state, + CropType crop, + HarvestProgression progression + ) { + CropProgress progress = state.progress(crop); + String name = crop.displayName().substring(0, 1).toUpperCase(Locale.ROOT) + + crop.displayName().substring(1); + if (progress.level() == HarvestProgression.MAX_LEVEL) { + return name + ": Level X complete, cap " + progression.capForLevel(10); + } + long requirement = progression.requirementForLevel(progress.level() + 1); + int currentCap = progression.operationCap(state, crop); + int nextCap = progression.capForLevel(progress.level() + 1); + return name + ": Level " + HarvestLevel.format(progress.level()) + ", " + + progress.harvests() + " / " + requirement + ", cap " + currentCap + " → " + nextCap; + } +} diff --git a/src/main/java/games/dmg/spigotharvest/SpigotHarvestPlugin.java b/src/main/java/games/dmg/spigotharvest/SpigotHarvestPlugin.java index 0029fe2..dfc80ee 100644 --- a/src/main/java/games/dmg/spigotharvest/SpigotHarvestPlugin.java +++ b/src/main/java/games/dmg/spigotharvest/SpigotHarvestPlugin.java @@ -1,11 +1,59 @@ package games.dmg.spigotharvest; +import java.nio.file.Path; +import java.util.Objects; +import org.bukkit.command.PluginCommand; import org.bukkit.plugin.java.JavaPlugin; /** Spigot Harvest plugin entry point. */ public final class SpigotHarvestPlugin extends JavaPlugin { + private HarvestStateManager states; + private HarvestFeedback feedback; + private HarvestOperationManager operations; + @Override public void onEnable() { - // Feature services are registered by their corresponding user stories. + saveDefaultConfig(); + final HarvestSettings settings; + try { + settings = HarvestSettings.from(getConfig()); + } catch (IllegalArgumentException exception) { + getLogger().severe("Invalid Spigot Harvest configuration: " + exception.getMessage()); + getServer().getPluginManager().disablePlugin(this); + return; + } + + Path statePath = getDataFolder().toPath().resolve("state.yml"); + states = new HarvestStateManager(this, new YamlHarvestStateRepository(statePath)); + feedback = new HarvestFeedback(this, settings); + HarvestProgressService progress = new HarvestProgressService(states, settings, feedback); + operations = new HarvestOperationManager(this, settings, progress); + + HarvestCommand harvest = new HarvestCommand(states, settings, feedback); + PluginCommand harvestCommand = Objects.requireNonNull(getCommand("harvest"), "harvest command"); + harvestCommand.setExecutor(harvest); + harvestCommand.setTabCompleter(harvest); + + HarvestAdminCommand admin = new HarvestAdminCommand(states, settings, operations); + PluginCommand adminCommand = Objects.requireNonNull( + getCommand("harvestadmin"), "harvestadmin command"); + adminCommand.setExecutor(admin); + adminCommand.setTabCompleter(admin); + + getServer().getPluginManager().registerEvents( + new HarvestListener(states, settings, progress, operations, feedback), this); + } + + @Override + public void onDisable() { + if (operations != null) { + operations.close(); + } + if (feedback != null) { + feedback.close(); + } + if (states != null) { + states.save(); + } } } diff --git a/src/test/java/games/dmg/spigotharvest/AdminHarvestServiceTest.java b/src/test/java/games/dmg/spigotharvest/AdminHarvestServiceTest.java new file mode 100644 index 0000000..b8c9417 --- /dev/null +++ b/src/test/java/games/dmg/spigotharvest/AdminHarvestServiceTest.java @@ -0,0 +1,26 @@ +package games.dmg.spigotharvest; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.UUID; +import org.junit.jupiter.api.Test; + +final class AdminHarvestServiceTest { + @Test + void setsLevelsProgressAndResetsWithoutPartialState() { + PlayerHarvestState state = new PlayerHarvestState(UUID.randomUUID(), "Farmer"); + AdminHarvestService service = new AdminHarvestService(HarvestSettings.defaults()); + + service.setLevel(state, CropType.CARROT, 2); + assertEquals(2, state.progress(CropType.CARROT).level()); + assertEquals(0, state.progress(CropType.CARROT).harvests()); + + service.setProgress(state, CropType.CARROT, 805); + assertEquals(3, state.progress(CropType.CARROT).level()); + assertEquals(5, state.progress(CropType.CARROT).harvests()); + + service.reset(state, CropType.CARROT); + assertEquals(0, state.progress(CropType.CARROT).level()); + assertEquals(0, state.progress(CropType.CARROT).harvests()); + } +} diff --git a/src/test/java/games/dmg/spigotharvest/HarvestStatusFormatterTest.java b/src/test/java/games/dmg/spigotharvest/HarvestStatusFormatterTest.java new file mode 100644 index 0000000..19881c2 --- /dev/null +++ b/src/test/java/games/dmg/spigotharvest/HarvestStatusFormatterTest.java @@ -0,0 +1,23 @@ +package games.dmg.spigotharvest; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; +import org.junit.jupiter.api.Test; + +final class HarvestStatusFormatterTest { + @Test + void describesLevelLocalProgressCapsAndPreference() { + PlayerHarvestState state = new PlayerHarvestState(UUID.randomUUID(), "Farmer"); + state.progress(CropType.WHEAT).restore(1, 225); + state.setBossBarEnabled(false); + + String line = HarvestStatusFormatter.crop(state, CropType.WHEAT, HarvestProgression.defaults()); + String summary = HarvestStatusFormatter.summary(state, HarvestSettings.defaults()); + + assertTrue(line.contains("Level I")); + assertTrue(line.contains("225 / 400")); + assertTrue(line.contains("cap 4 → 8")); + assertTrue(summary.contains("boss bar: disabled")); + } +}