feat(harvest): run animated auto-harvest operations
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<PlayerHarvestState> 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 "
|
||||
+ "<status|setlevel|setprogress|reset> <player> ...");
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> 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<String> 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<String> 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<String> matches(String prefix, List<String> values) {
|
||||
String normalized = prefix.toLowerCase(Locale.ROOT);
|
||||
return values.stream().filter(value -> value.toLowerCase(Locale.ROOT).startsWith(normalized)).toList();
|
||||
}
|
||||
}
|
||||
@@ -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 <enable|disable>]");
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> 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<String> matches(String prefix, List<String> values) {
|
||||
String normalized = prefix.toLowerCase(Locale.ROOT);
|
||||
List<String> matches = new ArrayList<>();
|
||||
for (String value : values) {
|
||||
if (value.startsWith(normalized)) {
|
||||
matches.add(value);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
@@ -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<UUID, BossBar> bars = new HashMap<>();
|
||||
private final Map<UUID, BukkitTask> 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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<UUID, Operation> operations = new HashMap<>();
|
||||
private final Set<UUID> 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<BlockPosition> 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<Map.Entry<UUID, Operation>> iterator = operations.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<UUID, Operation> 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<ItemStack> 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<ItemStack> 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<BlockPosition> positions;
|
||||
private int index;
|
||||
|
||||
Operation(UUID worldId, CropType crop, List<BlockPosition> 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++);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user