In this section, we'll create an item that incorporates all the concepts discussed in this section so far.
The Baguette of Wisdom
The idea
Studies have shown that those who eat baguettes have a 68% higher IQ on average. Let's use this as inspiration for our addon. We'll create a 'Baguette of Wisdom' which allows you to transfer experience from one player to another.
Here's the plan:
The baguette has a maximum XP capacity
You can right click with the baguette to 'charge' it with experience
You can shift right click with the baguette to discharge the experience
To do this, we're going to need to keep track of how much experience the baguette has inside of it.
item:baguette_of_wisdom:name:"BaguetteofWisdom"lore:|-<arrow> Use the power of baguettes to transfer XP<arrow> <insn>Right click</insn> to charge with XP<arrow> <insn>Shift right click</insn> to discharge stored XP<arrow> <attr>XP capacity:</attr> %xp_capacity%<arrow> <attr>Stored XP:</attr> %stored_xp%
publicclassBaguetteOfWisdomextendsPylonItem{privatefinalintxpCapacity=getSettings().getOrThrow("xp-capacity",ConfigAdapter.INT);publicBaguetteOfWisdom(@NotNullItemStackstack){super(stack);}@Overridepublic@NotNullList<PylonArgument>getPlaceholders(){returnList.of(PylonArgument.of("xp_capacity",UnitFormat.EXPERIENCE.format(xpCapacity))// TODO add stored_xp placeholder);}}
classBaguetteOfWisdom(stack:ItemStack):PylonItem(stack){privatevalxpCapacity:Int=settings.getOrThrow("xp-capacity",ConfigAdapter.class)overridefungetPlaceholders()=listOf(PylonArgument.of("xp_capacity",UnitFormat.EXPERIENCE.format(xpCapacity))// TODO add stored_xp placeholder)}
All of this should be familiar from the advanced lore section.
The charge/discharge mechanic
Next, let's allow the player to charge by right clicking, and discharge by shift right clicking.
publicclassBaguetteOfWisdomextendsPylonItemimplementsPylonInteractor{privatefinalintxpCapacity=getSettings().getOrThrow("xp-capacity",ConfigAdapter.INT);publicBaguetteOfWisdom(@NotNullItemStackstack){super(stack);}@Overridepublic@NotNullList<PylonArgument>getPlaceholders(){returnList.of(PylonArgument.of("xp_capacity",UnitFormat.EXPERIENCE.format(xpCapacity))// TODO add stored_xp placeholder);}@OverridepublicvoidonUsedToRightClick(@NotNullPlayerInteractEventevent){if(event.getPlayer().isSneaking()){// TODO discharge logic}else{// TODO charge logic}}}
classBaguetteOfWisdom(stack:ItemStack):PylonItem(stack),PylonInteractor{privatevalxpCapacity:Int=settings.getOrThrow("xp-capacity",ConfigAdapter.class)overridefungetPlaceholders()=listOf(PylonArgument.of("xp_capacity",UnitFormat.EXPERIENCE.format(xpCapacity))// TODO add stored_xp placeholder)overridefunonUsedToRightClick(event:PlayerInteractEvent){if(event.player.isSneaking){// TODO discharge logic}else{// TODO charge logic}}}
The charge logic
Let's now do the charge logic. In order to charge a Baguette of Wisdom, we need to store its charge level. As mentioned beforehand, we can use the item's persistent data container to do this. To start with, let's just set the charge level to 50:
@OverridepublicvoidonUsedToRightClick(@NotNullPlayerInteractEventevent){if(event.getPlayer().isSneaking()){// TODO discharge logic}else{getStack().editPersistentDataContainer(pdc->pdc.set(newNamespacedKey(MyAddon.getInstance(),"stored_xp"),PylonSerializers.INTEGER,50));}}
overridefunonUsedToRightClick(event:PlayerInteractEvent){if(event.player.isSneaking){// TODO discharge logic}else{stack.editPersistentDataContainer{pdc->pdc.set(NamespacedKey(MyAddon.instance,"stored_xp"),PylonSerializers.INTEGER,50)}}}
As you can see, we need to provide three things to set a PDC value: the key, the serializer, and the value.
The serializer is just a 'thing' that describes how to convert your type into a more primitive type that can be stored on disk - we won't go into details. You need a serializer for every type that you want to store - so you can't store, for example, MyAddon in a persistent data container as there is no serializer for it and it doesn't make sense to create one anyway.
@OverridepublicvoidonUsedToRightClick(@NotNullPlayerInteractEventevent){if(event.getPlayer().isSneaking()){// TODO discharge logic}else{// 1. Read how much XP we already have storedintxp=getStack().getPersistentDataContainer().get(newNamespacedKey(MyAddon.getInstance(),"stored_xp"),PylonSerializers.INTEGER);// 2. Figure out how much XP we need to take to get to `xpCapacity`intextraXpNeeded=xpCapacity-xp;// 3. Take as much XP from the player as we can to get thereintxpToTake=Math.min(event.getPlayer().calculateTotalExperiencePoints(),extraXpNeeded);event.getPlayer().giveExp(-xpToTake);// 4. Set the new stored XP amountgetStack().editPersistentDataContainer(pdc->pdc.set(newNamespacedKey(MyAddon.getInstance(),"stored_xp"),PylonSerializers.INTEGER,xp+xpToTake));}}
overridefunonUsedToRightClick(event:PlayerInteractEvent){if(event.player.isSneaking){// TODO discharge logic}else{// 1. Read how much XP we already have storedvalxp=stack.persistentDataContainer.get(NamespacedKey(MyAddon.instance,"stored_xp"),PylonSerializers.INTEGER)!!// 2. Figure out how much XP we need to take to get to `xpCapacity`valextraXpNeeded=xpCapacity-xp// 3. Take as much XP from the player as we can to get therevalxpToTake=min(event.player.calculateTotalExperiencePoints(),extraXpNeeded)event.player.giveExp(-xpToTake)// 4. Set the new stored XP amountstack.editPersistentDataContainer{pdc->pdc.set(NamespacedKey(MyAddon.instance,"stored_xp"),PylonSerializers.INTEGER,xp+xpToTake)}}}
The discharge logic
And now for the discharge logic, which is quite similar:
@OverridepublicvoidonUsedToRightClick(@NotNullPlayerInteractEventevent){if(event.getPlayer().isSneaking()){// 1. Read how much XP we have storedintxp=getStack().getPersistentDataContainer().get(newNamespacedKey(MyAddon.getInstance(),"stored_xp"),PylonSerializers.INTEGER);// 2. Give all the XP to the playerevent.getPlayer().giveExp(xp);// 3. Set the stored XP to 0getStack().editPersistentDataContainer(pdc->pdc.set(newNamespacedKey(MyAddon.getInstance(),"stored_xp"),PylonSerializers.INTEGER,0));}else{...}}
overridefunonUsedToRightClick(event:PlayerInteractEvent){if(event.player.isSneaking){// 1. Read how much XP we have storedvalxp=stack.persistentDataContainer.get(NamespacedKey(MyAddon.instance,"stored_xp"),PylonSerializers.INTEGER)!!// 2. Give all the XP to the playerevent.player.giveExp(xp)// 3. Set the stored XP to 0stack.editPersistentDataContainer{pdc->pdc.set(NamespacedKey(MyAddon.instance,"stored_xp"),PylonSerializers.INTEGER,0)}}else{...}}
Adding a placeholder
Finally, let's add in the placeholder for the stored charge:
Now let's start the server and test our glorious new item. Try giving yourself a Baguette of Wisdom.
Obviously, as I am an expert programmer, it will work perfectly the first time and nothing will go wro- huh?
What nonsense is this? Have the brits sabotaged us again?!
When something like this happens, your first port of call should always be the server console to see if any errors have been logged. And indeed, if you have a look in the console you should find the following error:
[12:51:22 WARN]: java.lang.NullPointerException: Cannot invoke "java.lang.Float.floatValue()" because the return value of "io.papermc.paper.persistence.PersistentDataContainerView.get(org.bukkit.NamespacedKey, org.bukkit.persistence.PersistentDataType)" is null
[12:51:22 WARN]: at my-addon-MODIFIED-1757418660070.jar//io.github.pylonmc.myaddon.BaguetteOfWisdom.getPlaceholders(BaguetteOfWisdom.java:28)
[12:51:22 WARN]: at pylon-core-0.11.2.jar//io.github.pylonmc.pylon.core.guide.button.ItemButton.getItemProvider(ItemButton.kt:47)
[12:51:22 WARN]: at xyz.xenondevs.invui.gui.SlotElement$ItemSlotElement.getItemStack(SlotElement.java:44)
[12:51:22 WARN]: at xyz.xenondevs.invui.window.AbstractWindow.redrawItem(AbstractWindow.java:109)
[12:51:22 WARN]: at xyz.xenondevs.invui.window.AbstractSingleWindow.initItems(AbstractSingleWindow.java:58)
[12:51:22 WARN]: at xyz.xenondevs.invui.window.AbstractWindow.open(AbstractWindow.java:279)
[12:51:22 WARN]: at xyz.xenondevs.invui.window.AbstractWindow$AbstractBuilder.open(AbstractWindow.java:679)
[12:51:22 WARN]: at pylon-core-0.11.2.jar//io.github.pylonmc.pylon.core.guide.pages.base.GuidePage.open(GuidePage.kt:28)
[12:51:22 WARN]: at pylon-core-0.11.2.jar//io.github.pylonmc.pylon.core.guide.button.PageButton.handleClick(PageButton.kt:38)
[12:51:22 WARN]: at xyz.xenondevs.invui.gui.AbstractGui.handleClick(AbstractGui.java:95)
[12:51:22 WARN]: at xyz.xenondevs.invui.window.AbstractSingleWindow.handleClick(AbstractSingleWindow.java:84)
[12:51:22 WARN]: at xyz.xenondevs.invui.window.AbstractWindow.handleClickEvent(AbstractWindow.java:199)
[12:51:22 WARN]: at xyz.xenondevs.invui.window.WindowManager.handleInventoryClick(WindowManager.java:117)
[12:51:22 WARN]: at co.aikar.timings.TimedEventExecutor.execute(TimedEventExecutor.java:80)
[12:51:22 WARN]: at org.bukkit.plugin.RegisteredListener.callEvent(RegisteredListener.java:71)
[12:51:22 WARN]: at io.papermc.paper.plugin.manager.PaperEventManager.callEvent(PaperEventManager.java:54)
[12:51:22 WARN]: at io.papermc.paper.plugin.manager.PaperPluginManagerImpl.callEvent(PaperPluginManagerImpl.java:131)
[12:51:22 WARN]: at org.bukkit.plugin.SimplePluginManager.callEvent(SimplePluginManager.java:628)
[12:51:22 WARN]: at net.minecraft.server.network.ServerGamePacketListenerImpl.handleContainerClick(ServerGamePacketListenerImpl.java:3208)
[12:51:22 WARN]: at net.minecraft.network.protocol.game.ServerboundContainerClickPacket.handle(ServerboundContainerClickPacket.java:59)
[12:51:22 WARN]: at net.minecraft.network.protocol.game.ServerboundContainerClickPacket.handle(ServerboundContainerClickPacket.java:14)
[12:51:22 WARN]: at net.minecraft.network.protocol.PacketUtils.lambda$ensureRunningOnSameThread$0(PacketUtils.java:29)
[12:51:22 WARN]: at net.minecraft.server.TickTask.run(TickTask.java:18)
[12:51:22 WARN]: at net.minecraft.util.thread.BlockableEventLoop.doRunTask(BlockableEventLoop.java:155)
[12:51:22 WARN]: at net.minecraft.util.thread.ReentrantBlockableEventLoop.doRunTask(ReentrantBlockableEventLoop.java:24)
[12:51:22 WARN]: at net.minecraft.server.MinecraftServer.doRunTask(MinecraftServer.java:1450)
[12:51:22 WARN]: at net.minecraft.server.MinecraftServer.doRunTask(MinecraftServer.java:176)
[12:51:22 WARN]: at net.minecraft.util.thread.BlockableEventLoop.pollTask(BlockableEventLoop.java:129)
[12:51:22 WARN]: at net.minecraft.server.MinecraftServer.pollTaskInternal(MinecraftServer.java:1430)
[12:51:22 WARN]: at net.minecraft.server.MinecraftServer.pollTask(MinecraftServer.java:1424)
[12:51:22 WARN]: at net.minecraft.util.thread.BlockableEventLoop.managedBlock(BlockableEventLoop.java:139)
[12:51:22 WARN]: at net.minecraft.server.MinecraftServer.managedBlock(MinecraftServer.java:1381)
[12:51:22 WARN]: at net.minecraft.server.MinecraftServer.waitUntilNextTick(MinecraftServer.java:1389)
[12:51:22 WARN]: at net.minecraft.server.MinecraftServer.runServer(MinecraftServer.java:1266)
[12:51:22 WARN]: at net.minecraft.server.MinecraftServer.lambda$spin$2(MinecraftServer.java:310)
Wow. That's a fat error. But the lines we are most interested in are right at the top:
[12:51:22 WARN]: java.lang.NullPointerException: Cannot invoke "java.lang.Float.floatValue()" because the return value of "io.papermc.paper.persistence.PersistentDataContainerView.get(org.bukkit.NamespacedKey, org.bukkit.persistence.PersistentDataType)" is null
[12:51:22 WARN]: at my-addon-MODIFIED-1757418660070.jar//io.github.pylonmc.myaddon.BaguetteOfWisdom.getPlaceholders(BaguetteOfWisdom.java:28)
So it looks like the error occurred on line 28, in the getPlaceholders function, where we try to read from the persistent data container. Apparently, the 'stored_xp' key couldn't be found in the PDC, because the call to getStack().getPersistentDataContainer().get(...) returned null.
Wait, why did getPlaceholders get called and error now? We haven't given ourselves the item yet.
Simply put, the guide also needs to call getPlaceholders to display the item to you. The error only appears once you open the guide - or once you give yourself the item with /py give.
Kotlin - null safety
If you've been following along in Kotlin, you may have noticed that your error is different from the one above. Additionally, if you played around with the code a bit, you may have noticed that the Kotlin code refuses to compile unless you add a !! after the call to get(...) in the getPlaceholders function. This is because Kotlin actually tracks nulls in the type system, and will error during compile time instead of run time if you try to use a potentially null value without checking for null first. The !! tells the compiler, "hey, I know what I'm doing, this value will never be null." This is one of the advantages of using Kotlin over Java, as it can help catch potential null pointer exceptions before they even happen, and is one of the reasons why Pylon Core is written in Kotlin. In this situation, an Elvis operator (?:) or a call to getOrDefault would have been more appropriate, but for the purposes of this tutorial, we will leave it as is.
This actually makes perfect sense if you think about it. At no point do we set a default value for the stored XP, so of course any call to get it will return null.
Adding a default value
To add a default value for stored XP to the PDC, we can modify the itemstack itself when we create it:
publicclassBaguetteOfWisdomextendsPylonItemimplementsPylonInteractor{...@Overridepublic@NotNullList<PylonArgument>getPlaceholders(){returnList.of(PylonArgument.of("xp_capacity",UnitFormat.EXPERIENCE.format(xpCapacity)),PylonArgument.of("stored_xp",UnitFormat.EXPERIENCE.format(getStoredXp())));}@OverridepublicvoidonUsedToRightClick(@NotNullPlayerInteractEventevent){if(event.getPlayer().isSneaking()){// 1. Read how much XP we already have storedintxp=getStoredXp();// 2. Give all the XP to the playerevent.getPlayer().giveExp(xp);// 3. Set the stored XP to 0setStoredXp(0);}else{// 1. Read how much XP we already have storedintxp=getStoredXp();// 2. Figure out how much XP we need to take to get to `xpCapacity`intextraXpNeeded=xpCapacity-xp;// 3. Take as much XP from the player as we can to get thereintxpToTake=Math.min(event.getPlayer().calculateTotalExperiencePoints(),extraXpNeeded);event.getPlayer().giveExp(-xpToTake);// 4. Set the new stored XP amountsetStoredXp(xp+xpToTake);}}...}
classBaguetteOfWisdom(stack:ItemStack):PylonItem(stack),PylonInteractor{...overridefungetPlaceholders()=listOf(PylonArgument.of("xp_capacity",UnitFormat.EXPERIENCE.format(xpCapacity)),PylonArgument.of("stored_xp",UnitFormat.EXPERIENCE.format(storedXp)))overridefunonUsedToRightClick(event:PlayerInteractEvent){if(event.player.isSneaking){// 1. Read how much XP we already have storedvalxp=storedXp// 2. Give all the XP to the playerevent.player.giveExp(xp)// 3. Set the stored XP to 0storedXp=0}else{// 1. Read how much XP we already have storedvalxp=storedXp// 2. Figure out how much XP we need to take to get to `xpCapacity`valextraXpNeeded=xpCapacity-xp// 3. Take as much XP from the player as we can to get therevalxpToTake=min(event.player.calculateTotalExperiencePoints(),extraXpNeeded)event.player.giveExp(-xpToTake)// 4. Set the new stored XP amountstoredXp=xp+xpToTake}}...}
The second thing we should do is reuse NamespacedKeys. This is more of a 'best practice' thing - it's generally recommend to reuse keys. It'll become more apparent why later on.