up3
Build and deploy / Build (push) Has been cancelled
Build and deploy / Update Contributors (push) Has been cancelled
Build and deploy / Deploy (push) Has been cancelled

This commit is contained in:
2026-07-09 08:33:57 +08:00
commit 26ed99fda6
845 changed files with 75419 additions and 0 deletions
+330
View File
@@ -0,0 +1,330 @@
package session
import (
"fmt"
"math"
"math/rand/v2"
"slices"
"github.com/df-mc/dragonfly/server/block"
"github.com/df-mc/dragonfly/server/block/cube"
"github.com/df-mc/dragonfly/server/internal/sliceutil"
"github.com/df-mc/dragonfly/server/item"
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
const (
// enchantingInputSlot is the slot index of the input item in the enchanting table.
enchantingInputSlot = 0x0e
// enchantingLapisSlot is the slot index of the lapis in the enchanting table.
enchantingLapisSlot = 0x0f
)
// handleEnchant handles the enchantment of an item using the CraftRecipe stack request action.
func (h *ItemStackRequestHandler) handleEnchant(a *protocol.CraftRecipeStackRequestAction, s *Session, tx *world.Tx, c Controllable) error {
// First ensure that the selected slot is not out of bounds.
if a.RecipeNetworkID > 2 {
return fmt.Errorf("invalid recipe network id: %d", a.RecipeNetworkID)
}
// Now ensure we have an input and only one input.
input, err := s.ui.Item(enchantingInputSlot)
if err != nil {
return err
}
if input.Count() > 1 {
return fmt.Errorf("enchanting tables only accept one item at a time")
}
// Determine the available enchantments using the session's enchantment seed.
allCosts, allEnchants := s.determineAvailableEnchantments(tx, c, *s.openedPos.Load(), input)
if len(allEnchants) == 0 {
return fmt.Errorf("can't enchant non-enchantable item")
}
// Use the slot plus one as the cost. The requirement and enchantments can be found in the results from
// determineAvailableEnchantments using the same slot index.
cost := int(a.RecipeNetworkID + 1)
requirement := allCosts[a.RecipeNetworkID]
enchants := allEnchants[a.RecipeNetworkID]
// If we don't have infinite resources, we need to deduct Lapis Lazuli and experience.
if !c.GameMode().CreativeInventory() {
// First ensure that the experience level is both underneath the requirement and the cost.
if c.ExperienceLevel() < requirement {
return fmt.Errorf("not enough levels to meet requirement")
}
if c.ExperienceLevel() < cost {
return fmt.Errorf("not enough levels to meet cost")
}
// Then ensure that the player has input Lapis Lazuli, and enough of it to meet the cost.
lapis, err := s.ui.Item(enchantingLapisSlot)
if err != nil {
return err
}
if _, ok := lapis.Item().(item.LapisLazuli); !ok {
return fmt.Errorf("lapis lazuli was not input")
}
if lapis.Count() < cost {
return fmt.Errorf("not enough lapis lazuli to meet cost")
}
// Deduct the experience and Lapis Lazuli.
c.SetExperienceLevel(c.ExperienceLevel() - cost)
h.setItemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerEnchantingMaterial},
Slot: enchantingLapisSlot,
}, lapis.Grow(-cost), s, tx)
}
// Reset the enchantment seed so different enchantments can be selected.
c.ResetEnchantmentSeed()
// Clear the existing input item, and apply the new item into the crafting result slot of the UI. The client will
// automatically move the item into the input slot.
h.setItemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerEnchantingInput},
Slot: enchantingInputSlot,
}, item.Stack{}, s, tx)
return h.createResults(s, tx, input.WithEnchantments(enchants...))
}
// sendEnchantmentOptions sends a list of available enchantments to the client based on the client's enchantment seed
// and nearby bookshelves.
func (s *Session) sendEnchantmentOptions(tx *world.Tx, c Controllable, pos cube.Pos, stack item.Stack) {
// First determine the available enchantments for the given item stack.
selectedCosts, selectedEnchants := s.determineAvailableEnchantments(tx, c, pos, stack)
if len(selectedEnchants) == 0 {
// No available enchantments.
return
}
// Build the protocol variant of the enchantment options.
options := make([]protocol.EnchantmentOption, 0, 3)
for i := 0; i < 3; i++ {
// First build the enchantment instances for each selected enchantment.
enchants := make([]protocol.EnchantmentInstance, 0, len(selectedEnchants[i]))
for _, enchant := range selectedEnchants[i] {
id, _ := item.EnchantmentID(enchant.Type())
enchants = append(enchants, protocol.EnchantmentInstance{
Type: byte(id),
Level: byte(enchant.Level()),
})
}
// Then build the enchantment option. We can use the slot as the RecipeNetworkID, since the IDs seem to be unique
// to enchanting tables only. We also only need to set the middle index of Enchantments. The other two serve
// an unknown purpose and can cause various unexpected issues.
options = append(options, protocol.EnchantmentOption{
Name: enchantNames[rand.IntN(len(enchantNames))],
Cost: uint8(selectedCosts[i]),
RecipeNetworkID: uint32(i),
Enchantments: protocol.ItemEnchantments{
Slot: int32(i),
Enchantments: [3][]protocol.EnchantmentInstance{1: enchants},
},
})
}
// Send the enchantment options to the client.
s.writePacket(&packet.PlayerEnchantOptions{Options: options})
}
// determineAvailableEnchantments returns a list of pseudo-random enchantments for the given item stack.
func (s *Session) determineAvailableEnchantments(tx *world.Tx, c Controllable, pos cube.Pos, stack item.Stack) ([]int, [][]item.Enchantment) {
// First ensure that the item is enchantable and does not already have any enchantments.
enchantable, ok := stack.Item().(item.Enchantable)
if !ok {
// We can't enchant this item.
return nil, nil
}
if len(stack.Enchantments()) > 0 {
// We can't enchant this item.
return nil, nil
}
// Search for bookshelves around the enchanting table. Bookshelves help boost the value of the enchantments that
// are selected, resulting in enchantments that are rarer but also more expensive.
seed := uint64(c.EnchantmentSeed())
random := rand.New(rand.NewPCG(seed, seed))
bookshelves := searchBookshelves(tx, pos)
value := enchantable.EnchantmentValue()
// Calculate the base cost, used to calculate the upper, middle, and lower level costs.
baseCost := random.IntN(8) + 1 + (bookshelves >> 1) + random.IntN(bookshelves+1)
// Calculate the upper, middle, and lower level costs.
upperLevelCost := max(baseCost/3, 1)
middleLevelCost := baseCost*2/3 + 1
lowerLevelCost := max(baseCost, bookshelves*2)
// Create a list of available enchantments for each slot.
return []int{
upperLevelCost,
middleLevelCost,
lowerLevelCost,
}, [][]item.Enchantment{
createEnchantments(random, stack, value, upperLevelCost),
createEnchantments(random, stack, value, middleLevelCost),
createEnchantments(random, stack, value, lowerLevelCost),
}
}
// treasureEnchantment represents an enchantment that may be a treasure enchantment.
type treasureEnchantment interface {
item.EnchantmentType
Treasure() bool
}
// createEnchantments creates a list of enchantments for the given item stack and returns them.
func createEnchantments(random *rand.Rand, stack item.Stack, value, level int) []item.Enchantment {
// Calculate the "random bonus" for this level. This factor is used in calculating the enchantment cost, used
// during the selection of enchantments.
randomBonus := (random.Float64() + random.Float64() - 1.0) * 0.15
// Calculate the enchantment cost and clamp it to ensure it is always at least one with triangular distribution.
cost := level + 1 + random.IntN(value/4+1) + random.IntN(value/4+1)
cost = clamp(int(math.Round(float64(cost)+float64(cost)*randomBonus)), 1, math.MaxInt32)
// Books are applicable to all enchantments, so make sure we have a flag for them here.
it := stack.Item()
_, book := it.(item.Book)
// Now that we have our enchantment cost, we need to select the available enchantments. First, we iterate through
// each possible enchantment.
availableEnchants := make([]item.Enchantment, 0, len(item.Enchantments()))
for _, enchant := range item.Enchantments() {
if t, ok := enchant.(treasureEnchantment); ok && t.Treasure() {
// We then have to ensure that the enchantment is not a treasure enchantment, as those cannot be selected through
// the enchanting table.
continue
}
if !book && !enchant.CompatibleWithItem(it) {
// The enchantment is not compatible with the item.
continue
}
// Now iterate through each possible level of the enchantment.
for i := enchant.MaxLevel(); i > 0; i-- {
// Use the level to calculate the minimum and maximum costs for this enchantment.
if minCost, maxCost := enchant.Cost(i); cost >= minCost && cost <= maxCost {
// If the cost is within the bounds, add the enchantment to the list of available enchantments.
availableEnchants = append(availableEnchants, item.NewEnchantment(enchant, i))
break
}
}
}
if len(availableEnchants) == 0 {
// No available enchantments, so we can't really do much here.
return nil
}
// Now we need to select the enchantments.
selectedEnchants := make([]item.Enchantment, 0, len(availableEnchants))
// Select the first enchantment using a weighted random algorithm, favouring enchantments that have a higher weight.
// These weights are based on the enchantment's rarity, with common and uncommon enchantments having a higher weight
// than rare and very rare enchantments.
enchant := weightedRandomEnchantment(random, availableEnchants)
selectedEnchants = append(selectedEnchants, enchant)
// Remove the selected enchantment from the list of available enchantments, so we don't select it again.
ind := slices.Index(availableEnchants, enchant)
availableEnchants = slices.Delete(availableEnchants, ind, ind+1)
// Based on the cost, select a random amount of additional enchantments.
for random.IntN(50) <= cost {
// Ensure that we don't have any conflicting enchantments. If so, remove them from the list of available
// enchantments.
lastEnchant := selectedEnchants[len(selectedEnchants)-1]
if availableEnchants = sliceutil.Filter(availableEnchants, func(enchant item.Enchantment) bool {
return lastEnchant.Type().CompatibleWithEnchantment(enchant.Type())
}); len(availableEnchants) == 0 {
// We've exhausted all available enchantments.
break
}
// Select another enchantment using the same weighted random algorithm.
enchant = weightedRandomEnchantment(random, availableEnchants)
selectedEnchants = append(selectedEnchants, enchant)
// Remove the selected enchantment from the list of available enchantments, so we don't select it again.
ind = slices.Index(availableEnchants, enchant)
availableEnchants = slices.Delete(availableEnchants, ind, ind+1)
// Halve the cost, so we have a lower chance of selecting another enchantment.
cost /= 2
}
return selectedEnchants
}
// searchBookshelves searches for nearby bookshelves around the position passed, and returns the amount found.
func searchBookshelves(tx *world.Tx, pos cube.Pos) (shelves int) {
for x := -1; x <= 1; x++ {
for z := -1; z <= 1; z++ {
for y := 0; y <= 1; y++ {
if x == 0 && z == 0 {
// Ignore the centre block.
continue
}
if _, ok := tx.Block(pos.Add(cube.Pos{x, y, z})).(block.Air); !ok {
// There must be a one block space between the bookshelf and the player.
continue
}
// Check for a bookshelf two blocks away.
if _, ok := tx.Block(pos.Add(cube.Pos{x * 2, y, z * 2})).(block.Bookshelf); ok {
shelves++
}
if x != 0 && z != 0 {
// Check for a bookshelf two blocks away on the X axis.
if _, ok := tx.Block(pos.Add(cube.Pos{x * 2, y, z})).(block.Bookshelf); ok {
shelves++
}
// Check for a bookshelf two blocks away on the Z axis.
if _, ok := tx.Block(pos.Add(cube.Pos{x, y, z * 2})).(block.Bookshelf); ok {
shelves++
}
}
if shelves >= 15 {
// We've found enough bookshelves.
return 15
}
}
}
}
return shelves
}
// weightedRandomEnchantment returns a random enchantment from the given list of enchantments using the rarity weight of
// each enchantment.
func weightedRandomEnchantment(rs *rand.Rand, enchants []item.Enchantment) item.Enchantment {
var totalWeight int
for _, e := range enchants {
totalWeight += e.Type().Rarity().Weight()
}
r := rs.IntN(totalWeight)
for _, e := range enchants {
r -= e.Type().Rarity().Weight()
if r < 0 {
return e
}
}
panic("should never happen")
}
// clamp clamps a value into the given range.
func clamp(value, min, max int) int {
if value < min {
return min
}
if value > max {
return max
}
return value
}