diff --git a/design/user-stories/us-001-track-crop-harvesting-progression.md b/design/user-stories/us-001-track-crop-harvesting-progression.md index cfd33b9..2a778ee 100644 --- a/design/user-stories/us-001-track-crop-harvesting-progression.md +++ b/design/user-stories/us-001-track-crop-harvesting-progression.md @@ -2,7 +2,7 @@ type: User Story title: "US-001: Track crop harvesting progression" description: Track qualifying harvest activity independently for each player and supported crop. -status: backlog +status: in-progress --- # US-001: Track crop harvesting progression diff --git a/design/user-stories/us-002-unlock-crop-auto-harvest-levels.md b/design/user-stories/us-002-unlock-crop-auto-harvest-levels.md index f18d94e..be63768 100644 --- a/design/user-stories/us-002-unlock-crop-auto-harvest-levels.md +++ b/design/user-stories/us-002-unlock-crop-auto-harvest-levels.md @@ -2,7 +2,7 @@ type: User Story title: "US-002: Unlock crop auto-harvest levels" description: Let players earn ten independently configured auto-harvest levels for every supported crop. -status: backlog +status: in-progress --- # US-002: Unlock crop auto-harvest levels diff --git a/design/user-stories/us-007-configure-and-persist-harvesting-behavior.md b/design/user-stories/us-007-configure-and-persist-harvesting-behavior.md index 0f1fc1f..2bd5d51 100644 --- a/design/user-stories/us-007-configure-and-persist-harvesting-behavior.md +++ b/design/user-stories/us-007-configure-and-persist-harvesting-behavior.md @@ -2,7 +2,7 @@ type: User Story title: "US-007: Configure and persist harvesting behavior" description: Give operators validated configuration and durable storage for crop progression and harvesting behavior. -status: backlog +status: in-progress --- # US-007: Configure and persist harvesting behavior diff --git a/src/main/java/games/dmg/spigotharvest/CropProgress.java b/src/main/java/games/dmg/spigotharvest/CropProgress.java new file mode 100644 index 0000000..ac87462 --- /dev/null +++ b/src/main/java/games/dmg/spigotharvest/CropProgress.java @@ -0,0 +1,32 @@ +package games.dmg.spigotharvest; + +/** Mutable level-local progress for one crop. */ +public final class CropProgress { + private int level; + private long harvests; + + public int level() { + return level; + } + + public long harvests() { + return harvests; + } + + void addHarvests(long amount) { + harvests = Math.addExact(harvests, amount); + } + + void restore(int restoredLevel, long restoredHarvests) { + level = restoredLevel; + harvests = restoredHarvests; + } + + void setLevel(int newLevel) { + level = newLevel; + } + + void setHarvests(long newHarvests) { + harvests = newHarvests; + } +} diff --git a/src/main/java/games/dmg/spigotharvest/CropType.java b/src/main/java/games/dmg/spigotharvest/CropType.java new file mode 100644 index 0000000..4a1f513 --- /dev/null +++ b/src/main/java/games/dmg/spigotharvest/CropType.java @@ -0,0 +1,42 @@ +package games.dmg.spigotharvest; + +import java.util.Locale; +import java.util.Optional; +import org.bukkit.Material; + +/** A crop with independent player progression. */ +public enum CropType { + WHEAT(Material.WHEAT, Material.WHEAT_SEEDS), + CARROT(Material.CARROTS, Material.CARROT), + POTATO(Material.POTATOES, Material.POTATO), + BEETROOT(Material.BEETROOTS, Material.BEETROOT_SEEDS); + + private final Material blockMaterial; + private final Material plantingMaterial; + + CropType(Material blockMaterial, Material plantingMaterial) { + this.blockMaterial = blockMaterial; + this.plantingMaterial = plantingMaterial; + } + + public Material blockMaterial() { + return blockMaterial; + } + + public Material plantingMaterial() { + return plantingMaterial; + } + + public String displayName() { + return name().toLowerCase(Locale.ROOT); + } + + public static Optional fromBlock(Material material) { + for (CropType crop : values()) { + if (crop.blockMaterial == material) { + return Optional.of(crop); + } + } + return Optional.empty(); + } +} diff --git a/src/main/java/games/dmg/spigotharvest/HarvestProgressTracker.java b/src/main/java/games/dmg/spigotharvest/HarvestProgressTracker.java new file mode 100644 index 0000000..146346f --- /dev/null +++ b/src/main/java/games/dmg/spigotharvest/HarvestProgressTracker.java @@ -0,0 +1,25 @@ +package games.dmg.spigotharvest; + +import java.util.Objects; +import org.bukkit.GameMode; +import org.bukkit.Material; + +/** Applies qualifying crop harvests to player state. */ +public final class HarvestProgressTracker { + public boolean record( + PlayerHarvestState state, + Material material, + int age, + int maximumAge, + GameMode gameMode + ) { + Objects.requireNonNull(state, "state"); + if (gameMode != GameMode.SURVIVAL || age != maximumAge) { + return false; + } + return CropType.fromBlock(material).map(crop -> { + state.progress(crop).addHarvests(1); + return true; + }).orElse(false); + } +} diff --git a/src/main/java/games/dmg/spigotharvest/HarvestProgression.java b/src/main/java/games/dmg/spigotharvest/HarvestProgression.java new file mode 100644 index 0000000..b83df77 --- /dev/null +++ b/src/main/java/games/dmg/spigotharvest/HarvestProgression.java @@ -0,0 +1,77 @@ +package games.dmg.spigotharvest; + +import java.util.Arrays; +import java.util.Objects; + +/** Incremental ten-level crop progression policy. */ +public final class HarvestProgression { + public static final int MAX_LEVEL = 10; + private final long[] requirements; + private final int[] caps; + + public HarvestProgression(long[] requirements, int[] caps) { + if (requirements.length != MAX_LEVEL || caps.length != MAX_LEVEL) { + throw new IllegalArgumentException("exactly ten requirements and caps are required"); + } + this.requirements = Arrays.copyOf(requirements, requirements.length); + this.caps = Arrays.copyOf(caps, caps.length); + for (int index = 0; index < MAX_LEVEL; index++) { + if (this.requirements[index] <= 0 || this.caps[index] <= 0) { + throw new IllegalArgumentException("requirements and caps must be positive"); + } + if (index > 0 && this.caps[index] <= this.caps[index - 1]) { + throw new IllegalArgumentException("caps must increase by level"); + } + } + } + + public static HarvestProgression defaults() { + return new HarvestProgression( + new long[] {100, 400, 800, 1_600, 3_200, 12_800, 25_600, 51_200, 153_600, 409_600}, + new int[] {4, 8, 16, 32, 64, 128, 256, 512, 1_024, 2_048} + ); + } + + public ProgressionUpdate add(PlayerHarvestState state, CropType crop, long amount) { + Objects.requireNonNull(state, "state"); + Objects.requireNonNull(crop, "crop"); + if (amount < 0) { + throw new IllegalArgumentException("amount cannot be negative"); + } + CropProgress progress = state.progress(crop); + int previousLevel = progress.level(); + if (previousLevel == MAX_LEVEL) { + progress.setHarvests(0); + return new ProgressionUpdate(previousLevel, previousLevel, 0); + } + progress.addHarvests(amount); + while (progress.level() < MAX_LEVEL + && progress.harvests() >= requirementForLevel(progress.level() + 1)) { + long remainder = progress.harvests() - requirementForLevel(progress.level() + 1); + progress.setLevel(progress.level() + 1); + progress.setHarvests(progress.level() == MAX_LEVEL ? 0 : remainder); + } + return new ProgressionUpdate(previousLevel, progress.level(), progress.harvests()); + } + + public long requirementForLevel(int level) { + requireLevel(level); + return requirements[level - 1]; + } + + public int capForLevel(int level) { + requireLevel(level); + return caps[level - 1]; + } + + public int operationCap(PlayerHarvestState state, CropType crop) { + int level = state.progress(crop).level(); + return level == 0 ? 1 : capForLevel(level); + } + + private static void requireLevel(int level) { + if (level < 1 || level > MAX_LEVEL) { + throw new IllegalArgumentException("level must be from 1 through 10"); + } + } +} diff --git a/src/main/java/games/dmg/spigotharvest/HarvestSettings.java b/src/main/java/games/dmg/spigotharvest/HarvestSettings.java new file mode 100644 index 0000000..0a19fbf --- /dev/null +++ b/src/main/java/games/dmg/spigotharvest/HarvestSettings.java @@ -0,0 +1,179 @@ +package games.dmg.spigotharvest; + +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.bukkit.GameMode; +import org.bukkit.Material; +import org.bukkit.configuration.ConfigurationSection; + +/** Validated runtime configuration. */ +public final class HarvestSettings { + private final int cropsPerTick; + private final int bossBarIdleTicks; + private final Map progressions; + private final Set eligibleGameModes; + private final int titleFadeInTicks; + private final int titleStayTicks; + private final int titleFadeOutTicks; + + public HarvestSettings( + int cropsPerTick, + int bossBarIdleTicks, + Map progressions + ) { + this( + cropsPerTick, bossBarIdleTicks, progressions, EnumSet.of(GameMode.SURVIVAL), + 10, 70, 20 + ); + } + + public HarvestSettings( + int cropsPerTick, + int bossBarIdleTicks, + Map progressions, + Set eligibleGameModes, + int titleFadeInTicks, + int titleStayTicks, + int titleFadeOutTicks + ) { + if (cropsPerTick <= 0 || cropsPerTick > 64) { + throw new IllegalArgumentException("crops-per-tick must be from 1 through 64"); + } + if (bossBarIdleTicks < 0) { + throw new IllegalArgumentException("boss-bar-idle-ticks cannot be negative"); + } + if (titleFadeInTicks < 0 || titleStayTicks < 0 || titleFadeOutTicks < 0) { + throw new IllegalArgumentException("title timing cannot be negative"); + } + if (progressions.size() != CropType.values().length) { + throw new IllegalArgumentException("every supported crop requires progression settings"); + } + if (eligibleGameModes.isEmpty()) { + throw new IllegalArgumentException("at least one eligible game mode is required"); + } + this.cropsPerTick = cropsPerTick; + this.bossBarIdleTicks = bossBarIdleTicks; + this.progressions = new EnumMap<>(progressions); + this.eligibleGameModes = EnumSet.copyOf(eligibleGameModes); + this.titleFadeInTicks = titleFadeInTicks; + this.titleStayTicks = titleStayTicks; + this.titleFadeOutTicks = titleFadeOutTicks; + } + + public static HarvestSettings defaults() { + return new HarvestSettings(1, 100, defaultProgressions()); + } + + public static Map defaultProgressions() { + Map values = new EnumMap<>(CropType.class); + for (CropType crop : CropType.values()) { + values.put(crop, HarvestProgression.defaults()); + } + return values; + } + + public static HarvestSettings from(ConfigurationSection configuration) { + int rate = configuration.getInt("crops-per-tick", 1); + int idleTicks = configuration.getInt("boss-bar-idle-ticks", 100); + Set modes = EnumSet.noneOf(GameMode.class); + for (String value : configuration.getStringList("eligible-game-modes")) { + try { + modes.add(GameMode.valueOf(value)); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("unknown eligible game mode: " + value, exception); + } + } + if (modes.isEmpty() && !configuration.contains("eligible-game-modes")) { + modes.add(GameMode.SURVIVAL); + } + Map paths = new EnumMap<>(CropType.class); + for (CropType crop : CropType.values()) { + String root = "crops." + crop.displayName(); + validateCropDefinition(configuration, root, crop); + List requirements = configuration.getLongList(root + ".requirements"); + List caps = configuration.getIntegerList(root + ".caps"); + if (requirements.isEmpty() && caps.isEmpty()) { + paths.put(crop, HarvestProgression.defaults()); + } else { + paths.put(crop, new HarvestProgression(toLongArray(requirements), toIntArray(caps))); + } + } + return new HarvestSettings( + rate, + idleTicks, + paths, + modes, + configuration.getInt("title-fade-in-ticks", 10), + configuration.getInt("title-stay-ticks", 70), + configuration.getInt("title-fade-out-ticks", 20) + ); + } + + public int cropsPerTick() { + return cropsPerTick; + } + + public int bossBarIdleTicks() { + return bossBarIdleTicks; + } + + public HarvestProgression progression(CropType crop) { + return progressions.get(crop); + } + + public boolean isEligible(GameMode gameMode) { + return eligibleGameModes.contains(gameMode); + } + + public int titleFadeInTicks() { + return titleFadeInTicks; + } + + public int titleStayTicks() { + return titleStayTicks; + } + + public int titleFadeOutTicks() { + return titleFadeOutTicks; + } + + private static void validateCropDefinition( + ConfigurationSection configuration, + String root, + CropType crop + ) { + String blockName = configuration.getString(root + ".block-material"); + String plantingName = configuration.getString(root + ".planting-material"); + String maturity = configuration.getString(root + ".maturity"); + if (blockName == null && plantingName == null && maturity == null) { + return; + } + Material block = blockName == null ? null : Material.matchMaterial(blockName); + Material planting = plantingName == null ? null : Material.matchMaterial(plantingName); + if (block != crop.blockMaterial() || planting != crop.plantingMaterial()) { + throw new IllegalArgumentException("invalid materials for crop " + crop.displayName()); + } + if (!"maximum-age".equals(maturity)) { + throw new IllegalArgumentException("unsupported maturity rule for crop " + crop.displayName()); + } + } + + private static long[] toLongArray(List values) { + long[] result = new long[values.size()]; + for (int index = 0; index < values.size(); index++) { + result[index] = values.get(index); + } + return result; + } + + private static int[] toIntArray(List values) { + int[] result = new int[values.size()]; + for (int index = 0; index < values.size(); index++) { + result[index] = values.get(index); + } + return result; + } +} diff --git a/src/main/java/games/dmg/spigotharvest/HarvestStateManager.java b/src/main/java/games/dmg/spigotharvest/HarvestStateManager.java new file mode 100644 index 0000000..c3a8c3d --- /dev/null +++ b/src/main/java/games/dmg/spigotharvest/HarvestStateManager.java @@ -0,0 +1,52 @@ +package games.dmg.spigotharvest; + +import java.io.IOException; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; + +/** In-memory owner of durable player state. */ +public final class HarvestStateManager { + private final JavaPlugin plugin; + private final YamlHarvestStateRepository repository; + private final Map states; + + public HarvestStateManager(JavaPlugin plugin, YamlHarvestStateRepository repository) { + this.plugin = plugin; + this.repository = repository; + this.states = new LinkedHashMap<>(repository.load()); + } + + public PlayerHarvestState stateFor(Player player) { + PlayerHarvestState state = states.computeIfAbsent( + player.getUniqueId(), id -> new PlayerHarvestState(id, player.getName())); + state.updateLatestName(player.getName()); + return state; + } + + public Optional find(String playerName) { + return states.values().stream() + .filter(state -> state.latestName().equalsIgnoreCase(playerName)) + .findFirst(); + } + + public Optional find(UUID playerId) { + return Optional.ofNullable(states.get(playerId)); + } + + public Collection all() { + return states.values(); + } + + public void save() { + try { + repository.save(states.values()); + } catch (IOException exception) { + plugin.getLogger().severe("Could not save harvest state: " + exception.getMessage()); + } + } +} diff --git a/src/main/java/games/dmg/spigotharvest/PlayerHarvestState.java b/src/main/java/games/dmg/spigotharvest/PlayerHarvestState.java new file mode 100644 index 0000000..0862738 --- /dev/null +++ b/src/main/java/games/dmg/spigotharvest/PlayerHarvestState.java @@ -0,0 +1,46 @@ +package games.dmg.spigotharvest; + +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +/** Durable progression and preferences belonging to one player. */ +public final class PlayerHarvestState { + private final UUID playerId; + private final Map crops = new EnumMap<>(CropType.class); + private String latestName; + private boolean bossBarEnabled = true; + + public PlayerHarvestState(UUID playerId, String latestName) { + this.playerId = Objects.requireNonNull(playerId, "playerId"); + this.latestName = Objects.requireNonNull(latestName, "latestName"); + for (CropType crop : CropType.values()) { + crops.put(crop, new CropProgress()); + } + } + + public UUID playerId() { + return playerId; + } + + public String latestName() { + return latestName; + } + + public void updateLatestName(String name) { + latestName = Objects.requireNonNull(name, "name"); + } + + public CropProgress progress(CropType crop) { + return crops.get(Objects.requireNonNull(crop, "crop")); + } + + public boolean bossBarEnabled() { + return bossBarEnabled; + } + + public void setBossBarEnabled(boolean enabled) { + bossBarEnabled = enabled; + } +} diff --git a/src/main/java/games/dmg/spigotharvest/ProgressionUpdate.java b/src/main/java/games/dmg/spigotharvest/ProgressionUpdate.java new file mode 100644 index 0000000..d623726 --- /dev/null +++ b/src/main/java/games/dmg/spigotharvest/ProgressionUpdate.java @@ -0,0 +1,8 @@ +package games.dmg.spigotharvest; + +/** Observable result of applying harvest progress. */ +public record ProgressionUpdate(int previousLevel, int currentLevel, long currentProgress) { + public int levelsUnlocked() { + return currentLevel - previousLevel; + } +} diff --git a/src/main/java/games/dmg/spigotharvest/YamlHarvestStateRepository.java b/src/main/java/games/dmg/spigotharvest/YamlHarvestStateRepository.java new file mode 100644 index 0000000..b8cf577 --- /dev/null +++ b/src/main/java/games/dmg/spigotharvest/YamlHarvestStateRepository.java @@ -0,0 +1,99 @@ +package games.dmg.spigotharvest; + +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; + +/** Atomic YAML storage for UUID-keyed harvest state. */ +public final class YamlHarvestStateRepository { + private final Path path; + + public YamlHarvestStateRepository(Path path) { + this.path = path; + } + + public Map load() { + Map states = new LinkedHashMap<>(); + if (!Files.isRegularFile(path)) { + return states; + } + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(path.toFile()); + ConfigurationSection players = yaml.getConfigurationSection("players"); + if (players == null) { + return states; + } + for (String idText : players.getKeys(false)) { + try { + UUID id = UUID.fromString(idText); + ConfigurationSection player = players.getConfigurationSection(idText); + if (player == null) { + continue; + } + String name = player.getString("name"); + if (name == null || name.isBlank()) { + continue; + } + PlayerHarvestState state = new PlayerHarvestState(id, name); + state.setBossBarEnabled(player.getBoolean("boss-bar-enabled", true)); + ConfigurationSection crops = player.getConfigurationSection("crops"); + if (crops != null) { + restoreCrops(state, crops); + } + states.put(id, state); + } catch (IllegalArgumentException ignored) { + // Invalid records never grant progress. + } + } + return states; + } + + public void save(Collection states) throws IOException { + YamlConfiguration yaml = Files.isRegularFile(path) + ? YamlConfiguration.loadConfiguration(path.toFile()) + : new YamlConfiguration(); + for (PlayerHarvestState state : states) { + String root = "players." + state.playerId(); + yaml.set(root + ".name", state.latestName()); + yaml.set(root + ".boss-bar-enabled", state.bossBarEnabled()); + for (CropType crop : CropType.values()) { + CropProgress progress = state.progress(crop); + String cropRoot = root + ".crops." + crop.displayName(); + yaml.set(cropRoot + ".level", progress.level()); + yaml.set(cropRoot + ".progress", progress.harvests()); + } + } + Path parent = path.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Path temporary = path.resolveSibling(path.getFileName() + ".tmp"); + yaml.save(temporary.toFile()); + try { + Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static void restoreCrops(PlayerHarvestState state, ConfigurationSection crops) { + for (CropType crop : CropType.values()) { + ConfigurationSection section = crops.getConfigurationSection(crop.displayName()); + if (section == null) { + continue; + } + int level = section.getInt("level", 0); + long progress = section.getLong("progress", 0); + if (level >= 0 && level <= 10 && progress >= 0) { + state.progress(crop).restore(level, progress); + } + } + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..edc711b --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,36 @@ +# Processing and presentation +crops-per-tick: 1 +boss-bar-idle-ticks: 100 +title-fade-in-ticks: 10 +title-stay-ticks: 70 +title-fade-out-ticks: 20 +eligible-game-modes: + - SURVIVAL + +# Each requirement is incremental progress needed to earn Levels I through X. +# Each cap includes the crop manually broken to start an operation. +crops: + wheat: + block-material: WHEAT + planting-material: WHEAT_SEEDS + maturity: maximum-age + requirements: [100, 400, 800, 1600, 3200, 12800, 25600, 51200, 153600, 409600] + caps: [4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048] + carrot: + block-material: CARROTS + planting-material: CARROT + maturity: maximum-age + requirements: [100, 400, 800, 1600, 3200, 12800, 25600, 51200, 153600, 409600] + caps: [4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048] + potato: + block-material: POTATOES + planting-material: POTATO + maturity: maximum-age + requirements: [100, 400, 800, 1600, 3200, 12800, 25600, 51200, 153600, 409600] + caps: [4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048] + beetroot: + block-material: BEETROOTS + planting-material: BEETROOT_SEEDS + maturity: maximum-age + requirements: [100, 400, 800, 1600, 3200, 12800, 25600, 51200, 153600, 409600] + caps: [4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048] diff --git a/src/test/java/games/dmg/spigotharvest/HarvestProgressTrackerTest.java b/src/test/java/games/dmg/spigotharvest/HarvestProgressTrackerTest.java new file mode 100644 index 0000000..6a8ec5e --- /dev/null +++ b/src/test/java/games/dmg/spigotharvest/HarvestProgressTrackerTest.java @@ -0,0 +1,25 @@ +package games.dmg.spigotharvest; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.UUID; +import org.bukkit.GameMode; +import org.bukkit.Material; +import org.junit.jupiter.api.Test; + +final class HarvestProgressTrackerTest { + @Test + void tracksMatureSurvivalHarvestsIndependentlyByCrop() { + PlayerHarvestState state = new PlayerHarvestState(UUID.randomUUID(), "Farmer"); + HarvestProgressTracker tracker = new HarvestProgressTracker(); + + tracker.record(state, Material.WHEAT, 7, 7, GameMode.SURVIVAL); + tracker.record(state, Material.CARROTS, 7, 7, GameMode.SURVIVAL); + tracker.record(state, Material.WHEAT, 6, 7, GameMode.SURVIVAL); + tracker.record(state, Material.WHEAT, 7, 7, GameMode.CREATIVE); + + assertEquals(1, state.progress(CropType.WHEAT).harvests()); + assertEquals(1, state.progress(CropType.CARROT).harvests()); + assertEquals(0, state.progress(CropType.POTATO).harvests()); + } +} diff --git a/src/test/java/games/dmg/spigotharvest/HarvestProgressionTest.java b/src/test/java/games/dmg/spigotharvest/HarvestProgressionTest.java new file mode 100644 index 0000000..1554bc5 --- /dev/null +++ b/src/test/java/games/dmg/spigotharvest/HarvestProgressionTest.java @@ -0,0 +1,41 @@ +package games.dmg.spigotharvest; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.UUID; +import org.junit.jupiter.api.Test; + +final class HarvestProgressionTest { + @Test + void unlocksIncrementalLevelsAndCarriesExcessProgress() { + PlayerHarvestState state = new PlayerHarvestState(UUID.randomUUID(), "Farmer"); + HarvestProgression progression = HarvestProgression.defaults(); + + ProgressionUpdate first = progression.add(state, CropType.WHEAT, 105); + assertEquals(1, first.levelsUnlocked()); + assertEquals(1, state.progress(CropType.WHEAT).level()); + assertEquals(5, state.progress(CropType.WHEAT).harvests()); + assertEquals(4, progression.operationCap(state, CropType.WHEAT)); + + ProgressionUpdate second = progression.add(state, CropType.WHEAT, 395); + assertEquals(1, second.levelsUnlocked()); + assertEquals(2, state.progress(CropType.WHEAT).level()); + assertEquals(0, state.progress(CropType.WHEAT).harvests()); + assertEquals(8, progression.operationCap(state, CropType.WHEAT)); + } + + @Test + void usesApprovedRequirementsAndStopsAtLevelTen() { + HarvestProgression progression = HarvestProgression.defaults(); + assertEquals(100, progression.requirementForLevel(1)); + assertEquals(400, progression.requirementForLevel(2)); + assertEquals(12_800, progression.requirementForLevel(6)); + assertEquals(409_600, progression.requirementForLevel(10)); + assertEquals(2_048, progression.capForLevel(10)); + + PlayerHarvestState state = new PlayerHarvestState(UUID.randomUUID(), "Farmer"); + progression.add(state, CropType.POTATO, 1_000_000); + assertEquals(10, state.progress(CropType.POTATO).level()); + assertEquals(0, state.progress(CropType.POTATO).harvests()); + } +} diff --git a/src/test/java/games/dmg/spigotharvest/HarvestSettingsTest.java b/src/test/java/games/dmg/spigotharvest/HarvestSettingsTest.java new file mode 100644 index 0000000..7b4bfd6 --- /dev/null +++ b/src/test/java/games/dmg/spigotharvest/HarvestSettingsTest.java @@ -0,0 +1,26 @@ +package games.dmg.spigotharvest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +final class HarvestSettingsTest { + @Test + void defaultsMatchApprovedPaceAndPresentation() { + HarvestSettings settings = HarvestSettings.defaults(); + + assertEquals(1, settings.cropsPerTick()); + assertEquals(100, settings.bossBarIdleTicks()); + assertEquals(100, settings.progression(CropType.WHEAT).requirementForLevel(1)); + assertEquals(2_048, settings.progression(CropType.BEETROOT).capForLevel(10)); + } + + @Test + void rejectsUnsafeProcessingValues() { + assertThrows(IllegalArgumentException.class, () -> + new HarvestSettings(0, 100, HarvestSettings.defaultProgressions())); + assertThrows(IllegalArgumentException.class, () -> + new HarvestSettings(1, -1, HarvestSettings.defaultProgressions())); + } +} diff --git a/src/test/java/games/dmg/spigotharvest/YamlHarvestStateRepositoryTest.java b/src/test/java/games/dmg/spigotharvest/YamlHarvestStateRepositoryTest.java new file mode 100644 index 0000000..eb90550 --- /dev/null +++ b/src/test/java/games/dmg/spigotharvest/YamlHarvestStateRepositoryTest.java @@ -0,0 +1,46 @@ +package games.dmg.spigotharvest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class YamlHarvestStateRepositoryTest { + @TempDir + Path directory; + + @Test + void roundTripsUuidCropProgressAndBossBarPreference() throws Exception { + UUID id = UUID.randomUUID(); + PlayerHarvestState original = new PlayerHarvestState(id, "Farmer"); + original.progress(CropType.WHEAT).restore(2, 37); + original.setBossBarEnabled(false); + YamlHarvestStateRepository repository = new YamlHarvestStateRepository(directory.resolve("state.yml")); + + repository.save(List.of(original)); + Map loaded = repository.load(); + + assertEquals("Farmer", loaded.get(id).latestName()); + assertEquals(2, loaded.get(id).progress(CropType.WHEAT).level()); + assertEquals(37, loaded.get(id).progress(CropType.WHEAT).harvests()); + assertTrue(!loaded.get(id).bossBarEnabled()); + } + + @Test + void preservesUnknownForwardCompatibleFields() throws Exception { + Path path = directory.resolve("state.yml"); + UUID id = UUID.randomUUID(); + Files.writeString(path, "players:\n " + id + ":\n name: Farmer\n future-field: retained\n"); + YamlHarvestStateRepository repository = new YamlHarvestStateRepository(path); + + repository.save(repository.load().values()); + + assertTrue(Files.readString(path).contains("future-field: retained")); + } +}