feat(progression): add persistent crop levels
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user