Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .idea/dictionaries/project.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ paperPluginYaml {
main.set(group.toString())
authors.add("Xodium")
apiVersion.set(version)
bootstrapper.set("org.xodium.vanillaplus.VanillaPlusBootstrap")
dependencies {
server(name = "WorldEdit", load = PaperPluginYaml.Load.BEFORE, required = false, joinClasspath = true)
}
Expand Down
42 changes: 42 additions & 0 deletions src/main/kotlin/org/xodium/vanillaplus/VanillaPlusBootstrap.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
@file:Suppress("ktlint:standard:no-wildcard-imports")

package org.xodium.vanillaplus

import io.papermc.paper.plugin.bootstrap.BootstrapContext
import io.papermc.paper.plugin.bootstrap.PluginBootstrap
import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents
import io.papermc.paper.registry.RegistryKey
import io.papermc.paper.registry.event.RegistryEvents
import io.papermc.paper.registry.keys.tags.EnchantmentTagKeys
import io.papermc.paper.registry.keys.tags.ItemTypeTagKeys
import org.xodium.vanillaplus.enchantments.ReplantEnchantment

/** Main bootstrap class of the plugin. */
@Suppress("UnstableApiUsage", "Unused")
internal class VanillaPlusBootstrap : PluginBootstrap {
companion object {
const val INSTANCE = "vanillaplus"
val REPLANT = ReplantEnchantment.key
val ENCHANTS = setOf(REPLANT)
}

override fun bootstrap(ctx: BootstrapContext) {
ctx.lifecycleManager.apply {
registerEventHandler(
RegistryEvents.ENCHANTMENT.compose().newHandler { event ->
val hoeTag = event.getOrCreateTag(ItemTypeTagKeys.HOES)
event.registry().apply {
register(REPLANT) { ReplantEnchantment.init(it).supportedItems(hoeTag) }
}
},
)
registerEventHandler(LifecycleEvents.TAGS.postFlatten(RegistryKey.ENCHANTMENT)) { event ->
event.registrar().apply {
addToTag(EnchantmentTagKeys.TRADEABLE, ENCHANTS)
addToTag(EnchantmentTagKeys.NON_TREASURE, ENCHANTS)
addToTag(EnchantmentTagKeys.IN_ENCHANTING_TABLE, ENCHANTS)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package org.xodium.vanillaplus.enchantments

import io.papermc.paper.registry.RegistryKey
import io.papermc.paper.registry.TypedKey
import io.papermc.paper.registry.data.EnchantmentRegistryEntry
import net.kyori.adventure.key.Key
import org.bukkit.enchantments.Enchantment
import org.bukkit.inventory.EquipmentSlotGroup
import org.xodium.vanillaplus.VanillaPlusBootstrap.Companion.INSTANCE
import org.xodium.vanillaplus.VanillaPlusBootstrap.Companion.REPLANT
import org.xodium.vanillaplus.interfaces.EnchantmentInterface
import org.xodium.vanillaplus.utils.ExtUtils.mm

@Suppress("UnstableApiUsage")
internal object ReplantEnchantment : EnchantmentInterface {
override val key: TypedKey<Enchantment> = TypedKey.create(RegistryKey.ENCHANTMENT, Key.key(INSTANCE, "replant"))

override fun init(builder: EnchantmentRegistryEntry.Builder): EnchantmentRegistryEntry.Builder =
builder
.description(REPLANT.value().replaceFirstChar { it.uppercase() }.mm())
.anvilCost(8)
.maxLevel(1)
.weight(5)
.minimumCost(EnchantmentRegistryEntry.EnchantmentCost.of(1, 10))
.maximumCost(EnchantmentRegistryEntry.EnchantmentCost.of(8, 20))
.activeSlots(EquipmentSlotGroup.MAINHAND)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package org.xodium.vanillaplus.interfaces

import io.papermc.paper.registry.RegistryAccess
import io.papermc.paper.registry.RegistryKey
import io.papermc.paper.registry.TypedKey
import io.papermc.paper.registry.data.EnchantmentRegistryEntry
import org.bukkit.enchantments.Enchantment

/** Represents a contract for enchantments within the system. */
@Suppress("UnstableApiUsage")
internal interface EnchantmentInterface {
/**
* The unique typed key that identifies this enchantment in the registry.
* @see TypedKey
* @see RegistryKey.ENCHANTMENT
*/
val key: TypedKey<Enchantment>

/**
* Initializes the Drift enchantment.
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation refers to "Drift enchantment" but this interface is generic and used for all enchantments, including the "Replant" enchantment. The documentation should be updated to be generic (e.g., "Initializes the enchantment.")

Suggested change
* Initializes the Drift enchantment.
* Initializes the enchantment.

Copilot uses AI. Check for mistakes.
* @param builder The builder used to define the enchantment properties.
* @return The builder for method chaining.
*/
fun init(builder: EnchantmentRegistryEntry.Builder): EnchantmentRegistryEntry.Builder

/**
* Retrieves the enchantment from the registry.
* @return The [Enchantment] instance corresponding to the key.
* @throws NoSuchElementException if the enchantment is not found in the registry.
*/
fun get(): Enchantment = RegistryAccess.registryAccess().getRegistry(RegistryKey.ENCHANTMENT).getOrThrow(key)
}
48 changes: 48 additions & 0 deletions src/main/kotlin/org/xodium/vanillaplus/modules/PlayerModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ import io.papermc.paper.datacomponent.item.ItemLore
import io.papermc.paper.datacomponent.item.ResolvableProfile
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder
import org.bukkit.Material
import org.bukkit.block.Block
import org.bukkit.block.data.Ageable
import org.bukkit.entity.Player
import org.bukkit.event.EventHandler
import org.bukkit.event.EventPriority
import org.bukkit.event.block.BlockBreakEvent
import org.bukkit.event.entity.PlayerDeathEvent
import org.bukkit.event.inventory.ClickType
import org.bukkit.event.inventory.InventoryClickEvent
Expand All @@ -23,6 +26,7 @@ import org.bukkit.permissions.Permission
import org.bukkit.permissions.PermissionDefault
import org.xodium.vanillaplus.VanillaPlus.Companion.instance
import org.xodium.vanillaplus.data.CommandData
import org.xodium.vanillaplus.enchantments.ReplantEnchantment
import org.xodium.vanillaplus.interfaces.ModuleInterface
import org.xodium.vanillaplus.pdcs.PlayerPDC.nickname
import org.xodium.vanillaplus.utils.ExtUtils.mm
Expand Down Expand Up @@ -76,11 +80,15 @@ internal class PlayerModule(
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
fun on(event: PlayerJoinEvent) {
if (!enabled()) return

val player = event.player

player.displayName(player.nickname?.mm())

if (config.i18n.playerJoinMsg.isEmpty()) return

event.joinMessage(null)

instance.server.onlinePlayers
.filter { it.uniqueId != player.uniqueId }
.forEach {
Expand All @@ -95,13 +103,16 @@ internal class PlayerModule(
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
fun on(event: PlayerQuitEvent) {
if (!enabled() || config.i18n.playerQuitMsg.isEmpty()) return

event.quitMessage(config.i18n.playerQuitMsg.mm(Placeholder.component("player", event.player.displayName())))
}

@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
fun on(event: PlayerDeathEvent) {
if (!enabled()) return

val killer = event.entity.killer ?: return

if (Math.random() < config.skullDropChance) {
event.entity.world.dropItemNaturally(
event.entity.location,
Expand All @@ -116,6 +127,7 @@ internal class PlayerModule(
@EventHandler
fun on(event: PlayerAdvancementDoneEvent) {
if (!enabled() || config.i18n.playerAdvancementDoneMsg.isEmpty()) return

event.message(
config.i18n.playerAdvancementDoneMsg.mm(
Placeholder.component("player", event.player.displayName()),
Expand All @@ -133,7 +145,9 @@ internal class PlayerModule(
) {
return
}

event.isCancelled = true

instance.server.scheduler.runTask(
instance,
Runnable { event.whoClicked.openInventory(event.whoClicked.enderChest) },
Expand All @@ -143,9 +157,43 @@ internal class PlayerModule(
@EventHandler(ignoreCancelled = true)
fun on(event: PlayerInteractEvent) {
if (!enabled()) return

xpToBottle(event)
}

@EventHandler
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The replant logic directly replaces the block type without considering whether the block was actually broken. If the BlockBreakEvent is cancelled by another plugin with lower priority, this could still replant the crop. Consider adding ignoreCancelled = true to the EventHandler annotation to prevent this issue.

Suggested change
@EventHandler
@EventHandler(ignoreCancelled = true)

Copilot uses AI. Check for mistakes.
fun on(event: BlockBreakEvent) {
if (!enabled()) return

replant(event.block, event.player.inventory.itemInMainHand)
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code checks event.player.inventory.itemInMainHand but doesn't verify if this was actually the item used to break the block. Players can break blocks with their off-hand as well. Consider checking both main hand and off-hand, or use a more reliable method to determine which hand was used for breaking the block.

Suggested change
replant(event.block, event.player.inventory.itemInMainHand)
val mainHand = event.player.inventory.itemInMainHand
val offHand = event.player.inventory.itemInOffHand
// Prefer main hand if both have the enchantment, but check both
if (mainHand.hasItemMeta() && mainHand.itemMeta.hasEnchant(ReplantEnchantment.get())) {
replant(event.block, mainHand)
} else if (offHand.hasItemMeta() && offHand.itemMeta.hasEnchant(ReplantEnchantment.get())) {
replant(event.block, offHand)
}

Copilot uses AI. Check for mistakes.
}

/**
* Automatically replants a crop block after it has been fully grown and harvested.
* @param block The block that was broken.
* @param tool The tool used to break the block.
*/
private fun replant(
block: Block,
tool: ItemStack,
) {
val ageable = block.blockData as? Ageable ?: return

if (ageable.age < ageable.maximumAge) return
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The enchantment check doesn't verify that the tool is a hoe. While the enchantment is registered to only appear on hoes (in VanillaPlusBootstrap.kt), players could theoretically use commands or other means to add this enchantment to non-hoe items. Consider adding a check to ensure the tool is a hoe before attempting to replant, or document that this is intentionally allowed for any tool with the enchantment.

Suggested change
if (ageable.age < ageable.maximumAge) return
if (ageable.age < ageable.maximumAge) return
// Only allow hoes to trigger replant
if (tool.type !in setOf(
Material.WOODEN_HOE,
Material.STONE_HOE,
Material.IRON_HOE,
Material.GOLDEN_HOE,
Material.DIAMOND_HOE,
Material.NETHERITE_HOE
)) return

Copilot uses AI. Check for mistakes.
if (!tool.hasItemMeta() || !tool.itemMeta.hasEnchant(ReplantEnchantment.get())) return
Comment on lines +176 to +183
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The function checks if the tool has the Replant enchantment on a hoe, but doesn't verify that the block being broken is actually a crop that should be replanted. While the cast to Ageable will return null for non-ageable blocks, some ageable blocks like saplings or stems might not be appropriate for this enchantment. Consider adding validation to ensure only appropriate crop blocks (e.g., wheat, carrots, potatoes, beetroots, etc.) are replanted, or document that all ageable blocks are intentionally supported.

Copilot uses AI. Check for mistakes.

instance.server.scheduler.runTaskLater(
instance,
Runnable {
val blockType = block.type

block.type = blockType
block.blockData = ageable.apply { age = 0 }
Comment on lines +190 to +191
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a potential race condition in the replanting logic. The code schedules a task to run 2 ticks later that sets block.type = blockType, but it doesn't verify that the block is actually AIR after the break event completes. If another plugin or event modifies the block during the delay, or if the BlockBreakEvent gets cancelled after this handler runs, this could overwrite that change or place a crop in an unexpected location. Consider checking if (block.type == Material.AIR) before setting the block type in the delayed task.

Suggested change
block.type = blockType
block.blockData = ageable.apply { age = 0 }
if (block.type == Material.AIR) {
block.type = blockType
block.blockData = ageable.apply { age = 0 }
}

Copilot uses AI. Check for mistakes.
},
2,
Comment on lines +188 to +193
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Using a 2-tick delay for replanting may be fragile. The block break event processes synchronously, and the drops/block state changes happen during event processing. A 2-tick delay might not be sufficient in all cases (e.g., with lag or other plugins modifying the block). Consider using a 1-tick delay (which is typically sufficient for the block to be fully processed) or adding validation that the block is AIR before replanting to ensure the break was successful.

Suggested change
val blockType = block.type
block.type = blockType
block.blockData = ageable.apply { age = 0 }
},
2,
// Only replant if the block is AIR (i.e., was actually broken)
if (block.type == Material.AIR) {
block.type = block.type // restore the crop type
block.blockData = ageable.apply { age = 0 }
}
},
1,

Copilot uses AI. Check for mistakes.
)
}

/**
* Handles the interaction event where a player can convert their experience points into an experience bottle
* if specific conditions are met.
Expand Down
Loading