feat(harvest): run animated auto-harvest operations

This commit is contained in:
dmg
2026-08-14 18:26:10 -04:00
parent 7a89119869
commit e0a9413cb2
14 changed files with 865 additions and 3 deletions
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-005: View crop progress and notifications" title: "US-005: View crop progress and notifications"
description: Give players command and boss-bar feedback about each crop's current progression. 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 # US-005: View crop progress and notifications
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-006: Administer player harvest progression" title: "US-006: Administer player harvest progression"
description: Let administrators inspect, correct, and reset player crop progression safely through commands. description: Let administrators inspect, correct, and reset player crop progression safely through commands.
status: backlog status: in-progress
--- ---
# US-006: Administer player harvest progression # 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; package games.dmg.spigotharvest;
import java.nio.file.Path;
import java.util.Objects;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
/** Spigot Harvest plugin entry point. */ /** Spigot Harvest plugin entry point. */
public final class SpigotHarvestPlugin extends JavaPlugin { public final class SpigotHarvestPlugin extends JavaPlugin {
private HarvestStateManager states;
private HarvestFeedback feedback;
private HarvestOperationManager operations;
@Override @Override
public void onEnable() { 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"));
}
}