diff --git a/src/main/java/games/dmg/fantasystick/party/PartyManagement.java b/src/main/java/games/dmg/fantasystick/party/PartyManagement.java index 81c1094..2870584 100644 --- a/src/main/java/games/dmg/fantasystick/party/PartyManagement.java +++ b/src/main/java/games/dmg/fantasystick/party/PartyManagement.java @@ -33,7 +33,7 @@ public final class PartyManagement implements AutoCloseable { private void mutateParty(Player player,UUID id,java.util.function.BiFunction> action,Consumer result){var context=context(player,id);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var party=parties.partyOf(context.orElseThrow().record().id()).orElse(null);if(party==null){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}try{action.apply(context.orElseThrow(),party).whenComplete((value,failure)->complete(value,failure,result));}catch(RuntimeException failure){fail(failure,result);}} private void complete(CharacterPartyDirectory.Result value,Throwable failure,Consumer result){if(failure!=null){fail(failure,result);return;}publish(()->result.accept(new Outcome(value.status()==CharacterPartyDirectory.Status.APPLIED?Status.APPLIED:map(value.status()),value.party())));} public int registrationPrice(){return prices.registrationPrice();}public int leaderChangePrice(){return prices.leaderChangePrice();} - public Optional active(Player player,UUID registrar){return context(player,registrar).map(Context::record);}public Optional activeSession(Player player,UUID registrar){return context(player,registrar).map(value->value.active().session());}public boolean eligibleInvitationTarget(UUID character){return parties.partyOf(character).isEmpty()&&sessions.activeCharacter(character).filter(sessions::current).flatMap(sessions::currentRecord).filter(value->!value.dead()).isPresent();}public Optional activeCharacter(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);return active==null?Optional.empty():sessions.currentRecord(active).filter(value->!value.dead());} + public Optional active(Player player,UUID registrar){return context(player,registrar).map(Context::record);}public Optional activeSession(Player player,UUID registrar){return context(player,registrar).map(value->value.active().session());}public Status accessStatus(Player player,UUID registrarId,UUID character,UUID session){var registrar=registrars.find(registrarId).orElse(null);if(!parties.ready()||!recovered||registrar==null)return Status.INVALID_SESSION;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(record==null||!record.id().equals(character)||!active.session().equals(session))return Status.INVALID_SESSION;var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(registrar.world()))return Status.TOO_FAR;double dx=location.getX()-registrar.x(),dy=location.getY()-registrar.y(),dz=location.getZ()-registrar.z();return dx*dx+dy*dy+dz*dz>36?Status.TOO_FAR:Status.APPLIED;}public boolean eligibleInvitationTarget(UUID character){return parties.partyOf(character).isEmpty()&&sessions.activeCharacter(character).filter(sessions::current).flatMap(sessions::currentRecord).filter(value->!value.dead()).isPresent();}public Optional activeCharacter(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);return active==null?Optional.empty():sessions.currentRecord(active).filter(value->!value.dead());} private Optional context(Player player,UUID registrarId){if(!parties.ready()||!recovered)return Optional.empty();var registrar=registrars.find(registrarId).orElse(null);var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(registrar==null||record==null)return Optional.empty();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(registrar.world()))return Optional.empty();double dx=location.getX()-registrar.x(),dy=location.getY()-registrar.y(),dz=location.getZ()-registrar.z();if(dx*dx+dy*dy+dz*dz>36)return Optional.empty();return Optional.of(new Context(active,record,registrar));} private CharacterParty partyForInvitation(UUID invitation){return parties.all().stream().filter(party->party.invitations().stream().anyMatch(value->value.id().equals(invitation))).findFirst().orElse(null);} private games.dmg.fantasystick.events.PartyEventBridge.Action audit(Player player,Context context,String kind,UUID target,UUID invitation,CharacterParty before,BigInteger charge,UUID operation){return audit(player,context,kind,target,invitation,before,charge,operation,"requested");} diff --git a/src/main/java/games/dmg/fantasystick/party/PartyRegistrarMenus.java b/src/main/java/games/dmg/fantasystick/party/PartyRegistrarMenus.java index 5fc37fd..f958033 100644 --- a/src/main/java/games/dmg/fantasystick/party/PartyRegistrarMenus.java +++ b/src/main/java/games/dmg/fantasystick/party/PartyRegistrarMenus.java @@ -9,6 +9,8 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.inventory.PrepareAnvilEvent; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.InventoryHolder; import org.bukkit.inventory.ItemStack; @@ -19,17 +21,24 @@ import org.bukkit.plugin.Plugin; public final class PartyRegistrarMenus implements Listener,AutoCloseable { private enum Screen{ROOT,NAME,INVITATIONS,OUTGOING,INVITE,MEMBERS} private record Holder(UUID registrar,UUID character,UUID session,Screen screen,int page) implements InventoryHolder{public Inventory getInventory(){return null;}} - private final Server server;private final PartyManagement management;private final NamespacedKey actionKey,targetKey;private boolean closed; + private record Submission(UUID player,UUID registrar,UUID character,UUID session){} + private final Server server;private final PartyManagement management;private final NamespacedKey actionKey,targetKey;private final Set submissions=new HashSet<>();private final Map nameScreens=new IdentityHashMap<>();private boolean closed; public PartyRegistrarMenus(Plugin plugin,PartyManagement management){server=plugin.getServer();this.management=Objects.requireNonNull(management);actionKey=new NamespacedKey(plugin,"party_registrar_action");targetKey=new NamespacedKey(plugin,"party_registrar_target");} public void open(Player player,UUID registrar){open(player,registrar,Screen.ROOT,0);} private void open(Player player,UUID registrar,Screen screen,int page){if(closed)return;var record=management.active(player,registrar);if(record.isEmpty()){player.sendMessage("You must be actively playing a living fantasy character beside this Party Registrar.");return;}if(screen==Screen.NAME){openName(player,registrar,record.orElseThrow().id(),management.activeSession(player,registrar).orElseThrow());return;}var inventory=server.createInventory(new Holder(registrar,record.orElseThrow().id(),management.activeSession(player,registrar).orElseThrow(),screen,page),screen==Screen.ROOT?27:54,net.kyori.adventure.text.Component.text("Party Registrar"));var party=management.party(player,registrar);if(screen==Screen.ROOT){if(party.isEmpty()){inventory.setItem(11,item(Material.WRITABLE_BOOK,"Register a party — "+management.registrationPrice()+" coins","register",null));inventory.setItem(15,item(Material.MAP,"Invitations","invitations",null));}else{var group=party.orElseThrow();inventory.setItem(4,item(Material.PAPER,"Party: "+group.displayName(),"info",null));if(group.leader().equals(record.orElseThrow().id())){inventory.setItem(10,item(Material.EMERALD,"Invite active characters","invite",null));inventory.setItem(13,item(Material.PLAYER_HEAD,"Manage members","members",null));inventory.setItem(16,item(Material.BARRIER,"Disband party","disband",null));inventory.setItem(22,item(Material.MAP,"Pending invitations","outgoing",null));}else inventory.setItem(13,item(Material.BARRIER,"Leave party","leave",null));}}else if(screen==Screen.INVITATIONS){var values=management.invitations(player,registrar);fill(inventory,page,values,value->item(Material.MAP,"Invitation (left accept, right decline)","invitation",value.id()));}else if(screen==Screen.OUTGOING){var values=management.outgoingInvitations(player,registrar);fill(inventory,page,values,value->item(Material.MAP,"Cancel invitation for "+value.character(),"cancel-invitation",value.id()));}else if(screen==Screen.INVITE){var values=server.getOnlinePlayers().stream().filter(Player::isOnline).map(target->management.activeCharacter(target).orElse(null)).filter(Objects::nonNull).filter(target->management.eligibleInvitationTarget(target.id())).map(value->value.id()).distinct().sorted().toList();fill(inventory,page,values,value->item(Material.PLAYER_HEAD,"Invite "+value,"invite-target",value));}else{var values=party.map(value->value.members().stream().filter(member->!member.equals(value.leader())).sorted().toList()).orElse(List.of());fill(inventory,page,values,value->item(Material.PLAYER_HEAD,"Member "+value,"member",value,List.of(net.kyori.adventure.text.Component.text("Left click: Kick (free)"),net.kyori.adventure.text.Component.text("Right click: Transfer leadership — "+management.leaderChangePrice()+" coins"))));}player.openInventory(inventory);} private void fill(Inventory inventory,int page,List values,java.util.function.Function item){int start=Math.max(0,page)*45;for(int index=start;index0)inventory.setItem(45,item(Material.ARROW,"Previous","previous",null));if(start+45 lore){var item=new ItemStack(material);var meta=item.getItemMeta();meta.customName(net.kyori.adventure.text.Component.text(name));if(!lore.isEmpty())meta.lore(lore);meta.getPersistentDataContainer().set(actionKey,PersistentDataType.STRING,action);if(target!=null)meta.getPersistentDataContainer().set(targetKey,PersistentDataType.STRING,target.toString());item.setItemMeta(meta);return item;} - @EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof Holder holder)||!(event.getWhoClicked() instanceof Player player))return;event.setCancelled(true);if(closed)return;if(!management.active(player,holder.registrar()).map(value->value.id().equals(holder.character())).orElse(false)||!management.activeSession(player,holder.registrar()).map(holder.session()::equals).orElse(false)){player.sendMessage("Party action refused: the character or session changed.");return;}if(holder.screen()==Screen.NAME&&event.getRawSlot()==2){String name=event.getView() instanceof org.bukkit.inventory.view.AnvilView anvil?anvil.getRenameText():null;if(name!=null)management.register(player,holder.registrar(),name,outcome->respond(player,holder.registrar(),outcome,"Registered party."));return;}var clicked=event.getCurrentItem();if(clicked==null)return;var data=clicked.getItemMeta().getPersistentDataContainer();String action=data.get(actionKey,PersistentDataType.STRING);UUID target=uuid(data.get(targetKey,PersistentDataType.STRING));if("register".equals(action)){open(player,holder.registrar(),Screen.NAME,0);return;}if("invitations".equals(action)){open(player,holder.registrar(),Screen.INVITATIONS,0);return;}if("invite".equals(action)){open(player,holder.registrar(),Screen.INVITE,0);return;}if("outgoing".equals(action)){open(player,holder.registrar(),Screen.OUTGOING,0);return;}if("members".equals(action)){open(player,holder.registrar(),Screen.MEMBERS,0);return;}if("previous".equals(action)){open(player,holder.registrar(),holder.screen(),holder.page()-1);return;}if("next".equals(action)){open(player,holder.registrar(),holder.screen(),holder.page()+1);return;}if("leave".equals(action)){management.leave(player,holder.registrar(),outcome->respond(player,holder.registrar(),outcome,"Left party."));return;}if("disband".equals(action)){management.disband(player,holder.registrar(),outcome->respond(player,holder.registrar(),outcome,"Party ended."));return;}if(target==null)return;if("invite-target".equals(action)){management.invite(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation saved."));return;}if("cancel-invitation".equals(action)){management.cancel(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation cancelled."));return;}if("invitation".equals(action)){if(event.isRightClick())management.decline(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation declined."));else management.accept(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Joined party."));return;}if("member".equals(action)){if(event.isRightClick())management.transfer(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Leadership transferred."));else management.kick(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Member removed."));}} - @EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void drag(org.bukkit.event.inventory.InventoryDragEvent event){if(event.getInventory().getHolder() instanceof Holder)event.setCancelled(true);} + @EventHandler(priority=EventPriority.HIGHEST)public void prepare(PrepareAnvilEvent event){var holder=holder(event.getInventory());if(closed||holder==null||holder.screen()!=Screen.NAME)return;var source=event.getInventory().getItem(0);if(!action(source,"name")){event.setResult(null);return;}var result=source.clone();var meta=result.getItemMeta();String name=event.getView().getRenameText();meta.customName(net.kyori.adventure.text.Component.text(name==null||name.isBlank()?"Submit party name":name));result.setItemMeta(meta);event.setResult(result);free(event.getView());} + @EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void click(InventoryClickEvent event){if(!(event.getWhoClicked() instanceof Player player))return;var holder=holder(event.getInventory());if(holder==null)return;event.setCancelled(true);if(closed)return;var access=management.accessStatus(player,holder.registrar(),holder.character(),holder.session());if(access!=PartyManagement.Status.APPLIED){player.sendMessage(access==PartyManagement.Status.TOO_FAR?"Party action refused: too far from the Party Registrar.":"Party action refused: the character or session changed.");return;}if(holder.screen()==Screen.NAME&&event.getRawSlot()==2){if(!(event.getView() instanceof org.bukkit.inventory.view.AnvilView anvil)||!action(event.getCurrentItem(),"name")){player.sendMessage("Party action refused: the name result is unavailable.");return;}var submission=new Submission(player.getUniqueId(),holder.registrar(),holder.character(),holder.session());if(!submissions.add(submission)){player.sendMessage("Party action refused: submission already in progress.");return;}String name=anvil.getRenameText();try{management.register(player,holder.registrar(),name==null?"":name,outcome->{submissions.remove(submission);respond(player,holder.registrar(),outcome,"Registered party.");});}catch(RuntimeException failure){submissions.remove(submission);throw failure;}return;}var clicked=event.getCurrentItem();if(clicked==null)return;var data=clicked.getItemMeta().getPersistentDataContainer();String action=data.get(actionKey,PersistentDataType.STRING);UUID target=uuid(data.get(targetKey,PersistentDataType.STRING));if("register".equals(action)){open(player,holder.registrar(),Screen.NAME,0);return;}if("invitations".equals(action)){open(player,holder.registrar(),Screen.INVITATIONS,0);return;}if("invite".equals(action)){open(player,holder.registrar(),Screen.INVITE,0);return;}if("outgoing".equals(action)){open(player,holder.registrar(),Screen.OUTGOING,0);return;}if("members".equals(action)){open(player,holder.registrar(),Screen.MEMBERS,0);return;}if("previous".equals(action)){open(player,holder.registrar(),holder.screen(),holder.page()-1);return;}if("next".equals(action)){open(player,holder.registrar(),holder.screen(),holder.page()+1);return;}if("leave".equals(action)){management.leave(player,holder.registrar(),outcome->respond(player,holder.registrar(),outcome,"Left party."));return;}if("disband".equals(action)){management.disband(player,holder.registrar(),outcome->respond(player,holder.registrar(),outcome,"Party ended."));return;}if(target==null)return;if("invite-target".equals(action)){management.invite(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation saved."));return;}if("cancel-invitation".equals(action)){management.cancel(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation cancelled."));return;}if("invitation".equals(action)){if(event.isRightClick())management.decline(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation declined."));else management.accept(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Joined party."));return;}if("member".equals(action)){if(event.isRightClick())management.transfer(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Leadership transferred."));else management.kick(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Member removed."));}} + @EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void drag(org.bukkit.event.inventory.InventoryDragEvent event){if(holder(event.getInventory())!=null)event.setCancelled(true);} + @EventHandler public void close(InventoryCloseEvent event){clearName(event.getInventory());} private void respond(Player player,UUID registrar,PartyManagement.Outcome outcome,String success){if(!player.isOnline())return;if(outcome.status()==PartyManagement.Status.APPLIED){player.sendMessage(success);open(player,registrar);}else player.sendMessage("Party action refused: "+outcome.status().name().toLowerCase(Locale.ROOT).replace('_',' ')+".");} + private Holder holder(Inventory inventory){return inventory.getHolder() instanceof Holder holder?holder:nameScreens.get(inventory);} + private void clearName(Inventory inventory){if(nameScreens.remove(inventory)==null)return;for(int slot:new int[]{0,2})if(action(inventory.getItem(slot),"name"))inventory.setItem(slot,null);} + private boolean action(ItemStack item,String expected){return item!=null&&item.hasItemMeta()&&expected.equals(item.getItemMeta().getPersistentDataContainer().get(actionKey,PersistentDataType.STRING));} + private static void free(org.bukkit.inventory.view.AnvilView anvil){anvil.setRepairItemCountCost(0);anvil.setRepairCost(0);anvil.setMaximumRepairCost(Integer.MAX_VALUE);anvil.setBypassCost(true);} private static UUID uuid(String value){try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}} - @Override public void close(){closed=true;} + @Override public void close(){closed=true;submissions.clear();for(var inventory:List.copyOf(nameScreens.keySet()))clearName(inventory);} } diff --git a/src/nativeTest/java/games/dmg/fantasystick/FantasyStickLifecycleNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/FantasyStickLifecycleNativeTest.java index c8e36a2..9d27d14 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/FantasyStickLifecycleNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/FantasyStickLifecycleNativeTest.java @@ -1468,6 +1468,8 @@ class FantasyStickLifecycleNativeTest { case "callEvent" -> { dispatcher().accept((org.bukkit.event.Event) args[0]); yield null; } default -> throw new AssertionError("Unexpected manager call " + name); }); + when(nativeServer.getPluginManager()).thenReturn(manager); + platform.when(org.bukkit.Bukkit::getPluginManager).thenReturn(manager); var scheduler = port(BukkitScheduler.class, (name, args) -> switch (name) { case "runTaskTimer" -> { if ("presentation-timer".equals(failRegistrationAt)) throw new IllegalStateException("Timer rejected"); @@ -1703,6 +1705,8 @@ class FantasyStickLifecycleNativeTest { final List actionBars = new ArrayList<>(); final List books = new ArrayList<>(); final List inventories = new ArrayList<>(); + final List inventoryViews = new ArrayList<>(); + org.bukkit.inventory.InventoryView openView; int inventoryCloses; final List particles = new ArrayList<>(); final List particleCalls = new ArrayList<>(); @@ -1747,6 +1751,7 @@ class FantasyStickLifecycleNativeTest { when(craftBoard.getHandle()).thenReturn(board); player = own(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class, this)); core.location.setWorld(world); + when(nativePlayer.getBukkitEntity()).thenReturn(player); when(nativePlayer.getId()).thenReturn(42); when(nativePlayer.getUUID()).thenReturn(core.id); when(nativePlayer.getTabListDisplayName()).thenReturn(net.minecraft.network.chat.Component.literal("Ordinary tab")); @@ -1854,8 +1859,13 @@ class FantasyStickLifecycleNativeTest { yield null; } case "openBook" -> { books.add(((org.bukkit.inventory.ItemStack) args[0]).clone()); yield null; } - case "openInventory" -> { inventories.add((org.bukkit.inventory.Inventory) args[0]); yield core.view; } - case "closeInventory" -> { inventoryCloses++; yield null; } + case "getOpenInventory" -> openView == null ? core.view : openView; + case "openInventory" -> { + if (args[0] instanceof org.bukkit.inventory.Inventory inventory) { inventories.add(inventory); openView=null; yield core.view; } + var opened = (org.bukkit.inventory.InventoryView) args[0]; inventoryViews.add(opened); + inventories.add(opened.getTopInventory()); openView=opened; yield null; + } + case "closeInventory" -> { inventoryCloses++; openView=null; yield null; } case "spawnParticle" -> { particleCalls.add(args.clone()); 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 "showTitle" -> { titles.add((net.kyori.adventure.title.Title)args[0]); yield null; } diff --git a/src/nativeTest/java/games/dmg/fantasystick/PartyRegistrarNameSubmissionNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/PartyRegistrarNameSubmissionNativeTest.java new file mode 100644 index 0000000..fca714d --- /dev/null +++ b/src/nativeTest/java/games/dmg/fantasystick/PartyRegistrarNameSubmissionNativeTest.java @@ -0,0 +1,296 @@ +package games.dmg.fantasystick; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import games.dmg.fantasystick.items.CharacterSheets; +import games.dmg.fantasystick.party.CharacterPartyDirectory; +import games.dmg.fantasystick.party.PartyManagement; +import games.dmg.fantasystick.party.PartyRegistrar; +import games.dmg.fantasystick.party.PartyRegistrarController; +import games.dmg.fantasystick.party.PartyRegistrarDirectory; +import games.dmg.fantasystick.persistence.CharacterRecord; +import games.dmg.fantasystick.persistence.CharacterStorage; +import games.dmg.fantasystick.persistence.YamlCharacterRepository; +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; +import net.minecraft.world.inventory.AnvilMenu; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Villager; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.MenuType; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.inventory.view.AnvilView; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PartyRegistrarNameSubmissionNativeTest { + @TempDir Path data; + + @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + + @Test void registeredNativeResultIsFreeAndCreatesOnePartyAtZeroVanillaExperience() throws Exception { + try (var path = new RegistrarPath(data.resolve("success"), BigInteger.valueOf(20))) { + var view = path.openName(); + int paperBefore = paperCount(path.actor.core.inventory); + path.actor.core.inventory.setItem(7, new ItemStack(Material.DIAMOND, 3)); + var unrelatedBefore = path.actor.core.inventory.getItem(7).clone(); + var inputBefore = view.getTopInventory().getItem(0).clone(); + + var result = path.prepare(view, " Cafe\u0301 Heroes "); + assertEquals(0, view.getRepairCost()); + assertEquals(0, view.getRepairItemCountCost()); + assertTrue(view.canBypassCost()); + assertNotNull(result, "The registered native prepare path must make the result available"); + assertEquals(Material.PAPER, result.getType()); + assertNotNull(view.getTopInventory().getLocation()); + var nativeMenu = assertInstanceOf(AnvilMenu.class, + ((org.bukkit.craftbukkit.inventory.CraftInventoryView) view).getHandle()); + assertFalse(nativeMenu.checkReachable, "The virtual text-entry menu must not require a physical anvil block"); + + var priorCancellation = path.submit(view, true); + assertTrue(priorCancellation.isCancelled()); + path.assertUnchanged(BigInteger.valueOf(20)); + var first = path.submit(view); + var repeated = path.submit(view); + assertTrue(first.isCancelled(), "The plugin owns result submission instead of native taking"); + assertTrue(repeated.isCancelled()); + assertTrue(path.actor.messages.stream().anyMatch(message -> message.contains("already in progress")), + path.actor.messages::toString); + + path.await(() -> path.parties.partyOf(path.actor.core.record.id()).isPresent() + && path.actor.messages.contains("Registered party.")); + var party = path.parties.partyOf(path.actor.core.record.id()).orElseThrow(); + assertEquals("Café Heroes", party.displayName()); + assertEquals(path.actor.core.record.id(), party.leader()); + assertEquals(1, path.parties.all().size()); + assertEquals(BigInteger.TEN, path.characters.find(path.actor.core.record.id()).get().orElseThrow().coins()); + assertEquals(0, path.actor.player.getLevel()); + verify(path.actor.player, never()).setLevel(anyInt()); + assertEquals(paperBefore, paperCount(path.actor.core.inventory)); + assertEquals(unrelatedBefore, path.actor.core.inventory.getItem(7)); + assertTrue(path.actor.core.cursor == null || path.actor.core.cursor.getType().isAir()); + assertEquals(inputBefore, view.getTopInventory().getItem(0)); + assertEquals(result, view.getTopInventory().getItem(2)); + assertEquals(Material.PAPER, view.getTopInventory().getItem(2).getType(), + "The cancelled plugin control remains in the view until the view closes"); + assertTrue(path.actor.inventories.getLast().getItem(4).getItemMeta().getDisplayName().contains("Café Heroes")); + path.fixture.dispatcher.accept(new org.bukkit.event.inventory.InventoryCloseEvent(view)); + assertNull(view.getTopInventory().getItem(0)); assertNull(view.getTopInventory().getItem(2)); + nativeMenu.removed(path.actor.nativePlayer); + assertEquals(0, path.actor.nativePlayer.getInventory().getContents().stream() + .filter(item -> item.is(net.minecraft.world.item.Items.PAPER)).mapToInt(net.minecraft.world.item.ItemStack::getCount).sum()); + } + } + + @Test void invalidDuplicateInsufficientDistantAndStaleSubmissionsRefuseWithoutCharging() throws Exception { + try (var path = new RegistrarPath(data.resolve("invalid"), BigInteger.valueOf(20))) { + var view = path.openName(); view.getTopInventory().setItem(2, null); path.submit(view); + assertTrue(path.actor.messages.stream().anyMatch(message -> message.contains("result is unavailable")), path.actor.messages::toString); + path.prepare(view, " "); path.submit(view); + path.prepare(view, "x".repeat(33)); path.submit(view); + assertEquals(2, path.actor.messages.stream().filter(message -> message.contains("invalid name")).count(), path.actor.messages::toString); + path.assertUnchanged(BigInteger.valueOf(20)); + } + try (var path = new RegistrarPath(data.resolve("duplicate"), BigInteger.valueOf(20))) { + path.parties.create(UUID.randomUUID(), UUID.randomUUID(), "Café Heroes").get(); + var view = path.openName(); path.prepare(view, " cafe\u0301 heroes "); path.submit(view); + assertTrue(path.actor.messages.stream().anyMatch(message -> message.contains("name taken")), path.actor.messages::toString); + assertTrue(path.parties.partyOf(path.actor.core.record.id()).isEmpty()); + assertEquals(BigInteger.valueOf(20), path.characters.find(path.actor.core.record.id()).get().orElseThrow().coins()); + } + try (var path = new RegistrarPath(data.resolve("insufficient"), BigInteger.valueOf(3))) { + var view = path.openName(); path.prepare(view, "Poor Heroes"); path.submit(view); + path.await(() -> path.actor.messages.stream().anyMatch(message -> message.contains("insufficient"))); + path.assertUnchanged(BigInteger.valueOf(3)); + } + try (var path = new RegistrarPath(data.resolve("distant"), BigInteger.valueOf(20))) { + var view = path.openName(); path.prepare(view, "Far Heroes"); + path.actor.core.location.setX(path.actor.core.location.getX() + 100); + path.submit(view); + assertTrue(path.actor.messages.stream().anyMatch(message -> message.contains("too far from the Party Registrar")), + path.actor.messages::toString); + path.assertUnchanged(BigInteger.valueOf(20)); + } + try (var path = new RegistrarPath(data.resolve("stale"), BigInteger.valueOf(20))) { + var stale = path.openName(); path.prepare(stale, "Stale Heroes"); + path.actor.core.inventory.setItemInOffHand(null); path.fixture.changed(path.actor); + PartyBookNativeTest.drain(path.fixture); path.fixture.awaitCharacterAndReceipt(path.actor); + var current = new YamlCharacterRepository(path.directory).load(path.actor.core.record.id()).orElseThrow(); + path.actor.core.inventory.setItemInOffHand(new CharacterSheets().create(current)); path.fixture.activate(path.actor); + path.submit(stale); + assertTrue(path.actor.messages.stream().anyMatch(message -> message.contains("character or session changed")), + path.actor.messages::toString); + path.assertUnchanged(BigInteger.valueOf(20)); + } + } + + @Test void failedPartySaveRefundsTheCoinAndARegisteredRetrySucceedsOnce() throws Exception { + try (var path = new RegistrarPath(data.resolve("save-failure"), BigInteger.valueOf(20))) { + var partyFile = path.directory.resolve("parties.yml"); + assertTrue(Files.notExists(partyFile)); Files.createDirectory(partyFile); + var view = path.openName(); path.prepare(view, "Recovered Heroes"); path.submit(view); + path.await(() -> path.actor.messages.stream().anyMatch(message -> message.contains("failed"))); + path.assertUnchanged(BigInteger.valueOf(20)); + assertTrue(Files.isDirectory(partyFile)); Files.delete(partyFile); + + path.reenter(); view = path.openName(); + path.prepare(view, "Recovered Heroes"); path.submit(view); + path.await(() -> path.parties.partyOf(path.actor.core.record.id()).isPresent() + && path.actor.messages.contains("Registered party.")); + assertEquals(1, path.parties.all().size()); + assertEquals(BigInteger.TEN, path.characters.find(path.actor.core.record.id()).get().orElseThrow().coins()); + } + try (var path = new RegistrarPath(data.resolve("coin-failure"), BigInteger.valueOf(20))) { + var characterFile = path.directory.resolve("characters").resolve(path.actor.core.record.id() + ".yml"); + var backup = path.directory.resolve("character-backup.yml"); + Files.move(characterFile, backup); Files.createDirectory(characterFile); + var view = path.openName(); path.prepare(view, "Coin Recovery"); path.submit(view); + path.await(() -> path.actor.messages.stream().anyMatch(message -> message.contains("error"))); + assertTrue(path.parties.partyOf(path.actor.core.record.id()).isEmpty()); + assertTrue(Files.isDirectory(characterFile)); Files.delete(characterFile); Files.move(backup, characterFile); + path.assertUnchanged(BigInteger.valueOf(20)); + + path.prepare(view, "Coin Recovery Retry"); path.submit(view); + path.await(() -> path.parties.partyOf(path.actor.core.record.id()).isPresent() + && path.actor.messages.contains("Registered party.")); + assertEquals(1, path.parties.all().size()); + assertEquals(BigInteger.TEN, path.characters.find(path.actor.core.record.id()).get().orElseThrow().coins()); + } + } + + private static int paperCount(PlayerInventory inventory) { + int count = 0; + for (var item : inventory.getContents()) if (item != null && item.getType() == Material.PAPER) count += item.getAmount(); + return count; + } + + private static final class RegistrarPath implements AutoCloseable { + final Path directory; + final FantasyStickLifecycleNativeTest.Fixture fixture; + final FantasyStickLifecycleNativeTest.Actor actor; + final PartyRegistrar registrar; + final CharacterPartyDirectory parties; + final CharacterStorage characters; + final Villager villager; + + RegistrarPath(Path directory, BigInteger coins) throws Exception { + this.directory = directory; + fixture = new FantasyStickLifecycleNativeTest.Fixture(directory); fixture.plugin.setEnabled(true); + actor = new FantasyStickLifecycleNativeTest.Actor(directory); fixture.players.put(actor.core.id, actor.player); + when(actor.nativePlayer.getInventory()).thenReturn(new net.minecraft.world.entity.player.Inventory(actor.nativePlayer, + new net.minecraft.world.entity.EntityEquipment())); + doReturn(0).when(actor.player).getLevel(); doReturn(true).when(actor.player).isOnline(); + var repository = new YamlCharacterRepository(directory); var old = repository.load(actor.core.record.id()).orElseThrow(); + repository.replace(withCoins(old, coins), old.revision()); fixture.activate(actor); + var management = (PartyManagement) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "partyManagement"); + management.ready().toCompletableFuture().get(5, TimeUnit.SECONDS); + var registrations = (PartyRegistrarDirectory) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "partyRegistrars"); + var location = actor.core.location; registrar = registrations.place("Central", actor.world.getUID(), location.getX(), + location.getY(), location.getZ(), location.getYaw(), location.getPitch()).get(5, TimeUnit.SECONDS); + parties = (CharacterPartyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characterParties"); + characters = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters"); + villager = mock(Villager.class); when(villager.getLocation()).thenReturn(location); + when(villager.getUniqueId()).thenReturn(UUID.randomUUID()); + var values = new HashMap(); values.put("managed_npc_type", "party_registrar"); + values.put("party_registrar_id", registrar.id().toString()); + var pdc = mock(PersistentDataContainer.class); when(villager.getPersistentDataContainer()).thenReturn(pdc); + when(pdc.get(any(NamespacedKey.class), eq(PersistentDataType.STRING))) + .thenAnswer(call -> values.get(((NamespacedKey) call.getArgument(0)).getKey())); + interact(); + } + + void interact() { + var interaction = new PlayerInteractEntityEvent(actor.player, villager, EquipmentSlot.HAND); + fixture.dispatcher.accept(interaction); assertTrue(interaction.isCancelled()); + } + + void reenter() throws Exception { + actor.core.inventory.setItemInOffHand(null); fixture.changed(actor); + PartyBookNativeTest.drain(fixture); fixture.awaitCharacterAndReceipt(actor); + var current = new YamlCharacterRepository(directory).load(actor.core.record.id()).orElseThrow(); + actor.core.inventory.setItemInOffHand(new CharacterSheets().create(current)); fixture.activate(actor); interact(); + } + + AnvilView openName() { + var root = actor.inventories.getLast(); + var rootClick = new InventoryClickEvent(view(actor, root), InventoryType.SlotType.CONTAINER, 11, + ClickType.LEFT, InventoryAction.PICKUP_ALL); + fixture.dispatcher.accept(rootClick); assertTrue(rootClick.isCancelled()); + var opened = actor.inventoryViews.getLast(); + assertInstanceOf(org.bukkit.craftbukkit.inventory.view.CraftAnvilView.class, opened); + assertEquals(org.bukkit.inventory.MenuType.ANVIL, opened.getMenuType()); + return (AnvilView) opened; + } + + ItemStack prepare(AnvilView view, String name) { + var nativeMenu = assertInstanceOf(AnvilMenu.class, + ((org.bukkit.craftbukkit.inventory.CraftInventoryView) view).getHandle()); + assertTrue(nativeMenu.setItemName(name) || name.equals(view.getRenameText()), "Native rename input was rejected"); + return view.getTopInventory().getItem(2); + } + + InventoryClickEvent submit(AnvilView view) { return submit(view, false); } + + InventoryClickEvent submit(AnvilView view, boolean cancelled) { + var event = new InventoryClickEvent(view, InventoryType.SlotType.RESULT, 2, + ClickType.LEFT, InventoryAction.PICKUP_ALL); + event.setCancelled(cancelled); fixture.dispatcher.accept(event); return event; + } + + void assertUnchanged(BigInteger coins) throws Exception { + characters.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS); parties.defeat(UUID.randomUUID()).get(5, TimeUnit.SECONDS); + assertTrue(parties.partyOf(actor.core.record.id()).isEmpty()); + assertEquals(coins, characters.find(actor.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow().coins()); + } + + void await(BooleanSupplier condition) throws Exception { + for (int attempt = 0; attempt < 160 && !condition.getAsBoolean(); attempt++) { + var task = fixture.tasks.poll(25, TimeUnit.MILLISECONDS); if (task != null) task.run(); + if (!condition.getAsBoolean()) Thread.sleep(5); + } + assertTrue(condition.getAsBoolean(), () -> "condition not reached; messages=" + actor.messages + "; parties=" + parties.all()); + } + + @Override public void close() throws java.io.IOException { fixture.close(); } + } + + private static CharacterRecord withCoins(CharacterRecord old, BigInteger coins) { + return new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(), + old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(), + old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(), coins, true, old.rewardReceipts(), old.pvpCoinEscrow(), + old.gear(), old.sheetGeneration()); + } + + private static org.bukkit.inventory.InventoryView view(FantasyStickLifecycleNativeTest.Actor actor, Inventory top) { + return new org.bukkit.craftbukkit.inventory.CraftAbstractInventoryView() { + @Override public Inventory getTopInventory() { return top; } + @Override public PlayerInventory getBottomInventory() { return actor.core.inventory; } + @Override public org.bukkit.entity.HumanEntity getPlayer() { return actor.player; } + @Override public InventoryType getType() { return top.getType(); } + @Override public ItemStack getCursor() { return actor.core.cursor; } + @Override public void setCursor(ItemStack item) { actor.core.cursor = item; } + @Override public String getTitle() { return "Party Registrar"; } + @Override public String getOriginalTitle() { return getTitle(); } + @Override public void setTitle(String title) { throw new AssertionError("Unexpected title mutation"); } + @Override public MenuType getMenuType() { return null; } + }; + } +} diff --git a/src/nativeTest/java/games/dmg/fantasystick/PartyRegistrarNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/PartyRegistrarNativeTest.java index 31e20b2..03694af 100644 --- a/src/nativeTest/java/games/dmg/fantasystick/PartyRegistrarNativeTest.java +++ b/src/nativeTest/java/games/dmg/fantasystick/PartyRegistrarNativeTest.java @@ -45,7 +45,7 @@ class PartyRegistrarNativeTest { @Test void adminPlacementReconcilesProtectedVillagerAndActiveCharacterCanPayToRegister()throws Exception{ UUID invitedPlayer,invitedCharacter,invitedSession; try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){ - f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player); + f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);when(actor.nativePlayer.getInventory()).thenReturn(new net.minecraft.world.entity.player.Inventory(actor.nativePlayer,new net.minecraft.world.entity.EntityEquipment())); doReturn(true).when(actor.player).hasPermission("fantasystick.admin");UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId); int chunkX=actor.core.location.getBlockX()>>4,chunkZ=actor.core.location.getBlockZ()>>4;var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(chunkX);when(chunk.getZ()).thenReturn(chunkZ);when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[0]);when(actor.world.getChunkAt(chunkX,chunkZ)).thenReturn(chunk);when(actor.world.getChunkAt(any(Location.class))).thenReturn(chunk);when(actor.world.isChunkLoaded(chunkX,chunkZ)).thenReturn(true);var worldEntities=new ArrayList();when(actor.world.getEntities()).thenAnswer(call->List.copyOf(worldEntities)); var villager=mock(Villager.class);when(villager.getUniqueId()).thenReturn(UUID.randomUUID());when(villager.getLocation()).thenReturn(actor.core.location);var values=new HashMap();var pdc=mock(PersistentDataContainer.class);when(villager.getPersistentDataContainer()).thenReturn(pdc);doAnswer(call->{values.put(((NamespacedKey)call.getArgument(0)).getKey(),call.getArgument(2));return null;}).when(pdc).set(any(NamespacedKey.class),eq(PersistentDataType.STRING),anyString());when(pdc.get(any(NamespacedKey.class),eq(PersistentDataType.STRING))).thenAnswer(call->values.get(((NamespacedKey)call.getArgument(0)).getKey())); @@ -54,7 +54,7 @@ class PartyRegistrarNativeTest { var directory=(games.dmg.fantasystick.party.PartyRegistrarDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrars");var registrar=directory.all().getFirst();assertEquals("Central",registrar.name()); var old=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,null,null,BigInteger.valueOf(20),true,old.rewardReceipts()),old.revision());f.activate(actor);var management=(games.dmg.fantasystick.party.PartyManagement)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyManagement");management.ready().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);assertTrue(management.active(actor.player,registrar.id()).isPresent());f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-registration-price","7"});for(int attempt=0;attempt<10&&actor.messages.stream().noneMatch(message->message.contains("Saved party-registration-price=7"));attempt++){response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();}f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-leader-change-price","6"});for(int attempt=0;attempt<10&&actor.messages.stream().noneMatch(message->message.contains("Saved party-leader-change-price=6"));attempt++){response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();}f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-registration-price"});f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-leader-change-price"});var controller=(games.dmg.fantasystick.party.PartyRegistrarController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarController");assertTrue(controller.tagged(villager)); int openMenus=actor.inventories.size(),openBooks=actor.books.size();var mainInteraction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);f.dispatcher.accept(mainInteraction);var offhandDuplicate=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.OFF_HAND);f.dispatcher.accept(offhandDuplicate);assertTrue(mainInteraction.isCancelled());assertTrue(offhandDuplicate.isCancelled());assertEquals(openMenus+1,actor.inventories.size());assertEquals(openBooks,actor.books.size());assertEquals(Material.WRITABLE_BOOK,actor.inventories.getLast().getItem(11).getType());var vetoedInteraction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);vetoedInteraction.setCancelled(true);f.dispatcher.accept(vetoedInteraction);assertEquals(openMenus+1,actor.inventories.size());assertEquals(openBooks,actor.books.size()); - var menus=(games.dmg.fantasystick.party.PartyRegistrarMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarMenus");var root=actor.inventories.getLast();assertEquals(net.kyori.adventure.text.Component.text("Register a party — 7 coins"),root.getItem(11).getItemMeta().displayName());var openName=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(openName.getInventory()).thenReturn(root);when(openName.getWhoClicked()).thenReturn(actor.player);when(openName.getCurrentItem()).thenReturn(root.getItem(11));menus.click(openName);var anvil=actor.inventories.getLast();var submit=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(submit.getInventory()).thenReturn(anvil);when(submit.getWhoClicked()).thenReturn(actor.player);when(submit.getRawSlot()).thenReturn(2);var anvilView=mock(org.bukkit.inventory.view.AnvilView.class);when(anvilView.getRenameText()).thenReturn(" Cafe\u0301 Heroes ");when(submit.getView()).thenReturn(anvilView);menus.click(submit);var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");characters.find(UUID.randomUUID()).join();var parties=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals("Café Heroes",parties.partyOf(old.id()).orElseThrow().displayName());assertEquals(BigInteger.valueOf(13),characters.find(old.id()).join().orElseThrow().coins()); + var menus=(games.dmg.fantasystick.party.PartyRegistrarMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarMenus");var root=actor.inventories.getLast();assertEquals(net.kyori.adventure.text.Component.text("Register a party — 7 coins"),root.getItem(11).getItemMeta().displayName());var openName=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(openName.getInventory()).thenReturn(root);when(openName.getWhoClicked()).thenReturn(actor.player);when(openName.getCurrentItem()).thenReturn(root.getItem(11));menus.click(openName);var anvilView=(org.bukkit.inventory.view.AnvilView)actor.inventoryViews.getLast();var nativeMenu=(net.minecraft.world.inventory.AnvilMenu)((org.bukkit.craftbukkit.inventory.CraftInventoryView)anvilView).getHandle();assertTrue(nativeMenu.setItemName(" Cafe\u0301 Heroes "));var submit=new org.bukkit.event.inventory.InventoryClickEvent(anvilView,org.bukkit.event.inventory.InventoryType.SlotType.RESULT,2,org.bukkit.event.inventory.ClickType.LEFT,org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);f.dispatcher.accept(submit);assertTrue(submit.isCancelled());var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");characters.find(UUID.randomUUID()).join();var parties=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals("Café Heroes",parties.partyOf(old.id()).orElseThrow().displayName());assertEquals(BigInteger.valueOf(13),characters.find(old.id()).join().orElseThrow().coins()); var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(actor.world);f.players.put(member.core.id,member.player);f.activate(member);invitedPlayer=member.core.id;invitedCharacter=member.core.record.id();invitedSession=((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(member.core.id).orElseThrow().session();var leaderRoot=actor.inventories.getLast();click(menus,actor.player,leaderRoot,10,false);var eligible=actor.inventories.getLast();click(menus,actor.player,eligible,0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();click(menus,actor.player,actor.inventories.getLast(),22,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.invitationsFor(member.core.record.id()).isEmpty());click(menus,actor.player,actor.inventories.getLast(),10,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));var memberRoot=member.inventories.getLast();click(menus,member.player,memberRoot,15,false);var invitationMenu=member.inventories.getLast();click(menus,member.player,invitationMenu,0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.partyOf(member.core.record.id()).isPresent());controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),13,false);assertEquals(List.of(net.kyori.adventure.text.Component.text("Left click: Kick (free)"),net.kyori.adventure.text.Component.text("Right click: Transfer leadership — 6 coins")),actor.inventories.getLast().getItem(0).getItemMeta().lore());click(menus,actor.player,actor.inventories.getLast(),0,true);characters.find(UUID.randomUUID()).join();parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals(member.core.record.id(),parties.partyOf(old.id()).orElseThrow().leader());assertEquals(BigInteger.valueOf(7),characters.find(old.id()).join().orElseThrow().coins()); controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));click(menus,member.player,member.inventories.getLast(),13,false);click(menus,member.player,member.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.partyOf(old.id()).isEmpty());controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));click(menus,member.player,member.inventories.getLast(),10,false);click(menus,member.player,member.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),15,false);click(menus,actor.player,actor.inventories.getLast(),0,true);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));click(menus,member.player,member.inventories.getLast(),10,false);click(menus,member.player,member.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),15,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),13,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));click(menus,member.player,member.inventories.getLast(),16,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.partyOf(member.core.record.id()).isEmpty()); f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"party-registrar","list"});assertTrue(actor.messages.stream().anyMatch(message->message.contains(registrar.id().toString())&&message.contains("Central")));when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[]{villager});controller.reconcile(chunk);verify(actor.world,times(1)).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));var damage=new org.bukkit.event.entity.EntityDamageEvent(villager,org.bukkit.event.entity.EntityDamageEvent.DamageCause.FIRE,1);controller.damage(damage);assertTrue(damage.isCancelled());var combust=new org.bukkit.event.entity.EntityCombustEvent(villager,5);controller.combust(combust);assertTrue(combust.isCancelled());var interaction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);controller.interact(interaction);assertTrue(interaction.isCancelled());var leash=new org.bukkit.event.entity.PlayerLeashEntityEvent(villager,actor.player,actor.player,EquipmentSlot.HAND);controller.leash(leash);assertTrue(leash.isCancelled());