Compare commits

...
1 Commits
Author SHA1 Message Date
dmg a58c192358 feat(crafting): discover character sheet recipe
Release / release (push) Failing after 8m34s
2026-09-20 15:29:21 -04:00
10 changed files with 450 additions and 5 deletions

No files matched your search

@@ -9,6 +9,7 @@ import games.dmg.fantasystick.crafting.CharacterAnvilNames;
import games.dmg.fantasystick.crafting.ManualCraftingController;
import games.dmg.fantasystick.crafting.ManualCraftingGuard;
import games.dmg.fantasystick.crafting.PlayerSheetDrops;
import games.dmg.fantasystick.crafting.RecipeDiscovery;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
@@ -18,6 +19,7 @@ import games.dmg.fantasystick.session.EquipmentSessions;
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
import games.dmg.fantasystick.progression.ProgressionRules;
import java.time.Clock;
import java.util.List;
import java.util.logging.Level;
import org.bukkit.plugin.java.JavaPlugin;
@@ -27,6 +29,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private ManualCraftingController crafting;
private ManualCraftingGuard guard;
private CharacterAnvilNames anvilNames;
private RecipeDiscovery recipeDiscovery;
private boolean recipeRegistered;
private EquipmentSessionStore receipts;
private EquipmentSessions sessions;
@@ -319,8 +322,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
sessionVisuals.install(this);
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
if (!recipeRegistered) throw new IllegalStateException("Character-sheet recipe key is already registered");
for (var player : getServer().getOnlinePlayers()) { sessions.arrive(player); revive.reconcile(player); }
sheetResourcePack.deliverAll(getServer().getOnlinePlayers());
recipeDiscovery = new RecipeDiscovery(CharacterSheets.RECIPE, getLogger());
getServer().getPluginManager().registerEvents(recipeDiscovery, this);
var onlinePlayers = List.copyOf(getServer().getOnlinePlayers());
recipeDiscovery.discoverAll(onlinePlayers);
for (var player : onlinePlayers) { sessions.arrive(player); revive.reconcile(player); }
sheetResourcePack.deliverAll(onlinePlayers);
} catch (RuntimeException | LinkageError failure) {
try { onDisable(); }
catch (RuntimeException | LinkageError cleanupFailure) { failure.addSuppressed(cleanupFailure); }
@@ -343,6 +350,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingCrafting = crafting; crafting = null;
var closingGuard = guard; guard = null;
var closingAnvil = anvilNames; anvilNames = null;
var closingRecipeDiscovery = recipeDiscovery; recipeDiscovery = null;
var closingSessions = sessions; sessions = null;
var closingReceipts = receipts; receipts = null;
var closingCharacters = characters; characters = null;
@@ -482,6 +490,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingCrafting);
unregister(closingGuard);
unregister(closingAnvil);
unregister(closingRecipeDiscovery);
if (recipeRegistered) cleanup("character-sheet recipe", () -> {
getServer().removeRecipe(CharacterSheets.RECIPE);
recipeRegistered = false; // Retain ownership for a later retry if removal failed.
@@ -0,0 +1,49 @@
package games.dmg.fantasystick.crafting;
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;
/** Idempotent recipe-book visibility; recipe registration and crafting remain separate authorities. */
public final class RecipeDiscovery implements Listener {
private final NamespacedKey recipe;
private final Logger logger;
public RecipeDiscovery(NamespacedKey recipe, Logger logger) {
this.recipe = Objects.requireNonNull(recipe, "recipe");
this.logger = Objects.requireNonNull(logger, "logger");
}
public void discoverAll(Iterable<? extends Player> players) {
for (var player : players) discover(player);
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
discover(event.getPlayer());
}
public void discover(Player player) {
Objects.requireNonNull(player, "player");
try {
if (player.hasDiscoveredRecipe(recipe)) return;
player.discoverRecipes(List.of(recipe));
if (!player.hasDiscoveredRecipe(recipe)) {
logger.warning("Could not discover character-sheet recipe for " + label(player));
}
} catch (RuntimeException failure) {
logger.log(Level.WARNING, "Could not discover character-sheet recipe for " + label(player), failure);
}
}
private static String label(Player player) {
try { return player.getName(); }
catch (RuntimeException ignored) { return "an unidentified player"; }
}
}
@@ -34,9 +34,11 @@ public final class CharacterSheets {
/** Definition only: a future craft-completion adapter must persist and mint each actual result. */
public ShapelessRecipe recipe() {
return new ShapelessRecipe(RECIPE, paper("Character sheet"))
var recipe = new ShapelessRecipe(RECIPE, paper("Character sheet"))
.addIngredient(Material.STICK).addIngredient(Material.PAPER)
.addIngredient(Material.FEATHER).addIngredient(Material.INK_SAC);
recipe.setCategory(org.bukkit.inventory.recipe.CraftingBookCategory.MISC);
return recipe;
}
private static ItemStack paper(String name) {
@@ -0,0 +1,154 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.items.CharacterSheets;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.OptionalInt;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.Server;
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.inventory.ItemStack;
import org.bukkit.plugin.PluginManager;
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 recipe-book persistence and placement without starting a listening server/client. */
class CharacterSheetRecipeBookNativeTest {
private static Server previousServer;
private static Server server;
@BeforeAll static void bootstrap() throws Exception {
NativeRuntime.bootstrap(); previousServer = Bukkit.getServer(); server = mock(Server.class); setServer(server);
}
@BeforeEach void configure() {
reset(server);
when(server.getItemFactory()).thenAnswer(call -> CraftItemFactory.instance());
when(server.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
when(server.isPrimaryThread()).thenReturn(true);
}
@AfterAll static void restore() throws Exception { setServer(previousServer); }
private static void setServer(Server value) throws Exception {
var field = Bukkit.class.getDeclaredField("server"); field.setAccessible(true); field.set(null, value);
}
@Test void nativeBookAwardsOneMiscHighlightAndPreservesItWithUnrelatedState() {
var bukkit = new CharacterSheets().recipe(); var nativeRecipe = nativeRecipe(bukkit); var key = key();
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(key, nativeRecipe);
var events = new ArrayList<org.bukkit.event.Event>(); var manager = mock(PluginManager.class);
doAnswer(call -> { events.add(call.getArgument(0)); return null; }).when(manager).callEvent(any());
when(server.getPluginManager()).thenReturn(manager);
var book = new Book((requested, consumer) -> consumer.accept(new net.minecraft.world.item.crafting.display.RecipeDisplayEntry(
new net.minecraft.world.item.crafting.display.RecipeDisplayId(7), nativeRecipe.display().getFirst(),
OptionalInt.empty(), nativeRecipe.recipeBookCategory(), Optional.of(nativeRecipe.placementInfo().ingredients()))));
var player = mock(net.minecraft.server.level.ServerPlayer.class);
when(player.getBukkitEntity()).thenReturn(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
when(player.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class));
assertEquals(1, book.addRecipes(List.of(holder), player));
assertEquals(0, book.addRecipes(List.of(holder), player));
assertTrue(book.contains(key));
assertEquals(List.of(key), book.pack().highlight());
assertEquals(net.minecraft.world.item.crafting.CraftingBookCategory.MISC, nativeRecipe.category());
assertEquals(1, events.size());
var event = assertInstanceOf(org.bukkit.event.player.PlayerRecipeDiscoverEvent.class, events.getFirst());
assertTrue(event.shouldShowNotification());
var unrelated = nativeKey(new org.bukkit.NamespacedKey("minecraft", "stick"));
book.add(unrelated); book.highlight(unrelated);
book.setBookSetting(net.minecraft.world.inventory.RecipeBookType.CRAFTING, true, true);
var tag = net.minecraft.stats.ServerRecipeBook.Packed.CODEC.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, book.pack()).getOrThrow();
var packed = net.minecraft.stats.ServerRecipeBook.Packed.CODEC.parse(net.minecraft.nbt.NbtOps.INSTANCE, tag).getOrThrow();
var restored = new Book(); restored.loadUntrusted(packed, ignored -> true);
assertTrue(restored.contains(key)); assertTrue(restored.contains(unrelated));
assertEquals(new LinkedHashSet<>(packed.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 known = restored.pack().known().size(); int highlighted = restored.pack().highlight().size();
restored.add(key);
assertEquals(known, restored.pack().known().size()); assertEquals(highlighted, restored.pack().highlight().size());
}
@Test void nativeAutofillPlacesExactIngredientsAndPreviewCannotMintACharacter() {
var sheets = new CharacterSheets(); var bukkit = sheets.recipe(); var nativeRecipe = nativeRecipe(bukkit);
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(key(), nativeRecipe);
var player = mock(net.minecraft.world.entity.player.Player.class);
when(player.level()).thenReturn(mock(net.minecraft.world.level.Level.class));
when(player.getBukkitEntity()).thenReturn(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
var inventory = new net.minecraft.world.entity.player.Inventory(player, new net.minecraft.world.entity.EntityEquipment());
when(player.getInventory()).thenReturn(inventory);
var ingredients = List.of(Material.STICK, Material.PAPER, Material.FEATHER, Material.INK_SAC);
for (int index = 0; index < ingredients.size(); index++) inventory.setItem(index,
CraftItemStack.asNMSCopy(new ItemStack(ingredients.get(index))));
var 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, player);
grid.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 3)));
var slots = 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.ShapelessRecipe>() {
@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.ShapelessRecipe> candidate) {
return candidate.value().matches(input(grid), mock(net.minecraft.world.level.Level.class));
}
};
var action = net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(
access, 3, 3, slots, slots, inventory, holder, false, false);
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, action);
assertTrue(nativeRecipe.matches(input(grid), mock(net.minecraft.world.level.Level.class)));
for (var material : ingredients) assertEquals(1, count(grid, material));
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());
var before = grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList();
var preview = CraftItemStack.asBukkitCopy(nativeRecipe.assemble(input(grid)));
assertEquals(Material.PAPER, preview.getType()); assertTrue(sheets.identify(preview).isEmpty());
assertTrue(preview.getItemMeta().getPersistentDataContainer().isEmpty());
assertEquals(before, grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList());
}
private static net.minecraft.world.item.crafting.ShapelessRecipe nativeRecipe(org.bukkit.inventory.ShapelessRecipe recipe) {
var ingredients = recipe.getChoiceList().stream().map(choice -> CraftRecipe.toIngredient(choice, false)).toList();
return new net.minecraft.world.item.crafting.ShapelessRecipe(
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
new net.minecraft.world.item.crafting.CraftingRecipe.CraftingBookInfo(
CraftRecipe.getCategory(recipe.getCategory()), recipe.getGroup()),
CraftItemStack.asTemplate(recipe.getResult()), ingredients);
}
private static net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> key() {
return nativeKey(CharacterSheets.RECIPE);
}
private static net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> nativeKey(org.bukkit.NamespacedKey key) {
return net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE,
net.minecraft.resources.Identifier.fromNamespaceAndPath(key.getNamespace(), key.getKey()));
}
private static net.minecraft.world.item.crafting.CraftingInput input(net.minecraft.world.Container grid) {
return net.minecraft.world.item.crafting.CraftingInput.of(3, 3,
java.util.stream.IntStream.range(0, grid.getContainerSize()).mapToObj(grid::getItem).toList());
}
private static long count(net.minecraft.world.Container grid, Material material) {
return java.util.stream.IntStream.range(0, grid.getContainerSize()).mapToObj(grid::getItem)
.map(CraftItemStack::asBukkitCopy).filter(item -> item.getType() == material).mapToInt(ItemStack::getAmount).sum();
}
private static final class Book extends net.minecraft.stats.ServerRecipeBook {
Book() { this((key, consumer) -> { }); }
Book(DisplayResolver resolver) { super(resolver); }
void highlight(net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> key) { highlight.add(key); }
}
}
@@ -76,6 +76,7 @@ class CharacterSheetsNativeTest {
var sheets = new CharacterSheets();
var recipe = sheets.recipe();
assertEquals(new NamespacedKey("fantasystick", "character_sheet"), recipe.getKey());
assertEquals(org.bukkit.inventory.recipe.CraftingBookCategory.MISC, recipe.getCategory());
assertEquals(4, recipe.getChoiceList().size());
assertTrue(recipe.getChoiceList().stream().allMatch(choice ->
choice instanceof RecipeChoice.MaterialChoice materials && materials.getChoices().size() == 1));
@@ -73,6 +73,8 @@ class FantasyStickLifecycleNativeTest {
assertTrue(fixture.order.indexOf("BasicAttack") < recipe);
assertTrue(fixture.order.indexOf("NutritionPolicy") >= 0);
assertTrue(fixture.order.indexOf("NutritionPolicy") < recipe);
assertTrue(fixture.order.indexOf("RecipeDiscovery") > recipe,
"Discovery must not become active before successful recipe registration");
assertNotNull(fixture.command);
assertEquals("EquipmentAdminCommand", fixture.command.getExecutor().getClass().getSimpleName());
assertTrue(fixture.tasks.isEmpty());
@@ -138,7 +140,7 @@ class FantasyStickLifecycleNativeTest {
@Test
void recipeCollisionAndPartialListenerRegistrationFailureRollbackOnlyThisEnableAttempt() throws Exception {
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents", "CharacterChatNames", "ProtocolPresentation", "CombatExperience", "Fireball", "presentation-timer")) {
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents", "CharacterChatNames", "ProtocolPresentation", "CombatExperience", "Fireball", "presentation-timer", "RecipeDiscovery")) {
boolean collision = stage.equals("collision");
try (var fixture = new Fixture(data)) {
var existing = new org.bukkit.inventory.ShapelessRecipe(CharacterSheets.RECIPE,
@@ -173,6 +175,10 @@ class FantasyStickLifecycleNativeTest {
try (var fixture = new Fixture(directory)) {
fixture.plugin.setEnabled(true);
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
((games.dmg.fantasystick.crafting.RecipeDiscovery) Fixture.field(fixture.plugin, "recipeDiscovery"))
.discover(actor.player);
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), actor.knownRecipes);
assertEquals(1, actor.recipeAwards);
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
assertTrue(new CharacterSheets().identify(fixture.recipes.get(CharacterSheets.RECIPE).getResult()).isEmpty());
var cancelled = actor.fixture.event(org.bukkit.event.inventory.ClickType.LEFT,
@@ -1305,6 +1311,8 @@ class FantasyStickLifecycleNativeTest {
final List<Integer> particles = new ArrayList<>();
final List<org.bukkit.Sound> sounds = new ArrayList<>();
final List<Object[]> resourcePacks = new ArrayList<>();
final java.util.Set<org.bukkit.NamespacedKey> knownRecipes = new java.util.LinkedHashSet<>();
final List<List<org.bukkit.NamespacedKey>> recipeAwards = new ArrayList<>();
final net.minecraft.world.entity.ai.attributes.AttributeMap healthAttributes = new net.minecraft.world.entity.ai.attributes.AttributeMap(
net.minecraft.world.entity.ai.attributes.AttributeSupplier.builder().add(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH).build());
final net.minecraft.world.entity.ai.attributes.AttributeInstance maximumHealth = java.util.Objects.requireNonNull(
@@ -1372,6 +1380,14 @@ class FantasyStickLifecycleNativeTest {
case "spawnParticle" -> { particles.add((Integer) args[2]); yield null; }
case "playSound" -> { if (args.length > 1 && args[1] instanceof org.bukkit.Sound sound) sounds.add(sound); yield null; }
case "addResourcePack" -> { resourcePacks.add(args.clone()); yield null; }
case "hasDiscoveredRecipe" -> knownRecipes.contains((org.bukkit.NamespacedKey) args[0]);
case "discoverRecipes" -> {
var requested = ((java.util.Collection<?>) args[0]).stream()
.map(org.bukkit.NamespacedKey.class::cast).toList();
recipeAwards.add(requested); int before = knownRecipes.size(); knownRecipes.addAll(requested);
yield knownRecipes.size() - before;
}
case "undiscoverRecipes" -> throw new AssertionError("Recipe discovery must not be revoked");
default -> Proxy.getInvocationHandler(core.player).invoke(core.player, method, args);
};
});
@@ -424,6 +424,8 @@ class ManualCraftingControllerNativeTest {
org.bukkit.Location eyeLocation;
boolean online = true;
int syncs;
final java.util.Set<org.bukkit.NamespacedKey> knownRecipes = new java.util.LinkedHashSet<>();
int recipeAwards;
Runnable onSync = () -> { };
Actor(int width, int amount) {
@@ -438,6 +440,14 @@ class ManualCraftingControllerNativeTest {
case "getEyeLocation" -> java.util.Objects.requireNonNull(eyeLocation).clone();
case "isOnline" -> online;
case "updateInventory" -> { syncs++; onSync.run(); yield null; }
case "getName" -> "CraftingActor";
case "hasDiscoveredRecipe" -> knownRecipes.contains((org.bukkit.NamespacedKey) args[0]);
case "discoverRecipes" -> {
recipeAwards++; int before = knownRecipes.size();
((java.util.Collection<?>) args[0]).stream().map(org.bukkit.NamespacedKey.class::cast)
.forEach(knownRecipes::add);
yield knownRecipes.size() - before;
}
case "equals" -> object == args[0];
case "hashCode" -> System.identityHashCode(object);
case "toString" -> "Crafting actor fixture";
@@ -0,0 +1,100 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.Mockito.doAnswer;
import games.dmg.fantasystick.items.CharacterSheets;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Handler;
import java.util.logging.LogRecord;
import org.bukkit.event.player.PlayerJoinEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class RecipeDiscoveryNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void packagedRegistrationDiscoversOnlineAndJoinPlayersOnceWithoutMintingAndRetainsKnowledge() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var online = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(online.core.id, online.player);
long records = records(data);
f.plugin.setEnabled(true);
var recipe = (org.bukkit.inventory.ShapelessRecipe) f.recipes.get(CharacterSheets.RECIPE);
assertNotNull(recipe);
assertEquals(org.bukkit.inventory.recipe.CraftingBookCategory.MISC, recipe.getCategory());
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), online.knownRecipes);
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), online.recipeAwards);
assertEquals(records, records(data));
assertTrue(new CharacterSheets().identify(recipe.getResult()).isEmpty());
var joining = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(joining.core.id, joining.player);
f.dispatcher.accept(new PlayerJoinEvent(joining.player, "joined"));
f.dispatcher.accept(new PlayerJoinEvent(joining.player, "joined again"));
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), joining.recipeAwards);
assertEquals(records + 1, records(data));
f.plugin.setEnabled(false);
f.dispatcher.accept(new PlayerJoinEvent(joining.player, "disabled"));
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), joining.recipeAwards);
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), joining.knownRecipes);
f.plugin.setEnabled(true);
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), online.recipeAwards);
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), joining.recipeAwards);
}
}
@Test void playerFailureIsLoggedIsolatedAndRetryableButRegistrationFailureAwardsNobody() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("player-failure"))) {
var failing = new FantasyStickLifecycleNativeTest.Actor(data.resolve("player-failure"));
var healthy = new FantasyStickLifecycleNativeTest.Actor(data.resolve("player-failure"));
var fail = new AtomicBoolean(true);
doAnswer(call -> {
if (fail.getAndSet(false)) throw new IllegalStateException("simulated discovery failure");
var requested = ((java.util.Collection<?>) call.getArgument(0)).stream()
.map(org.bukkit.NamespacedKey.class::cast).toList();
failing.recipeAwards.add(requested); failing.knownRecipes.addAll(requested); return requested.size();
}).when(failing.player).discoverRecipes(anyCollection());
f.players.put(failing.core.id, failing.player); f.players.put(healthy.core.id, healthy.player);
var logs = new java.util.ArrayList<LogRecord>();
var handler = new Handler() {
@Override public void publish(LogRecord record) { logs.add(record); }
@Override public void flush() { }
@Override public void close() { }
};
f.plugin.getLogger().addHandler(handler);
try {
f.plugin.setEnabled(true);
assertTrue(f.recipes.containsKey(CharacterSheets.RECIPE));
assertTrue(failing.knownRecipes.isEmpty());
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), healthy.knownRecipes);
assertTrue(logs.stream().anyMatch(log -> log.getMessage().contains("discover character-sheet recipe")));
f.dispatcher.accept(new PlayerJoinEvent(failing.player, "retry"));
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), failing.knownRecipes);
} finally { f.plugin.getLogger().removeHandler(handler); }
}
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("registration-failure"))) {
var online = new FantasyStickLifecycleNativeTest.Actor(data.resolve("registration-failure"));
f.players.put(online.core.id, online.player); f.failRegistrationAt = "recipe";
assertThrows(IllegalStateException.class, () -> f.plugin.setEnabled(true));
assertTrue(online.knownRecipes.isEmpty());
assertTrue(online.recipeAwards.isEmpty());
assertTrue(f.recipes.isEmpty());
}
}
private static long records(Path directory) throws Exception {
var characters = directory.resolve("characters");
if (!java.nio.file.Files.isDirectory(characters)) return 0;
try (var files = java.nio.file.Files.list(characters)) {
return files.filter(file -> file.getFileName().toString().endsWith(".yml")).count();
}
}
}
@@ -2,9 +2,10 @@ package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;import static org.mockito.ArgumentMatchers.*;import static org.mockito.Mockito.*;import java.nio.file.Path;import java.util.*;import net.kyori.adventure.text.Component;import org.bukkit.event.player.PlayerResourcePackStatusEvent;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class SheetResourcePackNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
record Request(UUID id,String url,byte[] hash,String prompt,boolean required){}
@Test void packagedStartupJoinAndStatusesUseOptionalNativeDeliveryWithoutGameplayMutation()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){var a=new FantasyStickLifecycleNativeTest.Actor(data);a.core.inventory.setItemInOffHand(null);f.players.put(a.core.id,a.player);var before=a.core.snapshot();f.plugin.setEnabled(true);f.awaitCharacterAndReceipt(a);PartyBookNativeTest.drain(f);assertEquals(1,a.resourcePacks.size());var request=request(a.resourcePacks.getFirst());assertEquals(games.dmg.fantasystick.presentation.SheetResourcePack.ID,request.id());assertTrue(request.url().contains("/v0.1.0-SNAPSHOT/purpur-fantasy-stick-0.1.0-SNAPSHOT-resource-pack.zip"));assertEquals(20,request.hash().length);assertFalse(request.required());assertArrayEquals(before,a.core.snapshot());for(var status:List.of(PlayerResourcePackStatusEvent.Status.DECLINED,PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD,PlayerResourcePackStatusEvent.Status.INVALID_URL,PlayerResourcePackStatusEvent.Status.FAILED_RELOAD,PlayerResourcePackStatusEvent.Status.DISCARDED)){f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),status));assertTrue(a.messages.getLast().toLowerCase().contains("cosmetic"));assertArrayEquals(before,a.core.snapshot());}int messages=a.messages.size();f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.ACCEPTED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DOWNLOADED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));assertEquals(messages,a.messages.size());var active=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(active.core.id,active.player);var legacyMeta=active.core.inventory.getItemInOffHand().getItemMeta();legacyMeta.setItemModel(null);active.core.inventory.getItemInOffHand().setItemMeta(legacyMeta);var activeBefore=active.core.snapshot();var expectedMeta=activeBefore[40].getItemMeta();expectedMeta.setItemModel(games.dmg.fantasystick.items.CharacterSheets.MODEL);activeBefore[40].setItemMeta(expectedMeta);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.enter(active.player);f.awaitCharacterAndReceipt(active);f.tick();f.tick();assertNotNull(active.core.marker(),active.messages::toString);assertTrue(active.core.marker().endsWith("|PACKED"));assertEquals(games.dmg.fantasystick.items.CharacterSheets.MODEL,active.core.inventory.getItemInOffHand().getItemMeta().getItemModel());sessions.exit(active.player);f.awaitCharacterAndReceipt(active);assertArrayEquals(activeBefore,active.core.snapshot());var retained=(games.dmg.fantasystick.presentation.SheetResourcePack)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sheetResourcePack");f.plugin.setEnabled(false);retained.status(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DECLINED));assertEquals(messages,a.messages.size());f.plugin.setEnabled(true);var late=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(late.core.id,late.player);f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(late.player,(Component)null));assertEquals(1,late.resourcePacks.size());}}
@Test void packagedStartupJoinAndStatusesUseOptionalNativeDeliveryWithoutGameplayMutation()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){var a=new FantasyStickLifecycleNativeTest.Actor(data);a.core.inventory.setItemInOffHand(null);f.players.put(a.core.id,a.player);var before=a.core.snapshot();f.plugin.setEnabled(true);f.awaitCharacterAndReceipt(a);PartyBookNativeTest.drain(f);assertEquals(1,a.resourcePacks.size());var request=request(a.resourcePacks.getFirst());assertEquals(games.dmg.fantasystick.presentation.SheetResourcePack.ID,request.id());assertTrue(request.url().contains("/v0.1.0-SNAPSHOT/purpur-fantasy-stick-0.1.0-SNAPSHOT-resource-pack.zip"));assertEquals(20,request.hash().length);assertFalse(request.required());assertArrayEquals(before,a.core.snapshot());for(var status:List.of(PlayerResourcePackStatusEvent.Status.DECLINED,PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD,PlayerResourcePackStatusEvent.Status.INVALID_URL,PlayerResourcePackStatusEvent.Status.FAILED_RELOAD,PlayerResourcePackStatusEvent.Status.DISCARDED)){f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),status));assertTrue(a.messages.getLast().toLowerCase().contains("cosmetic"));assertArrayEquals(before,a.core.snapshot());}int messages=a.messages.size();f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.ACCEPTED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DOWNLOADED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));assertEquals(messages,a.messages.size());var active=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(active.core.id,active.player);var legacyMeta=active.core.inventory.getItemInOffHand().getItemMeta();legacyMeta.setItemModel(null);active.core.inventory.getItemInOffHand().setItemMeta(legacyMeta);var activeBefore=active.core.snapshot();var expectedMeta=activeBefore[40].getItemMeta();expectedMeta.setItemModel(games.dmg.fantasystick.items.CharacterSheets.MODEL);activeBefore[40].setItemMeta(expectedMeta);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.enter(active.player);awaitPacked(f,active);assertEquals(games.dmg.fantasystick.items.CharacterSheets.MODEL,active.core.inventory.getItemInOffHand().getItemMeta().getItemModel());sessions.exit(active.player);f.awaitCharacterAndReceipt(active);assertArrayEquals(activeBefore,active.core.snapshot());var retained=(games.dmg.fantasystick.presentation.SheetResourcePack)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sheetResourcePack");f.plugin.setEnabled(false);retained.status(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DECLINED));assertEquals(messages,a.messages.size());f.plugin.setEnabled(true);var late=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(late.core.id,late.player);f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(late.player,(Component)null));assertEquals(1,late.resourcePacks.size());}}
@Test void invalidStartupAndFailedRegisteredWriteNeverDeliverOrChangeEffectiveDefaults()throws Exception{int route=0;for(String value:List.of("sheet-resource-pack-url: 'http://bad.test/x.zip'\n","sheet-resource-pack-sha1: bad\n")){var dir=data.resolve("invalid-"+route++);java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),value);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty(),"native registration order "+f.order);assertTrue(f.tasks.isEmpty(),"queued tasks");}}var failed=data.resolve("failed");java.nio.file.Files.createDirectories(failed);try(var f=new FantasyStickLifecycleNativeTest.Fixture(failed)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(failed);f.players.put(a.core.id,a.player);java.nio.file.Files.createDirectory(failed.resolve("config.yml"));var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/x.zip"});var callback=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(callback);callback.run();assertTrue(sender.messages.getLast().contains("save failed"),sender.messages::toString);assertTrue(a.resourcePacks.isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url"});assertTrue(sender.messages.getLast().contains("git.garvis.dev"));}}
@Test void registeredConfigurationDurablyPublishesAndRedeliversWhileFailureRetainsPair()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);var sender=new EquipmentAdminCommandNativeTest.Sender();assertEquals(List.of("sheet-resource-pack-sha1","sheet-resource-pack-url"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-r"}));assertEquals(1,f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url",""}).size());sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-r"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/sheet.zip"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/x.zip","extra"});assertTrue(sender.messages.getLast().contains("Usage"));for(var pair:List.of(new String[]{"sheet-resource-pack-url","http://bad.test/x.zip"},new String[]{"sheet-resource-pack-sha1","bad"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",pair[0],pair[1]});assertTrue(sender.messages.getLast().contains("invalid"));}save(f,sender,"sheet-resource-pack-url","https://packs.example.test/sheet.zip");assertEquals(1,a.resourcePacks.size());assertEquals("https://packs.example.test/sheet.zip",request(a.resourcePacks.getLast()).url());save(f,sender,"sheet-resource-pack-sha1","a".repeat(40));assertEquals(2,a.resourcePacks.size());assertArrayEquals(java.util.HexFormat.of().parseHex("a".repeat(40)),request(a.resourcePacks.getLast()).hash());f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url"});assertTrue(sender.messages.getLast().contains("https://packs.example.test/sheet.zip"));}}
static void awaitPacked(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{long deadline=System.nanoTime()+java.util.concurrent.TimeUnit.SECONDS.toNanos(10);while(System.nanoTime()<deadline){var marker=actor.core.marker();if(marker!=null&&marker.endsWith("|PACKED"))return;var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();}fail("Session did not activate; notices="+actor.messages);}
static Request request(Object[] args){return new Request((UUID)args[0],(String)args[1],(byte[])args[2],(String)args[3],(Boolean)args[4]);}
static void save(FantasyStickLifecycleNativeTest.Fixture f,EquipmentAdminCommandNativeTest.Sender sender,String key,String value)throws Exception{int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config",key,value});assertEquals(before,sender.messages.size());while(sender.messages.size()==before){var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}assertTrue(sender.messages.getLast().contains("Saved "+key));}
}
@@ -0,0 +1,103 @@
package games.dmg.fantasystick.crafting;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.items.CharacterSheets;
import java.lang.reflect.Proxy;
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 {
@Test void onlineJoinRepeatedFailureAndRetryAreIsolatedAndIdempotent() {
var warnings = new ArrayList<LogRecord>();
var logger = Logger.getAnonymousLogger(); logger.setUseParentHandlers(false);
logger.addHandler(new Handler() {
@Override public void publish(LogRecord record) { warnings.add(record); }
@Override public void flush() { }
@Override public void close() { }
});
var firstKnown = new LinkedHashSet<NamespacedKey>(); var firstAwards = new ArrayList<List<NamespacedKey>>();
var secondKnown = new LinkedHashSet<NamespacedKey>(); var secondAwards = new ArrayList<List<NamespacedKey>>();
var fail = new AtomicBoolean(true);
var first = player("First", firstKnown, firstAwards, fail);
var second = player("Second", secondKnown, secondAwards, new AtomicBoolean(false));
var discovery = new RecipeDiscovery(CharacterSheets.RECIPE, logger);
discovery.discoverAll(List.of(first, second));
discovery.onJoin(new PlayerJoinEvent(first, "joined"));
discovery.onJoin(new PlayerJoinEvent(first, "joined again"));
discovery.discover(second);
assertEquals(Set.of(CharacterSheets.RECIPE), firstKnown);
assertEquals(Set.of(CharacterSheets.RECIPE), secondKnown);
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), firstAwards);
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), secondAwards);
assertEquals(1, warnings.size());
assertTrue(warnings.getFirst().getMessage().contains("First"));
}
@Test void missingNativePostconditionIsLoggedAndRetried() {
var warnings = new ArrayList<LogRecord>();
var logger = Logger.getAnonymousLogger(); logger.setUseParentHandlers(false);
logger.addHandler(new Handler() {
@Override public void publish(LogRecord record) { warnings.add(record); }
@Override public void flush() { }
@Override public void close() { }
});
var known = new LinkedHashSet<NamespacedKey>(); var awards = new ArrayList<List<NamespacedKey>>();
var omit = new AtomicBoolean(true);
var player = proxy("Ada", known, awards, omit);
var discovery = new RecipeDiscovery(CharacterSheets.RECIPE, logger);
discovery.discover(player);
discovery.discover(player);
assertEquals(Set.of(CharacterSheets.RECIPE), known);
assertEquals(List.of(List.of(CharacterSheets.RECIPE), List.of(CharacterSheets.RECIPE)), awards);
assertEquals(1, warnings.size());
}
private static Player player(String name, Set<NamespacedKey> known, List<List<NamespacedKey>> awards,
AtomicBoolean fail) {
return (Player) Proxy.newProxyInstance(Player.class.getClassLoader(), new Class<?>[]{Player.class}, (proxy, method, args) -> {
if (method.getName().equals("getName")) return name;
if (method.getName().equals("hasDiscoveredRecipe")) return known.contains(args[0]);
if (method.getName().equals("discoverRecipes")) {
if (fail.getAndSet(false)) throw new IllegalStateException("simulated discovery failure");
var requested = ((Collection<?>) args[0]).stream().map(NamespacedKey.class::cast).toList();
awards.add(requested); known.addAll(requested); return requested.size();
}
if (method.getName().equals("toString")) return name;
if (method.getName().equals("hashCode")) return System.identityHashCode(proxy);
if (method.getName().equals("equals")) return proxy == args[0];
throw new UnsupportedOperationException(method.getName());
});
}
private static Player proxy(String name, Set<NamespacedKey> known, List<List<NamespacedKey>> awards,
AtomicBoolean omitFirst) {
return (Player) Proxy.newProxyInstance(Player.class.getClassLoader(), new Class<?>[]{Player.class}, (proxy, method, args) -> {
if (method.getName().equals("getName")) return name;
if (method.getName().equals("hasDiscoveredRecipe")) return known.contains(args[0]);
if (method.getName().equals("discoverRecipes")) {
var requested = ((Collection<?>) args[0]).stream().map(NamespacedKey.class::cast).toList();
awards.add(requested); if (!omitFirst.getAndSet(false)) known.addAll(requested); return requested.size();
}
if (method.getName().equals("toString")) return name;
if (method.getName().equals("hashCode")) return System.identityHashCode(proxy);
if (method.getName().equals("equals")) return proxy == args[0];
throw new UnsupportedOperationException(method.getName());
});
}
}