From 3e96e9f165c2371c5097e7a3c2c69e175af4827c Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:12:04 +0200 Subject: [PATCH 1/2] feat: draw output aspects from a random pool Outputs can list an aspect_pool with a pick count. Each new project draws that many distinct aspects from the pool and adds them to the output's fixed aspects, so the same research needs different aspects each time. Draws are saved with the station; on load, draws that left the pool are replaced from it. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 + .../research/database/StationSaveData.java | 9 ++ .../research/database/StationStore.java | 14 ++- .../research/loader/OutputLoader.java | 53 ++++++++++-- .../research/manager/InventoryManager.java | 5 +- .../research/manager/ResearchManager.java | 9 +- .../research/model/ActiveProject.java | 21 +++++ .../tfminecraft/research/model/OutputDef.java | 83 ++++++++++++++++-- .../research/util/AspectSlotOrder.java | 29 ++++--- .../research/util/RevealHelper.java | 2 +- .../database/ResearchPersistenceTest.java | 45 ++++++++++ .../research/loader/ResearchLoadersTest.java | 45 ++++++++++ .../research/manager/ResearchManagerTest.java | 45 ++++++++++ .../research/model/AspectPoolTest.java | 86 +++++++++++++++++++ .../research/util/ResearchUtilitiesTest.java | 10 +-- 15 files changed, 416 insertions(+), 42 deletions(-) create mode 100644 src/test/java/net/tfminecraft/research/model/AspectPoolTest.java 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..1882d8e 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; @@ -84,6 +85,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 +165,19 @@ 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(); + project.setPoolAspects(outputDef.reconcilePoolAspects(savedPool)); + 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..9793629 100644 --- a/src/main/java/net/tfminecraft/research/model/OutputDef.java +++ b/src/main/java/net/tfminecraft/research/model/OutputDef.java @@ -5,6 +5,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; import org.bukkit.configuration.ConfigurationSection; @@ -16,6 +17,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 +36,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 +70,78 @@ 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) { + 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()); + 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..95492c9 100644 --- a/src/test/java/net/tfminecraft/research/database/ResearchPersistenceTest.java +++ b/src/test/java/net/tfminecraft/research/database/ResearchPersistenceTest.java @@ -30,6 +30,7 @@ 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 +152,48 @@ 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 legacyInvalidMissingWorldAndUnknownDefinitionsAreSkipped() throws Exception { write("null.json", "null"); @@ -261,6 +304,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..5c93410 --- /dev/null +++ b/src/test/java/net/tfminecraft/research/model/AspectPoolTest.java @@ -0,0 +1,86 @@ +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 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"); From d393bdff5241dddc8289a9163ae446dea9367d91 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:53:43 +0200 Subject: [PATCH 2/2] fix: redraw pool aspects a station has not already rejected A station loaded before its output had a pool (or after a pool edit) gets new draws. Prefer aspects it has not rejected while they were off-recipe, and clear the state of any rejected aspect it still has to draw, so the project can always finish. Co-Authored-By: Claude Opus 5.5 --- .../research/database/StationStore.java | 16 ++++++++- .../tfminecraft/research/model/OutputDef.java | 11 ++++++ .../database/ResearchPersistenceTest.java | 35 +++++++++++++++++++ .../research/model/AspectPoolTest.java | 12 +++++++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/tfminecraft/research/database/StationStore.java b/src/main/java/net/tfminecraft/research/database/StationStore.java index 1882d8e..751c156 100644 --- a/src/main/java/net/tfminecraft/research/database/StationStore.java +++ b/src/main/java/net/tfminecraft/research/database/StationStore.java @@ -18,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; @@ -166,7 +167,20 @@ 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(); - project.setPoolAspects(outputDef.reconcilePoolAspects(savedPool)); + 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 '" diff --git a/src/main/java/net/tfminecraft/research/model/OutputDef.java b/src/main/java/net/tfminecraft/research/model/OutputDef.java index 9793629..cb6da6a 100644 --- a/src/main/java/net/tfminecraft/research/model/OutputDef.java +++ b/src/main/java/net/tfminecraft/research/model/OutputDef.java @@ -1,7 +1,9 @@ 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; @@ -92,6 +94,14 @@ public List rollPoolAspects() { * 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) { @@ -103,6 +113,7 @@ public List reconcilePoolAspects(List saved) { 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; diff --git a/src/test/java/net/tfminecraft/research/database/ResearchPersistenceTest.java b/src/test/java/net/tfminecraft/research/database/ResearchPersistenceTest.java index 95492c9..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,6 +25,7 @@ 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; @@ -194,6 +196,39 @@ void savesWithMissingOrStalePoolDrawsAreToppedUpFromThePool() throws Exception { 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"); diff --git a/src/test/java/net/tfminecraft/research/model/AspectPoolTest.java b/src/test/java/net/tfminecraft/research/model/AspectPoolTest.java index 5c93410..f3ac151 100644 --- a/src/test/java/net/tfminecraft/research/model/AspectPoolTest.java +++ b/src/test/java/net/tfminecraft/research/model/AspectPoolTest.java @@ -73,6 +73,18 @@ void reconcileKeepsValidDrawsAndTopsUpTheRest() { 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");