feat(progression): add persistent crop levels

This commit is contained in:
dmg
2026-08-14 18:25:56 -04:00
parent 57bb0d6049
commit 9ddd69158f
17 changed files with 737 additions and 3 deletions
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-001: Track crop harvesting progression" title: "US-001: Track crop harvesting progression"
description: Track qualifying harvest activity independently for each player and supported crop. description: Track qualifying harvest activity independently for each player and supported crop.
status: backlog status: in-progress
--- ---
# US-001: Track crop harvesting progression # US-001: Track crop harvesting progression
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-002: Unlock crop auto-harvest levels" title: "US-002: Unlock crop auto-harvest levels"
description: Let players earn ten independently configured auto-harvest levels for every supported crop. 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 # US-002: Unlock crop auto-harvest levels
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-007: Configure and persist harvesting behavior" title: "US-007: Configure and persist harvesting behavior"
description: Give operators validated configuration and durable storage for crop progression and 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 # US-007: Configure and persist harvesting behavior
@@ -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;
}
}
@@ -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<CropType> fromBlock(Material material) {
for (CropType crop : values()) {
if (crop.blockMaterial == material) {
return Optional.of(crop);
}
}
return Optional.empty();
}
}
@@ -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);
}
}
@@ -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");
}
}
}
@@ -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<CropType, HarvestProgression> progressions;
private final Set<GameMode> eligibleGameModes;
private final int titleFadeInTicks;
private final int titleStayTicks;
private final int titleFadeOutTicks;
public HarvestSettings(
int cropsPerTick,
int bossBarIdleTicks,
Map<CropType, HarvestProgression> progressions
) {
this(
cropsPerTick, bossBarIdleTicks, progressions, EnumSet.of(GameMode.SURVIVAL),
10, 70, 20
);
}
public HarvestSettings(
int cropsPerTick,
int bossBarIdleTicks,
Map<CropType, HarvestProgression> progressions,
Set<GameMode> 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<CropType, HarvestProgression> defaultProgressions() {
Map<CropType, HarvestProgression> 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<GameMode> 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<CropType, HarvestProgression> paths = new EnumMap<>(CropType.class);
for (CropType crop : CropType.values()) {
String root = "crops." + crop.displayName();
validateCropDefinition(configuration, root, crop);
List<Long> requirements = configuration.getLongList(root + ".requirements");
List<Integer> 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<Long> 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<Integer> values) {
int[] result = new int[values.size()];
for (int index = 0; index < values.size(); index++) {
result[index] = values.get(index);
}
return result;
}
}
@@ -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<UUID, PlayerHarvestState> 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<PlayerHarvestState> find(String playerName) {
return states.values().stream()
.filter(state -> state.latestName().equalsIgnoreCase(playerName))
.findFirst();
}
public Optional<PlayerHarvestState> find(UUID playerId) {
return Optional.ofNullable(states.get(playerId));
}
public Collection<PlayerHarvestState> 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());
}
}
}
@@ -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<CropType, CropProgress> 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;
}
}
@@ -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;
}
}
@@ -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<UUID, PlayerHarvestState> load() {
Map<UUID, PlayerHarvestState> 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<PlayerHarvestState> 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);
}
}
}
}
+36
View File
@@ -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]
@@ -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());
}
}
@@ -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());
}
}
@@ -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()));
}
}
@@ -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<UUID, PlayerHarvestState> 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"));
}
}