diff --git a/README.md b/README.md index b39b5b2..301cac2 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ project produces its result item at the station. outputs, and the output's result is fixed when the project begins. - **Experiment-driven discovery** — items carry primary and secondary aspects that add points to the project, while unneeded aspects are eventually rejected. +- **Random aspect pools** — an output can list an `aspect_pool` and a `pick` count, + so each project draws that many required aspects from the pool when it begins. - **Hidden recipes** — required aspects sit among decoy rows in a shuffled station menu, and both their identity and the product are revealed as research progresses. - **Character focus** — experiments spend RPCharacters focus points, and a configurable diff --git a/src/main/java/net/tfminecraft/research/database/StationSaveData.java b/src/main/java/net/tfminecraft/research/database/StationSaveData.java index bfe8fa1..85b719c 100644 --- a/src/main/java/net/tfminecraft/research/database/StationSaveData.java +++ b/src/main/java/net/tfminecraft/research/database/StationSaveData.java @@ -23,6 +23,7 @@ public final class StationSaveData { private Map aspect_states = new HashMap<>(); private List tested_items = new ArrayList<>(); private List aspect_slot_order = new ArrayList<>(); + private List pool_aspects = new ArrayList<>(); public String getWorld() { return world; @@ -167,4 +168,12 @@ public List getAspectSlotOrder() { public void setAspectSlotOrder(List aspectSlotOrder) { this.aspect_slot_order = aspectSlotOrder != null ? aspectSlotOrder : new ArrayList<>(); } + + public List getPoolAspects() { + return pool_aspects; + } + + public void setPoolAspects(List poolAspects) { + this.pool_aspects = poolAspects != null ? poolAspects : new ArrayList<>(); + } } diff --git a/src/main/java/net/tfminecraft/research/database/StationStore.java b/src/main/java/net/tfminecraft/research/database/StationStore.java index f88f1bb..751c156 100644 --- a/src/main/java/net/tfminecraft/research/database/StationStore.java +++ b/src/main/java/net/tfminecraft/research/database/StationStore.java @@ -5,6 +5,7 @@ import java.io.IOException; import java.io.Reader; import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.UUID; @@ -17,6 +18,7 @@ import net.tfminecraft.research.loader.InputLoader; import net.tfminecraft.research.loader.OutputLoader; import net.tfminecraft.research.model.ActiveProject; +import net.tfminecraft.research.model.AspectState; import net.tfminecraft.research.model.OutputDef; import net.tfminecraft.research.model.ResearchStation; import net.tfminecraft.research.util.AspectSlotOrder; @@ -84,6 +86,7 @@ public static boolean saveStation(ResearchStation station) { data.setAspectStates(new java.util.HashMap<>(project.getAspectStates())); data.setTestedItems(new java.util.ArrayList<>(project.getTestedExperimentItems())); data.setAspectSlotOrder(new java.util.ArrayList<>(project.getAspectSlotOrder())); + data.setPoolAspects(new java.util.ArrayList<>(project.getPoolAspects())); try { DurableFiles.write(file.toPath(), GSON.toJson(data)); @@ -163,11 +166,32 @@ private static ResearchStation loadFile(File file) { } OutputDef outputDef = OutputLoader.getById(data.getResolvedOutputId()); String context = "station " + file.getName(); + List savedPool = data.getPoolAspects() != null ? data.getPoolAspects() : List.of(); + List rejected = new ArrayList<>(); + for (String aspectId : outputDef.getAspectPool().keySet()) { + if (project.getAspectState(aspectId) == AspectState.REJECTED) { + rejected.add(aspectId); + } + } + project.setPoolAspects(outputDef.reconcilePoolAspects(savedPool, rejected)); + for (String aspectId : project.getPoolAspects()) { + // A new draw the station rejected while it was off-recipe could never be confirmed. + if (!savedPool.contains(aspectId) && rejected.contains(aspectId)) { + project.getAspectStates().remove(aspectId); + project.getAspectPoints().remove(aspectId); + } + } + if (!project.getPoolAspects().equals(savedPool)) { + Research.plugin.getLogger().warning("[Research] Pool aspects for " + context + " changed from " + + savedPool + " to " + project.getPoolAspects() + " to match output '" + + outputDef.getId() + "'."); + } List savedOrder = data.getAspectSlotOrder(); + Collection recipe = outputDef.getRecipeAspects(project).keySet(); if (savedOrder == null || savedOrder.isEmpty()) { - project.setAspectSlotOrder(AspectSlotOrder.reconcileRowAssignments(outputDef, null, context)); + project.setAspectSlotOrder(AspectSlotOrder.reconcileRowAssignments(recipe, null, context)); } else { - project.setAspectSlotOrder(AspectSlotOrder.reconcileRowAssignments(outputDef, savedOrder, context)); + project.setAspectSlotOrder(AspectSlotOrder.reconcileRowAssignments(recipe, savedOrder, context)); } ResearchStation station = new ResearchStation(location, owner, project); station.setOwnerCharacterId(data.getOwnerCharacterId()); diff --git a/src/main/java/net/tfminecraft/research/loader/OutputLoader.java b/src/main/java/net/tfminecraft/research/loader/OutputLoader.java index a4ee99b..9515192 100644 --- a/src/main/java/net/tfminecraft/research/loader/OutputLoader.java +++ b/src/main/java/net/tfminecraft/research/loader/OutputLoader.java @@ -88,28 +88,65 @@ private boolean validate(OutputDef def, File configFile) { ok &= validateItemRef(def.getId(), "mystery_display", def.getMysteryDisplayItemRef(), configFile); ok &= validateItemRef(def.getId(), "product_display", def.getProductDisplayItemRef(), configFile); - if (def.getAspects().size() > GridLayout.ASPECT_ROW_COUNT) { + int recipeSize = def.getAspects().size() + def.getAspectPoolPick(); + if (recipeSize > GridLayout.ASPECT_ROW_COUNT) { Research.plugin.getLogger().severe("[Research] Output '" + def.getId() + "' has " - + def.getAspects().size() + " aspects but the station GUI supports " + + recipeSize + " aspects but the station GUI supports " + GridLayout.ASPECT_ROW_COUNT + " in " + configFile.getName()); ok = false; } - for (Map.Entry entry : def.getAspects().entrySet()) { + ok &= validateAspects(def.getId(), "", def.getAspects(), configFile); + ok &= validateAspectPool(def, configFile); + ok &= validateResult(def, configFile); + return ok; + } + + private boolean validateAspects(String outputId, String field, Map aspects, + File configFile) { + boolean ok = true; + for (Map.Entry entry : aspects.entrySet()) { String aspectId = entry.getKey(); if (AspectLoader.getById(aspectId) == null) { - Research.plugin.getLogger().severe("[Research] Output '" + def.getId() + "' references unknown aspect '" - + aspectId + "' in " + configFile.getName()); + Research.plugin.getLogger().severe("[Research] Output '" + outputId + "' references unknown " + + field + "aspect '" + aspectId + "' in " + configFile.getName()); ok = false; } if (entry.getValue().getRequiredPoints() <= 0) { - Research.plugin.getLogger().severe("[Research] Output '" + def.getId() + "' aspect '" + aspectId - + "' must have required_points > 0 in " + configFile.getName()); + Research.plugin.getLogger().severe("[Research] Output '" + outputId + "' " + field + "aspect '" + + aspectId + "' must have required_points > 0 in " + configFile.getName()); ok = false; } } + return ok; + } - ok &= validateResult(def, configFile); + private boolean validateAspectPool(OutputDef def, File configFile) { + int pick = def.getAspectPoolPick(); + Map pool = def.getAspectPool(); + if (pool.isEmpty()) { + if (pick != 0) { + Research.plugin.getLogger().severe("[Research] Output '" + def.getId() + + "' sets aspect_pool.pick but lists no aspect_pool.aspects in " + configFile.getName()); + return false; + } + return true; + } + + boolean ok = validateAspects(def.getId(), "pool ", pool, configFile); + if (pick < 1 || pick > pool.size()) { + Research.plugin.getLogger().severe("[Research] Output '" + def.getId() + "' aspect_pool.pick must be" + + " between 1 and " + pool.size() + " (the pool size), got " + pick + " in " + + configFile.getName()); + ok = false; + } + for (String aspectId : pool.keySet()) { + if (def.getAspects().containsKey(aspectId)) { + Research.plugin.getLogger().severe("[Research] Output '" + def.getId() + "' lists aspect '" + + aspectId + "' in both aspects and aspect_pool in " + configFile.getName()); + ok = false; + } + } return ok; } diff --git a/src/main/java/net/tfminecraft/research/manager/InventoryManager.java b/src/main/java/net/tfminecraft/research/manager/InventoryManager.java index 62f4071..800b66d 100644 --- a/src/main/java/net/tfminecraft/research/manager/InventoryManager.java +++ b/src/main/java/net/tfminecraft/research/manager/InventoryManager.java @@ -95,9 +95,10 @@ public void populateMain(Player player, ResearchStation station, Inventory inven inventory.setItem(GridLayout.SLOT_PRODUCT, productItem); } + Map recipe = output.getRecipeAspects(activeProject); List rowAssignments = activeProject.getAspectSlotOrder(); if (!activeProject.hasAspectSlotOrder()) { - rowAssignments = AspectSlotOrder.reconcileRowAssignments(output, null, + rowAssignments = AspectSlotOrder.reconcileRowAssignments(recipe.keySet(), null, "station " + station.getProject().getResolvedOutputId()); activeProject.setAspectSlotOrder(rowAssignments); } @@ -111,7 +112,7 @@ public void populateMain(Player player, ResearchStation station, Inventory inven continue; } - AspectRequirement requirement = output.getAspects().get(aspectId); + AspectRequirement requirement = recipe.get(aspectId); if (requirement == null) { inventory.setItem(GridLayout.getAspectLockSlot(row), buildAspectRowDivider()); renderInactiveProgressRow(inventory, row); diff --git a/src/main/java/net/tfminecraft/research/manager/ResearchManager.java b/src/main/java/net/tfminecraft/research/manager/ResearchManager.java index 309c21a..782a62a 100644 --- a/src/main/java/net/tfminecraft/research/manager/ResearchManager.java +++ b/src/main/java/net/tfminecraft/research/manager/ResearchManager.java @@ -457,7 +457,7 @@ private boolean applyExperimentAspect(Player player, ActiveProject active, Outpu if (active.getAspectState(aspectId) == AspectState.REJECTED) { return false; } - AspectRequirement requirement = outputDef.getAspects().get(aspectId); + AspectRequirement requirement = outputDef.getRecipeAspects(active).get(aspectId); if (requirement != null) { int required = requirement.getRequiredPoints(); int before = active.getAspectPoints(aspectId); @@ -518,7 +518,7 @@ private void reconcileAspectState(Player player, ActiveProject active, OutputDef active.setAspectState(aspectId, AspectState.TESTING); } - AspectRequirement requirement = outputDef.getAspects().get(aspectId); + AspectRequirement requirement = outputDef.getRecipeAspects(active).get(aspectId); int points = active.getAspectPoints(aspectId); int required = requirement.getRequiredPoints(); if (DiscoveryScaling.meetsConfirmThreshold(player, points, required)) { @@ -609,7 +609,7 @@ private void sendResultGrantedMessage(Player player, ItemStack item) { } private boolean isRecipeComplete(Player player, OutputDef outputDef, ActiveProject active) { - for (Map.Entry entry : outputDef.getAspects().entrySet()) { + for (Map.Entry entry : outputDef.getRecipeAspects(active).entrySet()) { String aspectId = entry.getKey(); if (active.getAspectState(aspectId) != AspectState.CONFIRMED) { return false; @@ -664,7 +664,8 @@ private ResearchStation startProject(Player player, Location location, Equipment } ActiveProject project = new ActiveProject(input.getId(), output.getId(), resolvedResultRef); - project.setAspectSlotOrder(AspectSlotOrder.buildRowAssignments(output)); + project.setPoolAspects(output.rollPoolAspects()); + project.setAspectSlotOrder(AspectSlotOrder.buildRowAssignments(output.getRecipeAspects(project).keySet())); ResearchStation station = new ResearchStation(blockLocation(location), player.getUniqueId(), project); station.setOwnerCharacterId(playerManager.getActiveCharacterId(player)); stations.add(station); diff --git a/src/main/java/net/tfminecraft/research/model/ActiveProject.java b/src/main/java/net/tfminecraft/research/model/ActiveProject.java index 93cc983..baacd5e 100644 --- a/src/main/java/net/tfminecraft/research/model/ActiveProject.java +++ b/src/main/java/net/tfminecraft/research/model/ActiveProject.java @@ -1,6 +1,7 @@ package net.tfminecraft.research.model; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -24,6 +25,7 @@ public final class ActiveProject { private final Map aspectTestCounts = new HashMap<>(); private final Set testedExperimentItems = new HashSet<>(); private final List aspectSlotOrder = new ArrayList<>(); + private final List poolAspects = new ArrayList<>(); public ActiveProject(String inputId, String resolvedOutputId, String resolvedResultRef) { this.inputId = inputId; @@ -111,6 +113,25 @@ public boolean hasAspectSlotOrder() { return aspectSlotOrder.size() == GridLayout.ASPECT_ROW_COUNT; } + /** + * Aspects this project drew from its output's {@code aspect_pool}, fixed when it started. + */ + public List getPoolAspects() { + return Collections.unmodifiableList(poolAspects); + } + + public void setPoolAspects(List aspectIds) { + poolAspects.clear(); + if (aspectIds == null) { + return; + } + for (String aspectId : aspectIds) { + if (aspectId != null && !aspectId.isBlank() && !poolAspects.contains(aspectId)) { + poolAspects.add(aspectId); + } + } + } + public AspectState getAspectState(String aspectId) { String raw = aspectStates.get(aspectId); if (raw == null || raw.isBlank()) { diff --git a/src/main/java/net/tfminecraft/research/model/OutputDef.java b/src/main/java/net/tfminecraft/research/model/OutputDef.java index 69c734a..cb6da6a 100644 --- a/src/main/java/net/tfminecraft/research/model/OutputDef.java +++ b/src/main/java/net/tfminecraft/research/model/OutputDef.java @@ -1,10 +1,13 @@ package net.tfminecraft.research.model; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; import org.bukkit.configuration.ConfigurationSection; @@ -16,6 +19,8 @@ public final class OutputDef { private final String productDisplayItemRef; private final int productRevealAfterConfirmedAspects; private final Map aspects; + private final Map aspectPool; + private final int aspectPoolPick; private final ResultSpec result; public OutputDef(String id, ConfigurationSection config) { @@ -33,13 +38,11 @@ public OutputDef(String id, ConfigurationSection config) { ? reveal.getInt("base_after_confirmed_aspects", 0) : 0; - this.aspects = new LinkedHashMap<>(); - ConfigurationSection aspectsSection = config.getConfigurationSection("aspects"); - if (aspectsSection != null) { - for (String aspectId : aspectsSection.getKeys(false)) { - aspects.put(aspectId, new AspectRequirement(aspectsSection.getConfigurationSection(aspectId))); - } - } + this.aspects = readAspects(config.getConfigurationSection("aspects")); + + ConfigurationSection pool = config.getConfigurationSection("aspect_pool"); + this.aspectPool = readAspects(pool != null ? pool.getConfigurationSection("aspects") : null); + this.aspectPoolPick = pool != null ? pool.getInt("pick", 0) : 0; ConfigurationSection resultSection = config.getConfigurationSection("result"); this.result = resultSection != null ? new ResultSpec(resultSection) : new ResultSpec(null); @@ -69,10 +72,87 @@ public Map getAspects() { return Collections.unmodifiableMap(aspects); } + public Map getAspectPool() { + return Collections.unmodifiableMap(aspectPool); + } + + public int getAspectPoolPick() { + return aspectPoolPick; + } + + /** + * Draws {@code aspect_pool.pick} distinct aspects from the pool for a new project. + */ + public List rollPoolAspects() { + List ids = new ArrayList<>(aspectPool.keySet()); + Collections.shuffle(ids, ThreadLocalRandom.current()); + return new ArrayList<>(ids.subList(0, Math.max(0, Math.min(aspectPoolPick, ids.size())))); + } + + /** + * Keeps the saved draws still in the pool (up to {@code pick}) and draws again to fill any gap, + * so a station survives pool edits without losing aspects it already researched. + */ + public List reconcilePoolAspects(List saved) { + return reconcilePoolAspects(saved, List.of()); + } + + /** + * Like {@link #reconcilePoolAspects(List)}, but refills from {@code avoid} only once the rest of the + * pool is used up (a station passes the aspects it already rejected). + */ + public List reconcilePoolAspects(List saved, Collection avoid) { + List kept = new ArrayList<>(); + if (saved != null) { + for (String aspectId : saved) { + if (kept.size() < aspectPoolPick && aspectPool.containsKey(aspectId) && !kept.contains(aspectId)) { + kept.add(aspectId); + } + } + } + List remaining = new ArrayList<>(aspectPool.keySet()); + remaining.removeAll(kept); + Collections.shuffle(remaining, ThreadLocalRandom.current()); + remaining.sort(Comparator.comparing(avoid::contains)); + for (String aspectId : remaining) { + if (kept.size() >= aspectPoolPick) { + break; + } + kept.add(aspectId); + } + return kept; + } + + /** + * The aspects a project must complete: every fixed aspect, then the pool aspects it drew. + */ + public Map getRecipeAspects(ActiveProject project) { + Map recipe = new LinkedHashMap<>(aspects); + if (project != null) { + for (String aspectId : project.getPoolAspects()) { + AspectRequirement requirement = aspectPool.get(aspectId); + if (requirement != null) { + recipe.putIfAbsent(aspectId, requirement); + } + } + } + return recipe; + } + public ResultSpec getResult() { return result; } + private static Map readAspects(ConfigurationSection section) { + Map read = new LinkedHashMap<>(); + if (section != null) { + for (String aspectId : section.getKeys(false)) { + read.put(aspectId, new AspectRequirement(section.getConfigurationSection(aspectId))); + } + } + return read; + } + public static final class ResultSpec { private final String itemRef; private final String templateRef; diff --git a/src/main/java/net/tfminecraft/research/util/AspectSlotOrder.java b/src/main/java/net/tfminecraft/research/util/AspectSlotOrder.java index 2edea9b..7757a0f 100644 --- a/src/main/java/net/tfminecraft/research/util/AspectSlotOrder.java +++ b/src/main/java/net/tfminecraft/research/util/AspectSlotOrder.java @@ -1,27 +1,30 @@ package net.tfminecraft.research.util; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.concurrent.ThreadLocalRandom; import net.tfminecraft.research.Research; -import net.tfminecraft.research.model.OutputDef; public final class AspectSlotOrder { private AspectSlotOrder() {} - public static List buildRowAssignments(OutputDef def) { + /** + * @param recipeAspects the project's required aspects, from {@code OutputDef#getRecipeAspects} + */ + public static List buildRowAssignments(Collection recipeAspects) { List rows = new ArrayList<>(); for (int i = 0; i < GridLayout.ASPECT_ROW_COUNT; i++) { rows.add(""); } - if (def == null || def.getAspects().isEmpty()) { + if (recipeAspects == null || recipeAspects.isEmpty()) { return rows; } - List aspectIds = new ArrayList<>(def.getAspects().keySet()); + List aspectIds = new ArrayList<>(recipeAspects); Collections.shuffle(aspectIds, ThreadLocalRandom.current()); List rowIndices = new ArrayList<>(); @@ -39,19 +42,20 @@ public static List buildRowAssignments(OutputDef def) { /** * Returns six row assignments (index 0 = top row). Empty string = decoy row. */ - public static List reconcileRowAssignments(OutputDef def, List saved, String context) { - if (def == null) { + public static List reconcileRowAssignments(Collection recipeAspects, List saved, + String context) { + if (recipeAspects == null) { return emptyRows(); } if (saved == null || saved.isEmpty()) { Research.plugin.getLogger().info("[Research] Assigned shuffled aspect rows for " + context + " (no saved order)."); - return buildRowAssignments(def); + return buildRowAssignments(recipeAspects); } - if (saved.size() != GridLayout.ASPECT_ROW_COUNT || !isValidRowAssignments(def, saved)) { + if (saved.size() != GridLayout.ASPECT_ROW_COUNT || !isValidRowAssignments(recipeAspects, saved)) { Research.plugin.getLogger().warning("[Research] Aspect row assignments for " + context + " were missing or stale; reshuffling."); - return buildRowAssignments(def); + return buildRowAssignments(recipeAspects); } return new ArrayList<>(saved); } @@ -68,19 +72,18 @@ public static boolean isDecoyRow(List rowAssignments, int rowIndex) { return aspectIdForRow(rowAssignments, rowIndex).isBlank(); } - private static boolean isValidRowAssignments(OutputDef def, List saved) { - List expected = new ArrayList<>(def.getAspects().keySet()); + private static boolean isValidRowAssignments(Collection recipeAspects, List saved) { List found = new ArrayList<>(); for (String entry : saved) { if (entry == null || entry.isBlank()) { continue; } - if (!def.getAspects().containsKey(entry) || found.contains(entry)) { + if (!recipeAspects.contains(entry) || found.contains(entry)) { return false; } found.add(entry); } - if (found.size() != expected.size()) { + if (found.size() != recipeAspects.size()) { return false; } return true; diff --git a/src/main/java/net/tfminecraft/research/util/RevealHelper.java b/src/main/java/net/tfminecraft/research/util/RevealHelper.java index ee75336..079a01d 100644 --- a/src/main/java/net/tfminecraft/research/util/RevealHelper.java +++ b/src/main/java/net/tfminecraft/research/util/RevealHelper.java @@ -10,7 +10,7 @@ private RevealHelper() {} public static int countConfirmedRecipeAspects(ActiveProject active, OutputDef def) { int count = 0; - for (String aspectId : def.getAspects().keySet()) { + for (String aspectId : def.getRecipeAspects(active).keySet()) { if (active.getAspectState(aspectId) == AspectState.CONFIRMED) { count++; } diff --git a/src/test/java/net/tfminecraft/research/database/ResearchPersistenceTest.java b/src/test/java/net/tfminecraft/research/database/ResearchPersistenceTest.java index e331769..dec213b 100644 --- a/src/test/java/net/tfminecraft/research/database/ResearchPersistenceTest.java +++ b/src/test/java/net/tfminecraft/research/database/ResearchPersistenceTest.java @@ -7,6 +7,7 @@ import net.tfminecraft.research.loader.OutputLoader; import net.tfminecraft.research.manager.StationMenuHolder; import net.tfminecraft.research.model.ActiveProject; +import net.tfminecraft.research.model.AspectState; import net.tfminecraft.research.model.InputDef; import net.tfminecraft.research.model.OutputDef; import net.tfminecraft.research.model.ResearchStation; @@ -24,12 +25,14 @@ import java.lang.reflect.Method; import java.nio.file.Files; import java.nio.file.Path; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.logging.Logger; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.contains; import static org.mockito.Mockito.*; @@ -151,6 +154,81 @@ void absentAndNullLegacyProgressGetsEmptyMapsAndACompleteRowAssignment() throws assertEquals(1, project.getAspectSlotOrder().stream().filter("fire"::equals).count()); } + private void pooledOutput() { + YamlConfiguration config = new YamlConfiguration(); + config.set("aspects.fire.required_points", 10); + config.set("aspect_pool.pick", 2); + for (String id : List.of("water", "earth", "air")) config.set("aspect_pool.aspects." + id + ".required_points", 4); + OutputLoader.get().put("output", new OutputDef("output", config)); + } + + @Test + void poolDrawsSurviveARestartWithTheirRows() throws Exception { + pooledOutput(); + ResearchStation original = station(); + original.getProject().setPoolAspects(List.of("air", "water")); + original.getProject().setAspectSlotOrder(List.of("", "air", "fire", "", "water", "")); + StationStore.saveStation(original); + ActiveProject loaded = StationStore.loadAll().getFirst().getProject(); + assertEquals(List.of("air", "water"), loaded.getPoolAspects()); + assertEquals(List.of("", "air", "fire", "", "water", ""), loaded.getAspectSlotOrder()); + verify(logger, never()).warning(anyString()); + verify(logger, never()).info(anyString()); + } + + @Test + void savesWithMissingOrStalePoolDrawsAreToppedUpFromThePool() throws Exception { + pooledOutput(); + StationSaveData stale = data(); + stale.setPoolAspects(List.of("removed", "earth")); + stale.setAspectSlotOrder(List.of("removed", "earth", "fire", "", "", "")); + write("stale.json", new Gson().toJson(stale)); + write("legacy.json", new Gson().toJson(data()).replace("\"pool_aspects\":[]", "\"pool_aspects\":null")); + for (ResearchStation station : StationStore.loadAll()) { + List drawn = station.getProject().getPoolAspects(); + assertEquals(2, drawn.size()); + assertTrue(List.of("water", "earth", "air").containsAll(drawn)); + assertEquals(3, station.getProject().getAspectSlotOrder().stream().filter(id -> !id.isBlank()).count()); + assertTrue(station.getProject().getAspectSlotOrder().containsAll(drawn)); + } + verify(logger).warning(contains("Pool aspects for station stale.json changed from [removed, earth]")); + verify(logger).warning(contains("Pool aspects for station legacy.json changed from []")); + verify(logger).warning(contains("Aspect row assignments for station stale.json were missing or stale")); + } + + @Test + void newDrawsPreferAspectsTheStationHasNotRejected() throws Exception { + pooledOutput(); + StationSaveData legacy = data(); + legacy.setAspectStates(new HashMap<>(Map.of("water", "REJECTED", "earth", "REJECTED", "fire", "TESTING"))); + legacy.setAspectPoints(new HashMap<>(Map.of("water", 5, "earth", 5))); + write("legacy.json", new Gson().toJson(legacy)); + ActiveProject project = StationStore.loadAll().getFirst().getProject(); + assertTrue(project.getPoolAspects().contains("air")); + String redrawn = project.getPoolAspects().get(project.getPoolAspects().indexOf("air") == 0 ? 1 : 0); + String other = redrawn.equals("water") ? "earth" : "water"; + assertEquals(AspectState.UNKNOWN, project.getAspectState(redrawn)); + assertEquals(0, project.getAspectPoints(redrawn)); + assertEquals(AspectState.REJECTED, project.getAspectState(other)); + assertEquals(5, project.getAspectPoints(other)); + assertEquals(AspectState.TESTING, project.getAspectState("fire")); + } + + @Test + void savedDrawsKeepTheirResearchState() throws Exception { + pooledOutput(); + StationSaveData saved = data(); + saved.setPoolAspects(List.of("water", "earth")); + saved.setAspectStates(new HashMap<>(Map.of("water", "CONFIRMED", "air", "REJECTED"))); + saved.setAspectPoints(new HashMap<>(Map.of("water", 4, "air", 5))); + write("saved.json", new Gson().toJson(saved)); + ActiveProject project = StationStore.loadAll().getFirst().getProject(); + assertEquals(List.of("water", "earth"), project.getPoolAspects()); + assertEquals(AspectState.CONFIRMED, project.getAspectState("water")); + assertEquals(4, project.getAspectPoints("water")); + assertEquals(AspectState.REJECTED, project.getAspectState("air")); + } + @Test void legacyInvalidMissingWorldAndUnknownDefinitionsAreSkipped() throws Exception { write("null.json", "null"); @@ -261,6 +339,8 @@ void saveDataNormalizesNullCollectionsAndPreservesLegacyOutputAlias() { data.setAspectStates(null); data.setTestedItems(null); data.setAspectSlotOrder(null); + data.setPoolAspects(null); + assertTrue(data.getPoolAspects().isEmpty()); assertTrue(data.getAspectPoints().isEmpty()); assertTrue(data.getAspectTestCounts().isEmpty()); assertTrue(data.getAspectStates().isEmpty()); diff --git a/src/test/java/net/tfminecraft/research/loader/ResearchLoadersTest.java b/src/test/java/net/tfminecraft/research/loader/ResearchLoadersTest.java index 3ef1087..ee4ee0a 100644 --- a/src/test/java/net/tfminecraft/research/loader/ResearchLoadersTest.java +++ b/src/test/java/net/tfminecraft/research/loader/ResearchLoadersTest.java @@ -222,6 +222,51 @@ void outputValidationChecksDisplaysAspectsAndExclusiveResultKinds() throws Excep assertNotNull(OutputLoader.getById("single")); } + @Test + void outputAspectPoolsLoadAndValidatePickSizeOverlapAndRows() throws Exception { + for (String id : List.of("fire", "water", "earth", "air")) AspectLoader.get().put(id, new AspectDef(id, config("{}"))); + File good = yaml("pool-good/a.yml", """ + pooled: + mystery_display: {item: v.paper} + product_display: {item: v.paper} + aspects: {fire: {required_points: 3}} + aspect_pool: + pick: 2 + aspects: + water: {required_points: 4} + earth: {required_points: 5} + air: {required_points: 6} + result: {item: v.paper} + """); + assertTrue(new OutputLoader().loadFolder(good.getParentFile())); + OutputDef pooled = OutputLoader.getById("pooled"); + assertEquals(2, pooled.getAspectPoolPick()); + assertEquals(List.of("water", "earth", "air"), List.copyOf(pooled.getAspectPool().keySet())); + assertEquals(5, pooled.getAspectPool().get("earth").getRequiredPoints()); + + File bad = yaml("pool-bad/a.yml", """ + pick-without-pool: {mystery_display: {item: v.paper}, product_display: {item: v.paper}, result: {item: v.paper}, aspect_pool: {pick: 1}} + pick-too-big: {mystery_display: {item: v.paper}, product_display: {item: v.paper}, result: {item: v.paper}, aspect_pool: {pick: 3, aspects: {water: {required_points: 1}, earth: {required_points: 1}}}} + pick-zero: {mystery_display: {item: v.paper}, product_display: {item: v.paper}, result: {item: v.paper}, aspect_pool: {aspects: {water: {required_points: 1}}}} + overlap: {mystery_display: {item: v.paper}, product_display: {item: v.paper}, result: {item: v.paper}, aspects: {fire: {required_points: 1}}, aspect_pool: {pick: 1, aspects: {fire: {required_points: 1}}}} + bad-pool-aspect: {mystery_display: {item: v.paper}, product_display: {item: v.paper}, result: {item: v.paper}, aspect_pool: {pick: 1, aspects: {ghost: {required_points: 0}}}} + too-many-rows: + mystery_display: {item: v.paper} + product_display: {item: v.paper} + result: {item: v.paper} + aspects: {fire: {required_points: 1}, water: {required_points: 1}, earth: {required_points: 1}, air: {required_points: 1}} + aspect_pool: {pick: 3, aspects: {a: {required_points: 1}, b: {required_points: 1}, c: {required_points: 1}}} + """); + assertFalse(new OutputLoader().loadFolder(bad.getParentFile())); + verify(logger).severe(contains("'pick-without-pool' sets aspect_pool.pick but lists no aspect_pool.aspects")); + verify(logger).severe(contains("'pick-too-big' aspect_pool.pick must be between 1 and 2 (the pool size), got 3")); + verify(logger).severe(contains("'pick-zero' aspect_pool.pick must be between 1 and 1 (the pool size), got 0")); + verify(logger).severe(contains("'overlap' lists aspect 'fire' in both aspects and aspect_pool")); + verify(logger).severe(contains("'bad-pool-aspect' references unknown pool aspect 'ghost'")); + verify(logger).severe(contains("'bad-pool-aspect' pool aspect 'ghost' must have required_points > 0")); + verify(logger).severe(contains("'too-many-rows' has 7 aspects but the station GUI supports 6")); + } + @Test void inputValidationChecksStartItemsWeightsTargetsAndDuplicateItems() throws Exception { OutputLoader.get().put("known", new OutputDef("known", config("{}"))); diff --git a/src/test/java/net/tfminecraft/research/manager/ResearchManagerTest.java b/src/test/java/net/tfminecraft/research/manager/ResearchManagerTest.java index b47b08f..46db82a 100644 --- a/src/test/java/net/tfminecraft/research/manager/ResearchManagerTest.java +++ b/src/test/java/net/tfminecraft/research/manager/ResearchManagerTest.java @@ -566,6 +566,51 @@ void startingConsumesOnlyTheOpeningHandAndSavesResolvedProject(boolean offHand) verify(menus).openMain(player, created); } + @Test + void startingDrawsPoolAspectsIntoTheProjectRecipe() { + noStations(); + ItemStack held = stack(Material.PAPER, 1); + when(bottom.getItemInMainHand()).thenReturn(held); + InputDef input = input("vanilla.PAPER", 1, "output"); + inputs.when(() -> InputMatcher.findByStartItem(held)).thenReturn(input); + items.when(() -> ItemRef.matches(held, "vanilla.PAPER")).thenReturn(true); + YamlConfiguration config = outputConfig(Map.of("fire", 8)); + config.set("aspect_pool.pick", 2); + for (String id : List.of("water", "earth", "air")) config.set("aspect_pool.aspects." + id + ".required_points", 4); + OutputLoader.get().put("output", new OutputDef("output", config)); + + manager.onStationInteract(interact(Action.RIGHT_CLICK_BLOCK, lectern, EquipmentSlot.HAND)); + + ActiveProject created = manager.getStationAt(location).getProject(); + List drawn = created.getPoolAspects(); + assertEquals(2, drawn.size()); + assertTrue(List.of("water", "earth", "air").containsAll(drawn)); + List rows = created.getAspectSlotOrder().stream().filter(id -> !id.isBlank()).toList(); + assertEquals(3, rows.size()); + assertTrue(rows.contains("fire")); + assertTrue(rows.containsAll(drawn)); + } + + @Test + void onlyDrawnPoolAspectsCountTowardTheRecipe() { + prepareExperiment("water", "earth", Map.of()); + YamlConfiguration config = outputConfig(Map.of()); + config.set("aspect_pool.pick", 1); + config.set("aspect_pool.aspects.water.required_points", 10); + config.set("aspect_pool.aspects.earth.required_points", 1); + OutputLoader.get().put("output", new OutputDef("output", config)); + project.setPoolAspects(List.of("water")); + + confirm(); + + assertEquals(AspectState.TESTING, project.getAspectState("water")); + assertEquals(2, project.getAspectPoints("water")); + assertEquals(AspectState.TESTING, project.getAspectState("earth"), + "an undrawn pool aspect is off-recipe, so 1 point does not confirm it"); + assertTrue(project.hasTestedItem("vanilla.COAL")); + assertSame(station, manager.getStationAt(location)); + } + @ParameterizedTest @ValueSource(strings = {"unknown-output", "disabled", "unresolved", "missing-item"}) void startFailuresDoNotCreateOrConsumeAProject(String failure) { diff --git a/src/test/java/net/tfminecraft/research/model/AspectPoolTest.java b/src/test/java/net/tfminecraft/research/model/AspectPoolTest.java new file mode 100644 index 0000000..f3ac151 --- /dev/null +++ b/src/test/java/net/tfminecraft/research/model/AspectPoolTest.java @@ -0,0 +1,98 @@ +package net.tfminecraft.research.model; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class AspectPoolTest { + private static final List POOL = List.of("earth", "nature", "fire", "ice", "water", "air"); + + private static OutputDef output(int pick) { + YamlConfiguration config = new YamlConfiguration(); + config.set("aspects.wealth.required_points", 3); + config.set("aspect_pool.pick", pick); + for (int i = 0; i < POOL.size(); i++) { + config.set("aspect_pool.aspects." + POOL.get(i) + ".required_points", i + 1); + } + return new OutputDef("output", config); + } + + @Test + void outputsWithoutAPoolDrawNothing() { + OutputDef plain = new OutputDef("plain", new YamlConfiguration()); + assertEquals(0, plain.getAspectPoolPick()); + assertTrue(plain.getAspectPool().isEmpty()); + assertEquals(List.of(), plain.rollPoolAspects()); + assertEquals(List.of(), plain.reconcilePoolAspects(List.of("fire"))); + assertThrows(UnsupportedOperationException.class, () -> plain.getAspectPool().clear()); + } + + @Test + void rollsDrawDistinctPoolAspectsAndEventuallyCoverThePool() { + OutputDef output = output(3); + Set seen = new HashSet<>(); + for (int roll = 0; roll < 200; roll++) { + List drawn = output.rollPoolAspects(); + assertEquals(3, drawn.size()); + assertEquals(3, new HashSet<>(drawn).size()); + assertTrue(POOL.containsAll(drawn)); + seen.addAll(drawn); + } + assertEquals(new HashSet<>(POOL), seen); + assertEquals(6, output(9).rollPoolAspects().size()); + assertEquals(List.of(), output(-1).rollPoolAspects()); + } + + @Test + void recipeIsTheFixedAspectsPlusTheProjectsDraws() { + OutputDef output = output(2); + ActiveProject project = new ActiveProject("input", "output", "v.paper"); + assertEquals(List.of("wealth"), List.copyOf(output.getRecipeAspects(null).keySet())); + project.setPoolAspects(List.of("ice", "removed")); + assertEquals(List.of("wealth", "ice"), List.copyOf(output.getRecipeAspects(project).keySet())); + assertEquals(4, output.getRecipeAspects(project).get("ice").getRequiredPoints()); + } + + @Test + void reconcileKeepsValidDrawsAndTopsUpTheRest() { + OutputDef output = output(3); + assertEquals(List.of("ice", "air", "fire"), output.reconcilePoolAspects(List.of("ice", "air", "fire"))); + assertEquals(List.of("ice", "air", "fire"), + output.reconcilePoolAspects(List.of("ice", "air", "fire", "earth"))); + List toppedUp = output.reconcilePoolAspects(Arrays.asList("ice", "ice", null, "gone")); + assertEquals(3, toppedUp.size()); + assertEquals("ice", toppedUp.getFirst()); + assertEquals(3, new HashSet<>(toppedUp).size()); + assertTrue(POOL.containsAll(toppedUp)); + assertEquals(3, output.reconcilePoolAspects(null).size()); + } + + @Test + void reconcileRefillsFromAvoidedAspectsOnlyWhenNothingElseIsLeft() { + for (int roll = 0; roll < 50; roll++) { + assertEquals(Set.of("ice", "air", "fire"), new HashSet<>( + output(3).reconcilePoolAspects(List.of("ice"), List.of("earth", "nature", "water")))); + List forced = output(3).reconcilePoolAspects(List.of("ice"), List.of("earth", "nature", "water", "fire")); + assertEquals("ice", forced.getFirst()); + assertTrue(forced.contains("air")); + assertEquals(3, new HashSet<>(forced).size()); + } + } + + @Test + void projectsKeepDistinctNonBlankDraws() { + ActiveProject project = new ActiveProject("input", "output", "v.paper"); + assertTrue(project.getPoolAspects().isEmpty()); + project.setPoolAspects(Arrays.asList("fire", null, " ", "fire", "ice")); + assertEquals(List.of("fire", "ice"), project.getPoolAspects()); + assertThrows(UnsupportedOperationException.class, () -> project.getPoolAspects().clear()); + project.setPoolAspects(null); + assertTrue(project.getPoolAspects().isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/research/util/ResearchUtilitiesTest.java b/src/test/java/net/tfminecraft/research/util/ResearchUtilitiesTest.java index 597d65c..ed3f231 100644 --- a/src/test/java/net/tfminecraft/research/util/ResearchUtilitiesTest.java +++ b/src/test/java/net/tfminecraft/research/util/ResearchUtilitiesTest.java @@ -141,20 +141,20 @@ void progressThresholdsDoNotOverflowForLargeConfiguredRequirements() { @Test void rowAssignmentsContainEveryAspectOnceAndKeepSixRows() { assertEquals(List.of("", "", "", "", "", ""), AspectSlotOrder.buildRowAssignments(null)); - assertEquals(List.of("", "", "", "", "", ""), AspectSlotOrder.buildRowAssignments(output())); - List rows = AspectSlotOrder.buildRowAssignments(output("fire", "water")); + assertEquals(List.of("", "", "", "", "", ""), AspectSlotOrder.buildRowAssignments(List.of())); + List rows = AspectSlotOrder.buildRowAssignments(List.of("fire", "water")); assertEquals(6, rows.size()); assertEquals(1, rows.stream().filter("fire"::equals).count()); assertEquals(1, rows.stream().filter("water"::equals).count()); assertEquals(4, rows.stream().filter(String::isEmpty).count()); - List truncated = AspectSlotOrder.buildRowAssignments(output("a", "b", "c", "d", "e", "f", "g")); + List truncated = AspectSlotOrder.buildRowAssignments(List.of("a", "b", "c", "d", "e", "f", "g")); assertEquals(6, truncated.size()); assertEquals(6, new HashSet<>(truncated).size()); } @Test void reconcilesAbsentStaleDuplicateAndUnknownSavedRows() { - OutputDef output = output("fire", "water"); + List output = List.of("fire", "water"); assertEquals(List.of("", "", "", "", "", ""), AspectSlotOrder.reconcileRowAssignments(null, null, "missing")); List> invalid = Arrays.asList(null, List.of(), List.of("fire"), @@ -173,7 +173,7 @@ void reconcilesAbsentStaleDuplicateAndUnknownSavedRows() { @Test void validSavedRowsAreCopiedAndNullsAreDecoys() { List saved = new ArrayList<>(Arrays.asList("fire", "", null, "water", " ", "")); - List result = AspectSlotOrder.reconcileRowAssignments(output("fire", "water"), saved, "station"); + List result = AspectSlotOrder.reconcileRowAssignments(List.of("fire", "water"), saved, "station"); assertEquals(saved, result); assertNotSame(saved, result); saved.set(0, "changed");