feat(heights): discover stature potion recipes
This commit is contained in:
1 parent
960e8891a8
commit
a6c80e7ac9
8 files changed
+1233
-21
No files matched your search
@@ -1,6 +1,10 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import io.papermc.paper.potion.PotionMix;
|
||||
import net.kyori.adventure.text.Component;
|
||||
@@ -13,6 +17,7 @@ import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.RecipeChoice;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.inventory.meta.PotionMeta;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.potion.PotionType;
|
||||
@@ -26,6 +31,8 @@ public final class PotionRecipes {
|
||||
private final Server server;
|
||||
private final NamespacedKey potionKindKey;
|
||||
private final Function<Material, ItemStack> items;
|
||||
private final List<NamespacedKey> ownedRecipeKeys = new ArrayList<>();
|
||||
private final Set<NamespacedKey> ownedMixKeys = new LinkedHashSet<>();
|
||||
|
||||
public PotionRecipes(JavaPlugin plugin) {
|
||||
this(plugin.getServer(), new NamespacedKey(plugin, "stature_potion"), ItemStack::new);
|
||||
@@ -41,12 +48,42 @@ public final class PotionRecipes {
|
||||
return new NamespacedKey(potionKindKey.getNamespace(), value);
|
||||
}
|
||||
|
||||
public void register() {
|
||||
registerShifting();
|
||||
registerGrowth();
|
||||
registerDiminution();
|
||||
registerRestoration();
|
||||
public boolean register() {
|
||||
boolean complete = registerCrafting();
|
||||
registerBrewing(server.getPotionBrewer());
|
||||
return complete;
|
||||
}
|
||||
|
||||
private boolean registerCrafting() {
|
||||
for (ShapedRecipe recipe : List.of(shiftingRecipe(), growthRecipe(), diminutionRecipe(), restorationRecipe())) {
|
||||
NamespacedKey recipeKey = recipe.getKey();
|
||||
if (server.getRecipe(recipeKey) != null) {
|
||||
rollbackCrafting();
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (!server.addRecipe(recipe)) {
|
||||
rollbackCrafting();
|
||||
return false;
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
try {
|
||||
if (server.getRecipe(recipeKey) != null) {
|
||||
ownedRecipeKeys.add(recipeKey);
|
||||
}
|
||||
} catch (RuntimeException inspectionFailure) {
|
||||
exception.addSuppressed(inspectionFailure);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
ownedRecipeKeys.add(recipeKey);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void rollbackCrafting() {
|
||||
ownedRecipeKeys.forEach(server::removeRecipe);
|
||||
ownedRecipeKeys.clear();
|
||||
}
|
||||
|
||||
public void registerBrewing(org.bukkit.potion.PotionBrewer brewer) {
|
||||
@@ -64,13 +101,22 @@ public final class PotionRecipes {
|
||||
PotionMix.createPredicateChoice(item -> item != null && item.getType() == source
|
||||
&& identify(item) == kind),
|
||||
PotionMix.createPredicateChoice(item -> item != null && item.getType() == ingredient)));
|
||||
ownedMixKeys.add(mixKey);
|
||||
}
|
||||
|
||||
public void unregister() {
|
||||
ownedRecipeKeys.forEach(server::removeRecipe);
|
||||
ownedRecipeKeys.clear();
|
||||
unregisterBrewing();
|
||||
}
|
||||
|
||||
public void unregisterBrewing() {
|
||||
for (StaturePotion kind : StaturePotion.values()) {
|
||||
server.getPotionBrewer().removePotionMix(mixKey(kind, Material.SPLASH_POTION));
|
||||
server.getPotionBrewer().removePotionMix(mixKey(kind, Material.LINGERING_POTION));
|
||||
if (ownedMixKeys.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
org.bukkit.potion.PotionBrewer brewer = server.getPotionBrewer();
|
||||
ownedMixKeys.forEach(brewer::removePotionMix);
|
||||
ownedMixKeys.clear();
|
||||
}
|
||||
|
||||
private NamespacedKey mixKey(StaturePotion kind, Material target) {
|
||||
@@ -123,26 +169,36 @@ public final class PotionRecipes {
|
||||
return key("shifting_stature");
|
||||
}
|
||||
|
||||
private void registerShifting() {
|
||||
public List<NamespacedKey> recipeKeys() {
|
||||
return List.of(shiftingKey(), key("growth"), key("diminution"), key("restoration"));
|
||||
}
|
||||
|
||||
private ShapedRecipe shiftingRecipe() {
|
||||
ShapedRecipe recipe = new ShapedRecipe(shiftingKey(), create(StaturePotion.SHIFTING));
|
||||
recipe.shape(SHIFTING_SHAPE);
|
||||
recipe.setIngredient('A', Material.AMETHYST_SHARD);
|
||||
recipe.setIngredient('C', Material.CHORUS_FRUIT);
|
||||
recipe.setIngredient('W', Material.POTION);
|
||||
server.addRecipe(recipe);
|
||||
ItemStack awkward = items.apply(Material.POTION);
|
||||
PotionMeta awkwardMeta = (PotionMeta) awkward.getItemMeta();
|
||||
awkwardMeta.setBasePotionType(PotionType.AWKWARD);
|
||||
awkward.setItemMeta(awkwardMeta);
|
||||
recipe.setIngredient('W', new RecipeChoice.ExactChoice(awkward));
|
||||
recipe.setCategory(CraftingBookCategory.MISC);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private void registerGrowth() {
|
||||
private ShapedRecipe growthRecipe() {
|
||||
ShapedRecipe recipe = new ShapedRecipe(key("growth"), create(StaturePotion.GROWTH));
|
||||
recipe.shape(GROWTH_SHAPE);
|
||||
recipe.setIngredient('G', Material.GOLD_INGOT);
|
||||
recipe.setIngredient('A', Material.AMETHYST_SHARD);
|
||||
recipe.setIngredient('S', new RecipeChoice.ExactChoice(create(StaturePotion.SHIFTING)));
|
||||
recipe.setIngredient('R', Material.RABBIT_FOOT);
|
||||
server.addRecipe(recipe);
|
||||
recipe.setCategory(CraftingBookCategory.MISC);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private void registerDiminution() {
|
||||
private ShapedRecipe diminutionRecipe() {
|
||||
ShapedRecipe recipe = new ShapedRecipe(key("diminution"),
|
||||
create(StaturePotion.DIMINUTION));
|
||||
recipe.shape(DIMINUTION_SHAPE);
|
||||
@@ -150,10 +206,11 @@ public final class PotionRecipes {
|
||||
recipe.setIngredient('A', Material.AMETHYST_SHARD);
|
||||
recipe.setIngredient('S', new RecipeChoice.ExactChoice(create(StaturePotion.SHIFTING)));
|
||||
recipe.setIngredient('F', Material.FERMENTED_SPIDER_EYE);
|
||||
server.addRecipe(recipe);
|
||||
recipe.setCategory(CraftingBookCategory.MISC);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private void registerRestoration() {
|
||||
private ShapedRecipe restorationRecipe() {
|
||||
ShapedRecipe recipe = new ShapedRecipe(key("restoration"),
|
||||
create(StaturePotion.RESTORATION));
|
||||
recipe.shape(RESTORATION_SHAPE);
|
||||
@@ -161,7 +218,8 @@ public final class PotionRecipes {
|
||||
recipe.setIngredient('A', Material.AMETHYST_SHARD);
|
||||
recipe.setIngredient('S', new RecipeChoice.ExactChoice(create(StaturePotion.SHIFTING)));
|
||||
recipe.setIngredient('U', Material.SUGAR);
|
||||
server.addRecipe(recipe);
|
||||
recipe.setCategory(CraftingBookCategory.MISC);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private static Color color(StaturePotion kind) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
|
||||
public final class RecipeDiscovery implements Listener {
|
||||
private final List<NamespacedKey> recipeKeys;
|
||||
private final Logger logger;
|
||||
|
||||
public RecipeDiscovery(List<NamespacedKey> recipeKeys, Logger logger) {
|
||||
this.recipeKeys = List.copyOf(recipeKeys);
|
||||
this.logger = Objects.requireNonNull(logger, "logger");
|
||||
}
|
||||
|
||||
public void discoverAll(Iterable<? extends Player> players) {
|
||||
players.forEach(this::discover);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
discover(event.getPlayer());
|
||||
}
|
||||
|
||||
public void discover(Player player) {
|
||||
try {
|
||||
List<NamespacedKey> unknown = recipeKeys.stream()
|
||||
.filter(key -> !player.hasDiscoveredRecipe(key))
|
||||
.toList();
|
||||
if (unknown.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
player.discoverRecipes(unknown);
|
||||
List<NamespacedKey> stillUnknown = unknown.stream()
|
||||
.filter(key -> !player.hasDiscoveredRecipe(key))
|
||||
.toList();
|
||||
if (!stillUnknown.isEmpty()) {
|
||||
logger.warning("Could not discover stature recipes for " + player.getName() + ": " + stillUnknown);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
logger.log(Level.WARNING, "Could not discover stature recipes for " + player.getName(), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
public final class RecipeLifecycle {
|
||||
private final Server server;
|
||||
private final Plugin plugin;
|
||||
private final PotionRecipes recipes;
|
||||
private final Logger logger;
|
||||
private RecipeDiscovery discovery;
|
||||
|
||||
public RecipeLifecycle(Server server, Plugin plugin, PotionRecipes recipes, Logger logger) {
|
||||
this.server = Objects.requireNonNull(server, "server");
|
||||
this.plugin = Objects.requireNonNull(plugin, "plugin");
|
||||
this.recipes = Objects.requireNonNull(recipes, "recipes");
|
||||
this.logger = Objects.requireNonNull(logger, "logger");
|
||||
}
|
||||
|
||||
public boolean enable() {
|
||||
if (discovery != null) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (!recipes.register()) {
|
||||
logger.severe("Stature recipes were not registered as a complete group; discovery is disabled.");
|
||||
return false;
|
||||
}
|
||||
List<? extends Player> onlinePlayers = List.copyOf(server.getOnlinePlayers());
|
||||
discovery = new RecipeDiscovery(recipes.recipeKeys(), logger);
|
||||
server.getPluginManager().registerEvents(discovery, plugin);
|
||||
discovery.discoverAll(onlinePlayers);
|
||||
return true;
|
||||
} catch (RuntimeException exception) {
|
||||
unregisterDiscovery();
|
||||
try {
|
||||
recipes.unregister();
|
||||
} catch (RuntimeException cleanupFailure) {
|
||||
exception.addSuppressed(cleanupFailure);
|
||||
}
|
||||
logger.log(Level.SEVERE,
|
||||
"Stature recipes were not registered as a complete group; discovery is disabled.", exception);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void disable() {
|
||||
unregisterDiscovery();
|
||||
recipes.unregister();
|
||||
}
|
||||
|
||||
private void unregisterDiscovery() {
|
||||
if (discovery != null) {
|
||||
HandlerList.unregisterAll(discovery);
|
||||
discovery = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import org.bukkit.plugin.java.JavaPlugin;
|
||||
public final class SpigotHeightsPlugin extends JavaPlugin {
|
||||
private BukkitStatureService stature;
|
||||
private PotionRecipes potions;
|
||||
private RecipeLifecycle recipeLifecycle;
|
||||
private BukkitCarry carry;
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -42,7 +43,8 @@ public final class SpigotHeightsPlugin extends JavaPlugin {
|
||||
command.setTabCompleter(executor);
|
||||
|
||||
potions = new PotionRecipes(this);
|
||||
potions.register();
|
||||
recipeLifecycle = new RecipeLifecycle(getServer(), this, potions, getLogger());
|
||||
recipeLifecycle.enable();
|
||||
getServer().getPluginManager().registerEvents(new StatureBrewing(potions), this);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new StatureListener(potions, stature::drink, stature::resume,
|
||||
@@ -67,8 +69,8 @@ public final class SpigotHeightsPlugin extends JavaPlugin {
|
||||
if (stature != null) {
|
||||
stature.close();
|
||||
}
|
||||
if (potions != null) {
|
||||
potions.unregisterBrewing();
|
||||
if (recipeLifecycle != null) {
|
||||
recipeLifecycle.disable();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,864 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.reset;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import io.papermc.paper.plugin.manager.PaperPluginManagerImpl;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.LogRecord;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.command.SimpleCommandMap;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.bukkit.craftbukkit.inventory.CraftRecipe;
|
||||
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
|
||||
import org.bukkit.Keyed;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.inventory.RecipeChoice;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.bukkit.potion.PotionBrewer;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Pinned Purpur book, codec, placement and result-slot behavior with server/player boundaries doubled.
|
||||
* No listening server, client, CraftServer registry or CraftPlayer discovery bridge is started here. */
|
||||
class NativeRecipeBookTest {
|
||||
private static Server previousServer;
|
||||
private static Server serverUnderTest;
|
||||
|
||||
@BeforeAll
|
||||
static void bootstrap() throws Exception {
|
||||
NativeRuntime.bootstrap();
|
||||
previousServer = Bukkit.getServer();
|
||||
serverUnderTest = mock(Server.class);
|
||||
setServer(serverUnderTest);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void configureServer() {
|
||||
reset(serverUnderTest);
|
||||
when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance());
|
||||
when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
|
||||
when(serverUnderTest.isPrimaryThread()).thenReturn(true);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void restoreServer() throws Exception {
|
||||
setServer(previousServer);
|
||||
}
|
||||
|
||||
private static void setServer(Server server) throws Exception {
|
||||
var field = Bukkit.class.getDeclaredField("server");
|
||||
field.setAccessible(true);
|
||||
field.set(null, server);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeRecipeBookAwardsKnownHighlightsAndNotificationOnlyOnce() {
|
||||
Server server = Bukkit.getServer();
|
||||
List<Recipe> registered = new ArrayList<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
List<org.bukkit.event.Event> events = new ArrayList<>();
|
||||
PluginManager pluginManager = mock(PluginManager.class);
|
||||
doAnswer(invocation -> {
|
||||
events.add(invocation.getArgument(0));
|
||||
return null;
|
||||
}).when(pluginManager).callEvent(any());
|
||||
when(server.getPluginManager()).thenReturn(pluginManager);
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
assertTrue(recipes.register());
|
||||
|
||||
java.util.Map<net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>>,
|
||||
net.minecraft.world.item.crafting.ShapedRecipe> nativeRecipes = new HashMap<>();
|
||||
List<net.minecraft.world.item.crafting.RecipeHolder<?>> holders = new ArrayList<>();
|
||||
for (Recipe recipe : registered) {
|
||||
ShapedRecipe bukkit = (ShapedRecipe) recipe;
|
||||
var key = nativeRecipeKey(bukkit.getKey());
|
||||
var nativeRecipe = nativeRecipe(bukkit);
|
||||
nativeRecipes.put(key, nativeRecipe);
|
||||
holders.add(new net.minecraft.world.item.crafting.RecipeHolder<>(key, nativeRecipe));
|
||||
}
|
||||
AtomicInteger displayId = new AtomicInteger();
|
||||
java.util.Map<net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>>,
|
||||
Integer> displayIds = new HashMap<>();
|
||||
nativeRecipes.keySet().forEach(key -> displayIds.put(key, displayId.getAndIncrement()));
|
||||
TestServerRecipeBook book = new TestServerRecipeBook((key, consumer) -> {
|
||||
net.minecraft.world.item.crafting.ShapedRecipe recipe = nativeRecipes.get(key);
|
||||
consumer.accept(new net.minecraft.world.item.crafting.display.RecipeDisplayEntry(
|
||||
new net.minecraft.world.item.crafting.display.RecipeDisplayId(displayIds.get(key)),
|
||||
recipe.display().getFirst(), java.util.OptionalInt.empty(), recipe.recipeBookCategory(),
|
||||
java.util.Optional.of(recipe.placementInfo().ingredients())));
|
||||
});
|
||||
var nativePlayer = mock(net.minecraft.server.level.ServerPlayer.class);
|
||||
when(nativePlayer.getBukkitEntity()).thenReturn(
|
||||
mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
|
||||
when(nativePlayer.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class));
|
||||
|
||||
assertEquals(4, book.addRecipes(holders, nativePlayer));
|
||||
assertEquals(0, book.addRecipes(holders, nativePlayer));
|
||||
|
||||
assertEquals(new LinkedHashSet<>(nativeRecipes.keySet()), book.known);
|
||||
assertEquals(new LinkedHashSet<>(nativeRecipes.keySet()),
|
||||
new LinkedHashSet<>(book.pack().highlight()));
|
||||
assertEquals(4, events.size());
|
||||
assertTrue(events.stream().allMatch(org.bukkit.event.player.PlayerRecipeDiscoverEvent.class::isInstance));
|
||||
assertTrue(events.stream().map(org.bukkit.event.player.PlayerRecipeDiscoverEvent.class::cast)
|
||||
.allMatch(org.bukkit.event.player.PlayerRecipeDiscoverEvent::shouldShowNotification));
|
||||
|
||||
net.minecraft.nbt.Tag serialized = net.minecraft.stats.ServerRecipeBook.Packed.CODEC
|
||||
.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, book.pack()).getOrThrow();
|
||||
net.minecraft.stats.ServerRecipeBook.Packed decoded = net.minecraft.stats.ServerRecipeBook.Packed.CODEC
|
||||
.parse(net.minecraft.nbt.NbtOps.INSTANCE, serialized).getOrThrow();
|
||||
TestServerRecipeBook restored = new TestServerRecipeBook();
|
||||
restored.loadUntrusted(decoded, nativeRecipes::containsKey);
|
||||
assertEquals(book.known, restored.known);
|
||||
assertEquals(new LinkedHashSet<>(book.pack().highlight()),
|
||||
new LinkedHashSet<>(restored.pack().highlight()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeRecipeBookSerializationPreservesKnownHighlightsAndVanillaStateAcrossReenable() {
|
||||
List<net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>>> statureKeys =
|
||||
List.of("shifting_stature", "growth", "diminution", "restoration").stream()
|
||||
.map(value -> nativeRecipeKey(new NamespacedKey("spigotheights", value)))
|
||||
.toList();
|
||||
var vanillaKey = nativeRecipeKey(new NamespacedKey("minecraft", "stick"));
|
||||
TestServerRecipeBook original = new TestServerRecipeBook();
|
||||
original.setBookSetting(net.minecraft.world.inventory.RecipeBookType.CRAFTING, true, true);
|
||||
original.add(vanillaKey);
|
||||
original.highlight(vanillaKey);
|
||||
statureKeys.forEach(key -> {
|
||||
original.add(key);
|
||||
original.highlight(key);
|
||||
});
|
||||
|
||||
net.minecraft.nbt.Tag serialized = net.minecraft.stats.ServerRecipeBook.Packed.CODEC
|
||||
.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, original.pack()).getOrThrow();
|
||||
net.minecraft.stats.ServerRecipeBook.Packed decoded = net.minecraft.stats.ServerRecipeBook.Packed.CODEC
|
||||
.parse(net.minecraft.nbt.NbtOps.INSTANCE, serialized).getOrThrow();
|
||||
TestServerRecipeBook restored = new TestServerRecipeBook();
|
||||
restored.loadUntrusted(decoded, key -> true);
|
||||
|
||||
assertTrue(restored.contains(vanillaKey));
|
||||
statureKeys.forEach(key -> assertTrue(restored.contains(key)));
|
||||
assertEquals(new LinkedHashSet<>(decoded.known()), restored.known);
|
||||
assertEquals(new LinkedHashSet<>(decoded.highlight()), new LinkedHashSet<>(restored.pack().highlight()));
|
||||
assertTrue(restored.isOpen(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
|
||||
assertTrue(restored.isFiltering(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
|
||||
|
||||
int knownBeforeReenable = restored.known.size();
|
||||
int highlightsBeforeReenable = restored.pack().highlight().size();
|
||||
statureKeys.forEach(restored::add);
|
||||
assertEquals(knownBeforeReenable, restored.known.size());
|
||||
assertEquals(highlightsBeforeReenable, restored.pack().highlight().size(),
|
||||
"Re-enable must not manufacture another first-time highlight");
|
||||
assertTrue(restored.contains(vanillaKey));
|
||||
}
|
||||
|
||||
private static final class TestServerRecipeBook extends net.minecraft.stats.ServerRecipeBook {
|
||||
TestServerRecipeBook() {
|
||||
this((key, consumer) -> { });
|
||||
}
|
||||
|
||||
TestServerRecipeBook(net.minecraft.stats.ServerRecipeBook.DisplayResolver resolver) {
|
||||
super(resolver);
|
||||
}
|
||||
|
||||
void highlight(net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> key) {
|
||||
highlight.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableAndReenableReRegistersRecipesWithoutForgettingPlayerKnowledge() {
|
||||
Server server = Bukkit.getServer();
|
||||
Set<NamespacedKey> registry = new LinkedHashSet<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation ->
|
||||
registry.add(((Keyed) invocation.getArgument(0)).getKey()));
|
||||
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation ->
|
||||
registry.remove(invocation.getArgument(0)));
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
when(server.getPluginManager()).thenReturn(mock(PluginManager.class));
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
Player player = discoveryPlayer("Persistent", known);
|
||||
doReturn(List.of(player)).when(server).getOnlinePlayers();
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
Logger logger = Logger.getAnonymousLogger();
|
||||
PotionRecipes firstRecipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle first = new RecipeLifecycle(server, plugin, firstRecipes, logger);
|
||||
|
||||
assertTrue(first.enable());
|
||||
first.disable();
|
||||
assertTrue(registry.isEmpty());
|
||||
assertEquals(new LinkedHashSet<>(firstRecipes.recipeKeys()), known);
|
||||
|
||||
PotionRecipes secondRecipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
assertTrue(new RecipeLifecycle(server, plugin, secondRecipes, logger).enable());
|
||||
assertEquals(new LinkedHashSet<>(secondRecipes.recipeKeys()), registry);
|
||||
verify(player, times(1)).discoverRecipes(any());
|
||||
verify(player, never()).undiscoverRecipes(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlineEnumerationFailureDoesNotLeaveDiscoveryListenerActiveAfterCleanup() {
|
||||
Server server = Bukkit.getServer();
|
||||
when(server.addRecipe(any(Recipe.class))).thenReturn(true);
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
PluginManager events = new PaperPluginManagerImpl(server,
|
||||
new SimpleCommandMap(server, new HashMap<>()), null);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
when(server.getOnlinePlayers()).thenThrow(new IllegalStateException("simulated lifecycle failure"));
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getName()).thenReturn("SpigotHeights");
|
||||
when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
PluginDescriptionFile description = new PluginDescriptionFile("SpigotHeights", "test", "unused.Main");
|
||||
when(plugin.getDescription()).thenReturn(description);
|
||||
when(plugin.getPluginMeta()).thenReturn(description);
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
|
||||
Player joining = discoveryPlayer("Joining", joiningKnown);
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, plugin.getLogger());
|
||||
|
||||
assertFalse(lifecycle.enable());
|
||||
events.callEvent(new PlayerJoinEvent(joining, "joined"));
|
||||
|
||||
assertTrue(joiningKnown.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listenerRegistrationExceptionRemovesPartiallyRegisteredDiscoveryListener() {
|
||||
Server server = Bukkit.getServer();
|
||||
when(server.addRecipe(any(Recipe.class))).thenReturn(true);
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
PluginManager registeredEvents = new PaperPluginManagerImpl(server,
|
||||
new SimpleCommandMap(server, new HashMap<>()), null);
|
||||
PluginManager throwingEvents = mock(PluginManager.class);
|
||||
doAnswer(invocation -> {
|
||||
registeredEvents.registerEvents(invocation.getArgument(0), invocation.getArgument(1));
|
||||
throw new IllegalStateException("simulated post-registration failure");
|
||||
}).when(throwingEvents).registerEvents(any(), any());
|
||||
when(server.getPluginManager()).thenReturn(throwingEvents);
|
||||
doReturn(List.of()).when(server).getOnlinePlayers();
|
||||
Plugin plugin = enabledPlugin("ListenerFailure");
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
|
||||
Player joining = discoveryPlayer("Joining", joiningKnown);
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, plugin.getLogger());
|
||||
|
||||
assertFalse(lifecycle.enable());
|
||||
registeredEvents.callEvent(new PlayerJoinEvent(joining, "joined"));
|
||||
|
||||
assertTrue(joiningKnown.isEmpty(), "A failed lifecycle must not leave its listener active");
|
||||
verify(server, times(4)).removeRecipe(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void lifecycleRegistrationExceptionCleansOwnedRecipesLogsAndAwardsNobody() {
|
||||
Server server = Bukkit.getServer();
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
List<NamespacedKey> removed = new ArrayList<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
if (attempts.getAndIncrement() == 1) {
|
||||
throw new IllegalStateException("simulated registry failure");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> {
|
||||
removed.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
PotionBrewer brewer = mock(PotionBrewer.class);
|
||||
when(server.getPotionBrewer()).thenReturn(brewer);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
doReturn(List.of(discoveryPlayer("Online", known))).when(server).getOnlinePlayers();
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
Logger logger = Logger.getAnonymousLogger();
|
||||
logger.setUseParentHandlers(false);
|
||||
List<String> logs = new ArrayList<>();
|
||||
logger.addHandler(new Handler() {
|
||||
@Override public void publish(LogRecord record) { logs.add(record.getMessage()); }
|
||||
@Override public void flush() {}
|
||||
@Override public void close() {}
|
||||
});
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, logger);
|
||||
|
||||
boolean enabled = assertDoesNotThrow(lifecycle::enable);
|
||||
|
||||
assertFalse(enabled);
|
||||
assertEquals(List.of(recipes.shiftingKey()), removed);
|
||||
assertTrue(known.isEmpty());
|
||||
assertEquals(1, logs.size());
|
||||
verifyNoInteractions(events);
|
||||
verifyNoInteractions(brewer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupFailureIsLoggedWithoutEscapingOrActivatingDiscovery() {
|
||||
Server server = Bukkit.getServer();
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
if (attempts.incrementAndGet() == 2) {
|
||||
throw new IllegalStateException("simulated registration failure");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
when(server.removeRecipe(any(NamespacedKey.class)))
|
||||
.thenThrow(new IllegalStateException("simulated cleanup failure"));
|
||||
PotionBrewer brewer = mock(PotionBrewer.class);
|
||||
when(server.getPotionBrewer()).thenReturn(brewer);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
Logger logger = Logger.getAnonymousLogger();
|
||||
logger.setUseParentHandlers(false);
|
||||
List<LogRecord> logs = new ArrayList<>();
|
||||
logger.addHandler(new Handler() {
|
||||
@Override public void publish(LogRecord record) { logs.add(record); }
|
||||
@Override public void flush() {}
|
||||
@Override public void close() {}
|
||||
});
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(server, mock(Plugin.class), recipes, logger);
|
||||
|
||||
boolean enabled = assertDoesNotThrow(lifecycle::enable);
|
||||
|
||||
assertFalse(enabled);
|
||||
assertEquals(1, logs.size());
|
||||
assertEquals(1, logs.getFirst().getThrown().getSuppressed().length);
|
||||
verifyNoInteractions(events, brewer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void postInsertionRegistrationFailureRemovesEveryRecipeOwnedByThisLifecycle() {
|
||||
Server server = Bukkit.getServer();
|
||||
NamespacedKey unrelatedKey = new NamespacedKey("minecraft", "stick");
|
||||
java.util.Map<NamespacedKey, Recipe> registry = new HashMap<>();
|
||||
registry.put(unrelatedKey, mock(Recipe.class));
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
when(server.getRecipe(any(NamespacedKey.class))).thenAnswer(invocation ->
|
||||
registry.get(invocation.getArgument(0)));
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
Recipe recipe = invocation.getArgument(0);
|
||||
registry.put(((Keyed) recipe).getKey(), recipe);
|
||||
if (attempts.incrementAndGet() == 2) {
|
||||
throw new IllegalStateException("simulated failure after native insertion");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation ->
|
||||
registry.remove(invocation.getArgument(0)) != null);
|
||||
PotionBrewer brewer = mock(PotionBrewer.class);
|
||||
when(server.getPotionBrewer()).thenReturn(brewer);
|
||||
when(server.getPluginManager()).thenReturn(mock(PluginManager.class));
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
Logger logger = Logger.getAnonymousLogger();
|
||||
logger.setUseParentHandlers(false);
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, logger);
|
||||
|
||||
assertFalse(lifecycle.enable());
|
||||
|
||||
assertEquals(Set.of(unrelatedKey), registry.keySet());
|
||||
verifyNoInteractions(brewer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableUnregistersTheCurrentLifecycleDiscoveryListener() {
|
||||
Server server = Bukkit.getServer();
|
||||
PluginManager events = new PaperPluginManagerImpl(server,
|
||||
new SimpleCommandMap(server, new HashMap<>()), null);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
when(server.addRecipe(any(Recipe.class))).thenReturn(true);
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
doReturn(List.of()).when(server).getOnlinePlayers();
|
||||
Plugin plugin = enabledPlugin("DisableListener");
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
|
||||
Player joining = discoveryPlayer("Joining", joiningKnown);
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, plugin.getLogger());
|
||||
|
||||
assertTrue(lifecycle.enable());
|
||||
lifecycle.disable();
|
||||
events.callEvent(new PlayerJoinEvent(joining, "joined"));
|
||||
|
||||
assertTrue(joiningKnown.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulLifecycleDiscoversForOnlineAndLaterJoiningPlayersThroughRegisteredListener() {
|
||||
Server server = Bukkit.getServer();
|
||||
PluginManager events = new PaperPluginManagerImpl(server,
|
||||
new SimpleCommandMap(server, new HashMap<>()), null);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
when(server.addRecipe(any(Recipe.class))).thenReturn(true);
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getName()).thenReturn("SpigotHeights");
|
||||
when(plugin.getLogger()).thenReturn(Logger.getLogger("NativeRecipeBookTest"));
|
||||
PluginDescriptionFile description = new PluginDescriptionFile("SpigotHeights", "test", "unused.Main");
|
||||
when(plugin.getDescription()).thenReturn(description);
|
||||
when(plugin.getPluginMeta()).thenReturn(description);
|
||||
Set<NamespacedKey> onlineKnown = new LinkedHashSet<>();
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
|
||||
Player online = discoveryPlayer("Online", onlineKnown);
|
||||
Player joining = discoveryPlayer("Joining", joiningKnown);
|
||||
doReturn(List.of(online)).when(server).getOnlinePlayers();
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, plugin.getLogger());
|
||||
|
||||
assertTrue(lifecycle.enable());
|
||||
events.callEvent(new PlayerJoinEvent(joining, "joined"));
|
||||
|
||||
assertEquals(new LinkedHashSet<>(recipes.recipeKeys()), onlineKnown);
|
||||
assertEquals(new LinkedHashSet<>(recipes.recipeKeys()), joiningKnown);
|
||||
}
|
||||
|
||||
private static Plugin enabledPlugin(String loggerName) {
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getName()).thenReturn("SpigotHeights");
|
||||
when(plugin.getLogger()).thenReturn(Logger.getLogger(loggerName));
|
||||
PluginDescriptionFile description = new PluginDescriptionFile("SpigotHeights", "test", "unused.Main");
|
||||
when(plugin.getDescription()).thenReturn(description);
|
||||
when(plugin.getPluginMeta()).thenReturn(description);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
private static Player discoveryPlayer(String name, Set<NamespacedKey> known) {
|
||||
Player player = mock(Player.class);
|
||||
when(player.getName()).thenReturn(name);
|
||||
when(player.hasDiscoveredRecipe(any(NamespacedKey.class)))
|
||||
.thenAnswer(invocation -> known.contains(invocation.getArgument(0)));
|
||||
when(player.discoverRecipes(any())).thenAnswer(invocation -> {
|
||||
List<NamespacedKey> requested = ((Collection<?>) invocation.getArgument(0)).stream()
|
||||
.map(NamespacedKey.class::cast).toList();
|
||||
known.addAll(requested);
|
||||
return requested.size();
|
||||
});
|
||||
return player;
|
||||
}
|
||||
|
||||
@Test
|
||||
void incompleteRegistrationRollsBackOnlyRecipesAddedInThisAttempt() {
|
||||
Server server = Bukkit.getServer();
|
||||
List<NamespacedKey> added = new ArrayList<>();
|
||||
List<NamespacedKey> removed = new ArrayList<>();
|
||||
NamespacedKey conflict = new NamespacedKey("spigotheights", "diminution");
|
||||
when(server.getRecipe(conflict)).thenReturn(mock(Recipe.class));
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
added.add(((Keyed) invocation.getArgument(0)).getKey());
|
||||
return true;
|
||||
});
|
||||
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> {
|
||||
removed.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
PotionBrewer brewer = mock(PotionBrewer.class);
|
||||
when(server.getPotionBrewer()).thenReturn(brewer);
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
|
||||
assertFalse(recipes.register());
|
||||
|
||||
assertEquals(List.of(
|
||||
new NamespacedKey("spigotheights", "shifting_stature"),
|
||||
new NamespacedKey("spigotheights", "growth")), added);
|
||||
assertEquals(added, removed);
|
||||
assertFalse(removed.contains(conflict), "A pre-existing conflict is not owned by this lifecycle");
|
||||
verify(brewer, times(8)).addPotionMix(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableRemovesOnlySuccessfulCurrentLifecycleRegistrations() {
|
||||
Server server = Bukkit.getServer();
|
||||
List<NamespacedKey> added = new ArrayList<>();
|
||||
List<NamespacedKey> removed = new ArrayList<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
added.add(((Keyed) invocation.getArgument(0)).getKey());
|
||||
return true;
|
||||
});
|
||||
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> {
|
||||
removed.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
|
||||
recipes.register();
|
||||
recipes.unregister();
|
||||
|
||||
assertEquals(4, added.size());
|
||||
assertEquals(added, removed);
|
||||
assertFalse(removed.contains(new NamespacedKey("minecraft", "unrelated")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeRecipeBookAutofillPlacesExactInputsAndPreservesUnrelatedInventory() {
|
||||
Server server = Bukkit.getServer();
|
||||
List<Recipe> registered = new ArrayList<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
when(server.getPluginManager()).thenReturn(mock(PluginManager.class));
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
assertTrue(recipes.register());
|
||||
|
||||
for (int index = 0; index < registered.size(); index++) {
|
||||
ShapedRecipe bukkit = (ShapedRecipe) registered.get(index);
|
||||
net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe = nativeRecipe(bukkit);
|
||||
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(
|
||||
nativeRecipeKey(bukkit.getKey()), nativeRecipe);
|
||||
var nativePlayer = mock(net.minecraft.world.entity.player.Player.class);
|
||||
when(nativePlayer.level()).thenReturn(mock(net.minecraft.world.level.Level.class));
|
||||
when(nativePlayer.getBukkitEntity()).thenReturn(
|
||||
mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
|
||||
var inventory = new net.minecraft.world.entity.player.Inventory(nativePlayer,
|
||||
new net.minecraft.world.entity.EntityEquipment());
|
||||
when(nativePlayer.getInventory()).thenReturn(inventory);
|
||||
List<ItemStack> expectedGrid = craftingGrid(recipes, index, null);
|
||||
for (int slot = 0; slot < expectedGrid.size(); slot++) {
|
||||
ItemStack ingredient = expectedGrid.get(slot);
|
||||
if (!ingredient.isEmpty()) {
|
||||
inventory.setItem(slot, CraftItemStack.asNMSCopy(ingredient));
|
||||
}
|
||||
}
|
||||
ItemStack unrelated = new ItemStack(Material.DIAMOND, 7);
|
||||
inventory.setItem(20, CraftItemStack.asNMSCopy(unrelated));
|
||||
var grid = new net.minecraft.world.inventory.TransientCraftingContainer(
|
||||
mock(net.minecraft.world.inventory.AbstractContainerMenu.class), 3, 3, nativePlayer);
|
||||
ItemStack displaced = new ItemStack(Material.DIRT, 3);
|
||||
grid.setItem(0, CraftItemStack.asNMSCopy(displaced));
|
||||
List<net.minecraft.world.inventory.Slot> gridSlots = java.util.stream.IntStream.range(0, 9)
|
||||
.mapToObj(slot -> new net.minecraft.world.inventory.Slot(grid, slot, 0, 0))
|
||||
.toList();
|
||||
var access = new net.minecraft.recipebook.ServerPlaceRecipe.CraftingMenuAccess<
|
||||
net.minecraft.world.item.crafting.ShapedRecipe>() {
|
||||
@Override
|
||||
public void fillCraftSlotsStackedContents(
|
||||
net.minecraft.world.entity.player.StackedItemContents contents) {
|
||||
grid.fillStackedContents(contents);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearCraftingContent() {
|
||||
grid.clearContent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean recipeMatches(net.minecraft.world.item.crafting.RecipeHolder<
|
||||
net.minecraft.world.item.crafting.ShapedRecipe> candidate) {
|
||||
return candidate.value().matches(
|
||||
net.minecraft.world.item.crafting.CraftingInput.of(3, 3, grid.getItems()),
|
||||
mock(net.minecraft.world.level.Level.class));
|
||||
}
|
||||
};
|
||||
|
||||
var action = net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(access, 3, 3,
|
||||
gridSlots, gridSlots, inventory, holder, false, false);
|
||||
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, action);
|
||||
assertEquals(expectedGrid, grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList());
|
||||
assertTrue(java.util.stream.IntStream.range(0, 9)
|
||||
.allMatch(slot -> inventory.getItem(slot).isEmpty()),
|
||||
"Autofill must relocate rather than duplicate or consume available ingredients");
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20)));
|
||||
assertEquals(3, inventory.getNonEquipmentItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy)
|
||||
.filter(item -> item.getType() == Material.DIRT)
|
||||
.mapToInt(ItemStack::getAmount)
|
||||
.sum(), "Autofill must return unrelated grid contents to inventory");
|
||||
assertTrue(nativeRecipe.matches(nativeInput(grid.getItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy).toList()), mock(net.minecraft.world.level.Level.class)));
|
||||
net.minecraft.world.item.ItemStack nativeResult = nativeRecipe.assemble(nativeInput(
|
||||
grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList()));
|
||||
ItemStack result = CraftItemStack.asBukkitCopy(nativeResult);
|
||||
assertEquals(StaturePotion.values()[index], recipes.identify(result));
|
||||
assertEquals(1, result.getAmount());
|
||||
|
||||
grid.setCurrentRecipe(new net.minecraft.world.item.crafting.RecipeHolder<
|
||||
net.minecraft.world.item.crafting.CraftingRecipe>(holder.id(), holder.value()));
|
||||
var resultContainer = new net.minecraft.world.inventory.ResultContainer();
|
||||
resultContainer.setRecipeUsed(holder);
|
||||
resultContainer.setItem(0, nativeResult);
|
||||
var resultSlot = new net.minecraft.world.inventory.ResultSlot(nativePlayer, grid,
|
||||
resultContainer, 0, 0, 0);
|
||||
net.minecraft.world.item.ItemStack taken = resultSlot.remove(1);
|
||||
resultSlot.onTake(nativePlayer, taken);
|
||||
|
||||
assertEquals(StaturePotion.values()[index],
|
||||
recipes.identify(CraftItemStack.asBukkitCopy(taken)));
|
||||
assertTrue(resultContainer.isEmpty(), "Completed crafting must yield exactly one result");
|
||||
assertTrue(grid.isEmpty(), "Completed crafting must consume exactly the placed ingredients");
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20)));
|
||||
assertEquals(3, inventory.getNonEquipmentItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy)
|
||||
.filter(item -> item.getType() == Material.DIRT)
|
||||
.mapToInt(ItemStack::getAmount)
|
||||
.sum());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeRecipeBookAutofillRejectsInvalidPotionSubstitutionsWithoutMovingInventory() {
|
||||
Server server = Bukkit.getServer();
|
||||
List<Recipe> registered = new ArrayList<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
assertTrue(recipes.register());
|
||||
|
||||
for (int index = 0; index < registered.size(); index++) {
|
||||
ShapedRecipe bukkit = (ShapedRecipe) registered.get(index);
|
||||
for (ItemStack invalidPotion : invalidPotionInputs(recipes, index)) {
|
||||
assertNativeAutofillRejected(recipes, index, bukkit, invalidPotion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertNativeAutofillRejected(PotionRecipes recipes, int recipeIndex,
|
||||
ShapedRecipe bukkit, ItemStack invalidPotion) {
|
||||
net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe = nativeRecipe(bukkit);
|
||||
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(
|
||||
nativeRecipeKey(bukkit.getKey()), nativeRecipe);
|
||||
var nativePlayer = mock(net.minecraft.world.entity.player.Player.class);
|
||||
var inventory = new net.minecraft.world.entity.player.Inventory(nativePlayer,
|
||||
new net.minecraft.world.entity.EntityEquipment());
|
||||
List<ItemStack> available = craftingGrid(recipes, recipeIndex, invalidPotion);
|
||||
for (int slot = 0; slot < available.size(); slot++) {
|
||||
if (!available.get(slot).isEmpty()) {
|
||||
inventory.setItem(slot, CraftItemStack.asNMSCopy(available.get(slot)));
|
||||
}
|
||||
}
|
||||
inventory.setItem(20, CraftItemStack.asNMSCopy(new ItemStack(Material.DIAMOND, 7)));
|
||||
List<ItemStack> inventoryBefore = inventory.getNonEquipmentItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy).toList();
|
||||
var grid = new net.minecraft.world.SimpleContainer(9);
|
||||
List<net.minecraft.world.inventory.Slot> gridSlots = java.util.stream.IntStream.range(0, 9)
|
||||
.mapToObj(slot -> new net.minecraft.world.inventory.Slot(grid, slot, 0, 0))
|
||||
.toList();
|
||||
var access = new net.minecraft.recipebook.ServerPlaceRecipe.CraftingMenuAccess<
|
||||
net.minecraft.world.item.crafting.ShapedRecipe>() {
|
||||
@Override
|
||||
public void fillCraftSlotsStackedContents(
|
||||
net.minecraft.world.entity.player.StackedItemContents contents) {
|
||||
grid.fillStackedContents(contents);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearCraftingContent() {
|
||||
grid.clearContent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean recipeMatches(net.minecraft.world.item.crafting.RecipeHolder<
|
||||
net.minecraft.world.item.crafting.ShapedRecipe> candidate) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
var action = net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(access, 3, 3,
|
||||
gridSlots, gridSlots, inventory, holder, false, false);
|
||||
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.PLACE_GHOST_RECIPE,
|
||||
action, invalidPotion::toString);
|
||||
assertTrue(grid.isEmpty());
|
||||
assertEquals(inventoryBefore, inventory.getNonEquipmentItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy).toList());
|
||||
}
|
||||
|
||||
private static net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> nativeRecipeKey(
|
||||
NamespacedKey key) {
|
||||
return net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE,
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath(key.getNamespace(), key.getKey()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeCraftingMatchesExactInputsAndProducesAllFourAuthenticatedResults() {
|
||||
Server server = Bukkit.getServer();
|
||||
List<Recipe> registered = new ArrayList<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
assertTrue(recipes.register());
|
||||
|
||||
for (int index = 0; index < registered.size(); index++) {
|
||||
ShapedRecipe bukkit = (ShapedRecipe) registered.get(index);
|
||||
net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe = nativeRecipe(bukkit);
|
||||
assertTrue(nativeRecipe.getIngredients().get(4).orElseThrow().isExact(),
|
||||
"Pinned recipe placement must retain the component-sensitive potion choice");
|
||||
List<ItemStack> valid = craftingGrid(recipes, index, null);
|
||||
List<ItemStack> unchanged = valid.stream().map(ItemStack::clone).toList();
|
||||
net.minecraft.world.item.crafting.CraftingInput input = nativeInput(valid);
|
||||
assertTrue(nativeRecipe.matches(input, mock(net.minecraft.world.level.Level.class)));
|
||||
ItemStack result = CraftItemStack.asBukkitCopy(nativeRecipe.assemble(input));
|
||||
assertEquals(StaturePotion.values()[index], recipes.identify(result));
|
||||
assertEquals(1, result.getAmount());
|
||||
assertEquals(unchanged, valid, "Matching and preview assembly must not consume the grid");
|
||||
|
||||
for (ItemStack invalidPotion : invalidPotionInputs(recipes, index)) {
|
||||
assertFalse(nativeRecipe.matches(nativeInput(craftingGrid(recipes, index, invalidPotion)),
|
||||
mock(net.minecraft.world.level.Level.class)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe(ShapedRecipe recipe) {
|
||||
java.util.Map<Character, net.minecraft.world.item.crafting.Ingredient> ingredients = new HashMap<>();
|
||||
recipe.getChoiceMap().forEach((symbol, choice) ->
|
||||
ingredients.put(symbol, CraftRecipe.toIngredient(choice, false)));
|
||||
var pattern = net.minecraft.world.item.crafting.ShapedRecipePattern.of(ingredients, recipe.getShape());
|
||||
return new net.minecraft.world.item.crafting.ShapedRecipe(
|
||||
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
|
||||
new net.minecraft.world.item.crafting.CraftingRecipe.CraftingBookInfo(
|
||||
CraftRecipe.getCategory(recipe.getCategory()), recipe.getGroup()),
|
||||
pattern, CraftItemStack.asTemplate(recipe.getResult()));
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.CraftingInput nativeInput(List<ItemStack> items) {
|
||||
return net.minecraft.world.item.crafting.CraftingInput.of(3, 3, CraftItemStack.asNMSCopy(items));
|
||||
}
|
||||
|
||||
private static List<ItemStack> craftingGrid(PotionRecipes recipes, int recipeIndex, ItemStack potionOverride) {
|
||||
String[] shape = switch (recipeIndex) {
|
||||
case 0 -> PotionRecipes.SHIFTING_SHAPE;
|
||||
case 1 -> PotionRecipes.GROWTH_SHAPE;
|
||||
case 2 -> PotionRecipes.DIMINUTION_SHAPE;
|
||||
case 3 -> PotionRecipes.RESTORATION_SHAPE;
|
||||
default -> throw new IllegalArgumentException("Unknown recipe index");
|
||||
};
|
||||
List<ItemStack> items = new ArrayList<>();
|
||||
for (String row : shape) {
|
||||
for (char symbol : row.toCharArray()) {
|
||||
items.add(switch (symbol) {
|
||||
case 'A' -> new ItemStack(Material.AMETHYST_SHARD);
|
||||
case 'C' -> new ItemStack(Material.CHORUS_FRUIT);
|
||||
case 'G' -> new ItemStack(Material.GOLD_INGOT);
|
||||
case 'R' -> new ItemStack(Material.RABBIT_FOOT);
|
||||
case 'F' -> new ItemStack(Material.FERMENTED_SPIDER_EYE);
|
||||
case 'U' -> new ItemStack(Material.SUGAR);
|
||||
case 'W', 'S' -> potionOverride != null ? potionOverride.clone()
|
||||
: recipeIndex == 0 ? awkwardPotion() : recipes.create(StaturePotion.SHIFTING);
|
||||
default -> ItemStack.empty();
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private static List<ItemStack> invalidPotionInputs(PotionRecipes recipes, int recipeIndex) {
|
||||
ItemStack ordinary = new ItemStack(Material.POTION);
|
||||
ItemStack unrelated = new ItemStack(Material.POTION);
|
||||
unrelated.editMeta(org.bukkit.inventory.meta.PotionMeta.class,
|
||||
meta -> meta.setBasePotionType(org.bukkit.potion.PotionType.HEALING));
|
||||
if (recipeIndex == 0) {
|
||||
return List.of(ordinary, unrelated, recipes.create(StaturePotion.SHIFTING));
|
||||
}
|
||||
ItemStack nameOnly = new ItemStack(Material.POTION);
|
||||
nameOnly.editMeta(meta -> meta.displayName(
|
||||
net.kyori.adventure.text.Component.text(StaturePotion.SHIFTING.displayName())));
|
||||
return List.of(ordinary, unrelated, nameOnly, recipes.create(StaturePotion.GROWTH));
|
||||
}
|
||||
|
||||
private static ItemStack awkwardPotion() {
|
||||
ItemStack awkward = new ItemStack(Material.POTION);
|
||||
awkward.editMeta(org.bukkit.inventory.meta.PotionMeta.class,
|
||||
meta -> meta.setBasePotionType(org.bukkit.potion.PotionType.AWKWARD));
|
||||
return awkward;
|
||||
}
|
||||
|
||||
@Test
|
||||
void registersAllAuthenticatedResultsInMiscWithExactPotionInputs() {
|
||||
Server server = Bukkit.getServer();
|
||||
List<Recipe> registered = new ArrayList<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
|
||||
recipes.register();
|
||||
|
||||
assertEquals(4, registered.size());
|
||||
for (int index = 0; index < StaturePotion.values().length; index++) {
|
||||
ShapedRecipe recipe = assertInstanceOf(ShapedRecipe.class, registered.get(index));
|
||||
assertEquals(CraftingBookCategory.MISC, recipe.getCategory());
|
||||
assertEquals(StaturePotion.values()[index], recipes.identify(recipe.getResult()));
|
||||
RecipeChoice.ExactChoice choice = assertInstanceOf(RecipeChoice.ExactChoice.class,
|
||||
recipe.getChoiceMap().get(index == 0 ? 'W' : 'S'));
|
||||
ItemStack selected = choice.getChoices().getFirst();
|
||||
if (index == 0) {
|
||||
assertEquals(Material.POTION, selected.getType());
|
||||
assertTrue(recipes.isAwkwardPotion(selected));
|
||||
assertNull(recipes.identify(selected));
|
||||
} else {
|
||||
assertEquals(StaturePotion.SHIFTING, recipes.identify(selected));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,14 @@ import org.bukkit.craftbukkit.CraftRegistry;
|
||||
|
||||
/** Loads vanilla registries/tags/components using the same path as the server's own tests. */
|
||||
final class NativeRuntime {
|
||||
private static boolean bootstrapped;
|
||||
|
||||
private NativeRuntime() {}
|
||||
|
||||
static void bootstrap() throws Exception {
|
||||
static synchronized void bootstrap() throws Exception {
|
||||
if (bootstrapped) {
|
||||
return;
|
||||
}
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
var flags = FeatureFlags.VANILLA_SET;
|
||||
@@ -53,6 +58,7 @@ final class NativeRuntime {
|
||||
Util.backgroundExecutor(), Runnable::run).join();
|
||||
datapack.updateComponentsAndStaticRegistryTags();
|
||||
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
|
||||
bootstrapped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package games.dmg.spigotheights;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
@@ -27,6 +28,30 @@ class PurpurDistributionTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void packagesTheIntegratedRecipeDiscoveryLifecycle() throws Exception {
|
||||
try (var jar = new JarFile(System.getProperty("distribution.jar"))) {
|
||||
String plugin = classConstants(jar, "games/dmg/spigotheights/SpigotHeightsPlugin.class");
|
||||
String lifecycle = classConstants(jar, "games/dmg/spigotheights/RecipeLifecycle.class");
|
||||
String discovery = classConstants(jar, "games/dmg/spigotheights/RecipeDiscovery.class");
|
||||
String recipes = classConstants(jar, "games/dmg/spigotheights/PotionRecipes.class");
|
||||
assertTrue(plugin.contains("RecipeLifecycle"));
|
||||
assertTrue(lifecycle.contains("getOnlinePlayers"));
|
||||
assertTrue(lifecycle.contains("registerEvents"));
|
||||
assertTrue(discovery.contains("discoverRecipes"));
|
||||
assertTrue(discovery.contains("hasDiscoveredRecipe"));
|
||||
assertTrue(recipes.contains("CraftingBookCategory"));
|
||||
assertTrue(recipes.contains("AWKWARD"));
|
||||
assertTrue(recipes.contains("RecipeChoice$ExactChoice"));
|
||||
assertTrue(recipes.contains("removeRecipe"));
|
||||
}
|
||||
}
|
||||
|
||||
private static String classConstants(JarFile jar, String entry) throws Exception {
|
||||
assertNotNull(jar.getJarEntry(entry), entry);
|
||||
return new String(jar.getInputStream(jar.getJarEntry(entry)).readAllBytes(), StandardCharsets.ISO_8859_1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pluginBytecodeTargetsJava25() throws Exception {
|
||||
try (var jar = new JarFile(System.getProperty("distribution.jar"));
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.LogRecord;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RecipeDiscoveryTest {
|
||||
private final List<NamespacedKey> keys = List.of(
|
||||
new NamespacedKey("spigotheights", "shifting_stature"),
|
||||
new NamespacedKey("spigotheights", "growth"),
|
||||
new NamespacedKey("spigotheights", "diminution"),
|
||||
new NamespacedKey("spigotheights", "restoration"));
|
||||
|
||||
@Test
|
||||
void allAlreadyOnlinePlayersReceiveEveryUnknownRecipe() {
|
||||
Set<NamespacedKey> firstKnown = new LinkedHashSet<>();
|
||||
Set<NamespacedKey> secondKnown = new LinkedHashSet<>();
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, Logger.getAnonymousLogger());
|
||||
|
||||
discovery.discoverAll(List.of(
|
||||
player("First", firstKnown, new ArrayList<>(), false),
|
||||
player("Second", secondKnown, new ArrayList<>(), false)));
|
||||
|
||||
assertEquals(new LinkedHashSet<>(keys), firstKnown);
|
||||
assertEquals(new LinkedHashSet<>(keys), secondKnown);
|
||||
}
|
||||
|
||||
@Test
|
||||
void joiningPlayerReceivesEveryUnknownRecipe() {
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
List<List<NamespacedKey>> awards = new ArrayList<>();
|
||||
Player player = player("Joiner", known, awards, false);
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, Logger.getAnonymousLogger());
|
||||
|
||||
discovery.onJoin(new PlayerJoinEvent(player, "joined"));
|
||||
|
||||
assertEquals(List.of(keys), awards);
|
||||
assertEquals(new LinkedHashSet<>(keys), known);
|
||||
}
|
||||
|
||||
@Test
|
||||
void onePlayerExceptionIsLoggedWithoutBlockingOthersAndRetriesLater() {
|
||||
Set<NamespacedKey> failedKnown = new LinkedHashSet<>();
|
||||
Set<NamespacedKey> healthyKnown = new LinkedHashSet<>();
|
||||
AtomicBoolean firstAttempt = new AtomicBoolean(true);
|
||||
List<String> warnings = new ArrayList<>();
|
||||
Logger logger = logger(warnings);
|
||||
Player failing = BukkitDoubles.proxy(Player.class, (proxy, method, args) -> switch (method.getName()) {
|
||||
case "getName" -> "Failing";
|
||||
case "hasDiscoveredRecipe" -> failedKnown.contains(args[0]);
|
||||
case "discoverRecipes" -> {
|
||||
if (firstAttempt.getAndSet(false)) {
|
||||
throw new IllegalStateException("simulated discovery failure");
|
||||
}
|
||||
List<NamespacedKey> requested = ((Collection<?>) args[0]).stream()
|
||||
.map(NamespacedKey.class::cast).toList();
|
||||
failedKnown.addAll(requested);
|
||||
yield requested.size();
|
||||
}
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, logger);
|
||||
|
||||
assertDoesNotThrow(() -> discovery.discover(failing));
|
||||
discovery.discover(player("Healthy", healthyKnown, new ArrayList<>(), false));
|
||||
discovery.discover(failing);
|
||||
|
||||
assertEquals(new LinkedHashSet<>(keys), healthyKnown);
|
||||
assertEquals(new LinkedHashSet<>(keys), failedKnown);
|
||||
assertEquals(1, warnings.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingPostconditionIsLoggedAndOnlyMissingRecipesRetryLater() {
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
List<List<NamespacedKey>> awards = new ArrayList<>();
|
||||
List<String> warnings = new ArrayList<>();
|
||||
Logger logger = logger(warnings);
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, logger);
|
||||
|
||||
discovery.discover(player("Ada", known, awards, true));
|
||||
discovery.discover(player("Ada", known, awards, false));
|
||||
|
||||
assertEquals(List.of(keys, List.of(keys.getLast())), awards);
|
||||
assertEquals(1, warnings.size());
|
||||
assertEquals(new LinkedHashSet<>(keys), known);
|
||||
}
|
||||
|
||||
@Test
|
||||
void discoversOnlyUnknownRecipesAndRepeatedDiscoveryIsSilent() {
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
List<List<NamespacedKey>> awards = new ArrayList<>();
|
||||
Player player = player("Ada", known, awards, false);
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, Logger.getAnonymousLogger());
|
||||
|
||||
discovery.discover(player);
|
||||
discovery.discover(player);
|
||||
|
||||
assertEquals(List.of(keys), awards);
|
||||
assertEquals(new LinkedHashSet<>(keys), known);
|
||||
}
|
||||
|
||||
private static Logger logger(List<String> warnings) {
|
||||
Logger logger = Logger.getAnonymousLogger();
|
||||
logger.setUseParentHandlers(false);
|
||||
logger.addHandler(new Handler() {
|
||||
@Override public void publish(LogRecord record) { warnings.add(record.getMessage()); }
|
||||
@Override public void flush() {}
|
||||
@Override public void close() {}
|
||||
});
|
||||
return logger;
|
||||
}
|
||||
|
||||
static Player player(String name, Set<NamespacedKey> known, List<List<NamespacedKey>> awards,
|
||||
boolean omitLastAward) {
|
||||
return BukkitDoubles.proxy(Player.class, (proxy, method, args) -> switch (method.getName()) {
|
||||
case "getName" -> name;
|
||||
case "hasDiscoveredRecipe" -> known.contains(args[0]);
|
||||
case "discoverRecipes" -> {
|
||||
List<NamespacedKey> requested = ((Collection<?>) args[0]).stream()
|
||||
.map(NamespacedKey.class::cast).toList();
|
||||
awards.add(requested);
|
||||
int limit = omitLastAward ? Math.max(0, requested.size() - 1) : requested.size();
|
||||
known.addAll(requested.subList(0, limit));
|
||||
yield limit;
|
||||
}
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user