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
+254
View File
@@ -0,0 +1,254 @@
package session
import (
"bytes"
"github.com/cespare/xxhash/v2"
"github.com/df-mc/dragonfly/server/block/cube"
"github.com/df-mc/dragonfly/server/world"
"github.com/df-mc/dragonfly/server/world/chunk"
"github.com/sandertv/gophertunnel/minecraft/nbt"
"github.com/sandertv/gophertunnel/minecraft/protocol"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
// subChunkRequests is set to true to enable the sub-chunk request system. This can (likely) cause unexpected issues,
// but also solves issues with block entities such as item frames and lecterns as of v1.19.10.
const subChunkRequests = true
// ViewChunk ...
func (s *Session) ViewChunk(pos world.ChunkPos, dim world.Dimension, blockEntities map[cube.Pos]world.Block, c *chunk.Chunk) {
if !s.conn.ClientCacheEnabled() {
s.sendNetworkChunk(pos, dim, c, blockEntities)
return
}
s.sendBlobHashes(pos, dim, c, blockEntities)
}
// ViewSubChunks ...
func (s *Session) ViewSubChunks(centre world.SubChunkPos, offsets []protocol.SubChunkOffset, tx *world.Tx) {
r := tx.Range()
entries := make([]protocol.SubChunkEntry, 0, len(offsets))
transaction := make(map[uint64]struct{})
for _, offset := range offsets {
ind := int16(centre.Y()) + int16(offset[1]) - int16(r[0])>>4
if ind < 0 || ind > int16(r.Height()>>4) {
entries = append(entries, protocol.SubChunkEntry{Result: protocol.SubChunkResultIndexOutOfBounds, Offset: offset})
continue
}
col, ok := s.chunkLoader.Chunk(world.ChunkPos{
centre.X() + int32(offset[0]),
centre.Z() + int32(offset[2]),
})
if !ok {
entries = append(entries, protocol.SubChunkEntry{Result: protocol.SubChunkResultChunkNotFound, Offset: offset})
continue
}
entries = append(entries, s.subChunkEntry(offset, ind, col, transaction))
}
if s.conn.ClientCacheEnabled() && len(transaction) > 0 {
s.blobMu.Lock()
s.openChunkTransactions = append(s.openChunkTransactions, transaction)
s.blobMu.Unlock()
}
dim, _ := world.DimensionID(tx.World().Dimension())
s.writePacket(&packet.SubChunk{
Dimension: int32(dim),
Position: protocol.SubChunkPos(centre),
CacheEnabled: s.conn.ClientCacheEnabled(),
SubChunkEntries: entries,
})
}
func (s *Session) subChunkEntry(offset protocol.SubChunkOffset, ind int16, col *world.Column, transaction map[uint64]struct{}) protocol.SubChunkEntry {
chunkMap := col.HeightMap()
subMapType, subMap := byte(protocol.HeightMapDataHasData), make([]int8, 256)
higher, lower := true, true
for x := uint8(0); x < 16; x++ {
for z := uint8(0); z < 16; z++ {
y, i := chunkMap.At(x, z), (uint16(z)<<4)|uint16(x)
otherInd := col.SubIndex(y)
switch {
case otherInd > ind:
subMap[i], lower = 16, false
case otherInd < ind:
subMap[i], higher = -1, false
default:
subMap[i], lower, higher = int8(y-col.SubY(otherInd)), false, false
}
}
}
if higher {
subMapType, subMap = protocol.HeightMapDataTooHigh, nil
} else if lower {
subMapType, subMap = protocol.HeightMapDataTooLow, nil
}
sub := col.Sub()[ind]
if sub.Empty() {
return protocol.SubChunkEntry{
Result: protocol.SubChunkResultSuccessAllAir,
HeightMapType: subMapType,
HeightMapData: subMap,
RenderHeightMapType: subMapType,
RenderHeightMapData: subMap,
Offset: offset,
}
}
serialisedSubChunk := chunk.EncodeSubChunk(col.Chunk, chunk.NetworkEncoding, int(ind))
blockEntityBuf := bytes.NewBuffer(nil)
enc := nbt.NewEncoderWithEncoding(blockEntityBuf, nbt.NetworkLittleEndian)
for pos, b := range col.BlockEntities {
if n, ok := b.(world.NBTer); ok && col.SubIndex(int16(pos.Y())) == ind {
d := n.EncodeNBT()
d["x"], d["y"], d["z"] = int32(pos[0]), int32(pos[1]), int32(pos[2])
_ = enc.Encode(d)
}
}
entry := protocol.SubChunkEntry{
Result: protocol.SubChunkResultSuccess,
RawPayload: append(serialisedSubChunk, blockEntityBuf.Bytes()...),
HeightMapType: subMapType,
HeightMapData: subMap,
RenderHeightMapType: subMapType,
RenderHeightMapData: subMap,
Offset: offset,
}
if s.conn.ClientCacheEnabled() {
if hash := xxhash.Sum64(serialisedSubChunk); s.trackBlob(hash, serialisedSubChunk) {
transaction[hash] = struct{}{}
entry.BlobHash = hash
entry.RawPayload = blockEntityBuf.Bytes()
}
}
return entry
}
// dimensionID returns the dimension ID of the world that the session is in.
func (s *Session) dimensionID(dim world.Dimension) int32 {
d, _ := world.DimensionID(dim)
return int32(d)
}
// sendBlobHashes sends chunk blob hashes of the data of the chunk and stores the data in a map of blobs. Only
// data that the client doesn't yet have will be sent over the network.
func (s *Session) sendBlobHashes(pos world.ChunkPos, dim world.Dimension, c *chunk.Chunk, blockEntities map[cube.Pos]world.Block) {
if subChunkRequests {
biomes := chunk.EncodeBiomes(c, chunk.NetworkEncoding)
if hash := xxhash.Sum64(biomes); s.trackBlob(hash, biomes) {
s.writePacket(&packet.LevelChunk{
Dimension: s.dimensionID(dim),
SubChunkCount: protocol.SubChunkRequestModeLimited,
Position: protocol.ChunkPos(pos),
HighestSubChunk: c.HighestFilledSubChunk(),
BlobHashes: []uint64{hash},
RawPayload: []byte{0},
CacheEnabled: true,
})
return
}
}
var (
data = chunk.Encode(c, chunk.NetworkEncoding)
count = uint32(len(data.SubChunks))
blobs = append(data.SubChunks, data.Biomes)
hashes = make([]uint64, len(blobs))
m = make(map[uint64]struct{}, len(blobs))
)
for i, blob := range blobs {
h := xxhash.Sum64(blob)
hashes[i], m[h] = h, struct{}{}
}
s.blobMu.Lock()
s.openChunkTransactions = append(s.openChunkTransactions, m)
if l := len(s.blobs); l > 4096 {
s.blobMu.Unlock()
s.conf.Log.Error("too many blobs pending", "n", l)
return
}
for i := range hashes {
s.blobs[hashes[i]] = blobs[i]
}
s.blobMu.Unlock()
// Length of 1 byte for the border block count.
raw := bytes.NewBuffer(make([]byte, 1, 32))
enc := nbt.NewEncoderWithEncoding(raw, nbt.NetworkLittleEndian)
for bp, b := range blockEntities {
if n, ok := b.(world.NBTer); ok {
d := n.EncodeNBT()
d["x"], d["y"], d["z"] = int32(bp[0]), int32(bp[1]), int32(bp[2])
_ = enc.Encode(d)
}
}
s.writePacket(&packet.LevelChunk{
Dimension: s.dimensionID(dim),
Position: protocol.ChunkPos{pos.X(), pos.Z()},
SubChunkCount: count,
CacheEnabled: true,
BlobHashes: hashes,
RawPayload: raw.Bytes(),
})
}
// sendNetworkChunk sends a network encoded chunk to the client.
func (s *Session) sendNetworkChunk(pos world.ChunkPos, dim world.Dimension, c *chunk.Chunk, blockEntities map[cube.Pos]world.Block) {
if subChunkRequests {
s.writePacket(&packet.LevelChunk{
Dimension: s.dimensionID(dim),
SubChunkCount: protocol.SubChunkRequestModeLimited,
Position: protocol.ChunkPos(pos),
HighestSubChunk: c.HighestFilledSubChunk(),
RawPayload: append(chunk.EncodeBiomes(c, chunk.NetworkEncoding), 0),
})
return
}
data := chunk.Encode(c, chunk.NetworkEncoding)
chunkBuf := bytes.NewBuffer(nil)
for _, s := range data.SubChunks {
_, _ = chunkBuf.Write(s)
}
_, _ = chunkBuf.Write(data.Biomes)
// Length of 1 byte for the border block count.
chunkBuf.WriteByte(0)
enc := nbt.NewEncoderWithEncoding(chunkBuf, nbt.NetworkLittleEndian)
for bp, b := range blockEntities {
if n, ok := b.(world.NBTer); ok {
d := n.EncodeNBT()
d["x"], d["y"], d["z"] = int32(bp[0]), int32(bp[1]), int32(bp[2])
_ = enc.Encode(d)
}
}
s.writePacket(&packet.LevelChunk{
Dimension: s.dimensionID(dim),
Position: protocol.ChunkPos{pos.X(), pos.Z()},
SubChunkCount: uint32(len(data.SubChunks)),
RawPayload: append([]byte(nil), chunkBuf.Bytes()...),
})
}
// trackBlob attempts to track the given blob. If the player has too many pending blobs, it returns false and closes the
// connection.
func (s *Session) trackBlob(hash uint64, blob []byte) bool {
s.blobMu.Lock()
if l := len(s.blobs); l > 4096 {
s.blobMu.Unlock()
s.conf.Log.Error("too many blobs pending", "n", l)
return false
}
s.blobs[hash] = blob
s.blobMu.Unlock()
return true
}
+315
View File
@@ -0,0 +1,315 @@
package session
import (
"math"
"github.com/df-mc/dragonfly/server/cmd"
"github.com/go-gl/mathgl/mgl64"
"github.com/sandertv/gophertunnel/minecraft/protocol"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
"golang.org/x/text/language"
)
// SendCommandOutput sends the output of a command to the player. It will be shown to the caller of the
// command, which might be the player or a websocket server.
func (s *Session) SendCommandOutput(output *cmd.Output, l language.Tag) {
if s == Nop {
return
}
messages := make([]protocol.CommandOutputMessage, 0, output.MessageCount()+output.ErrorCount())
for _, message := range output.Messages() {
om := protocol.CommandOutputMessage{Success: true, Message: message.String()}
if t, ok := message.(translation); ok {
om.Message, om.Parameters = t.Resolve(l), t.Params(l)
}
messages = append(messages, om)
}
for _, err := range output.Errors() {
om := protocol.CommandOutputMessage{Message: err.Error()}
if t, ok := err.(translation); ok {
om.Message, om.Parameters = t.Resolve(l), t.Params(l)
}
messages = append(messages, om)
}
s.writePacket(&packet.CommandOutput{
CommandOrigin: s.handlers[packet.IDCommandRequest].(*CommandRequestHandler).origin,
OutputType: packet.CommandOutputTypeAllOutput,
SuccessCount: uint32(output.MessageCount()),
OutputMessages: messages,
})
}
type translation interface {
Resolve(l language.Tag) string
Params(l language.Tag) []string
}
// BuildAvailableCommands builds an AvailableCommands packet and the runnable command map for the Source
// passed. The input map may contain aliases. It returns the AvailableCommands packet and the runnable command map
// for the commands that the Source can execute.
func BuildAvailableCommands(
commands map[string]cmd.Command,
src cmd.Source,
softEnums map[string]struct{},
) (*packet.AvailableCommands, map[string]map[int]cmd.Runnable) {
m := make(map[string]map[int]cmd.Runnable, len(commands))
pk := &packet.AvailableCommands{}
var enums []commandEnum
enumIndices := map[string]uint32{}
var dynamicEnums []commandEnum
dynamicEnumIndices := map[string]uint32{}
suffixIndices := map[string]uint32{}
for alias, c := range commands {
if c.Name() != alias {
// Don't add duplicate entries for aliases.
continue
}
if run := c.Runnables(src); len(run) > 0 {
m[alias] = run
} else {
continue
}
params := c.Params(src)
overloads := make([]protocol.CommandOverload, len(params))
aliasesIndex := uint32(math.MaxUint32)
if len(c.Aliases()) > 0 {
aliasesIndex = uint32(len(enumIndices))
enumIndices[c.Name()+"Aliases"] = aliasesIndex
enums = append(enums, commandEnum{Type: c.Name() + "Aliases", Options: c.Aliases()})
}
for i, params := range params {
for _, paramInfo := range params {
t, enum := valueToParamType(paramInfo, src)
suffix := paramInfo.Suffix
opt := byte(0)
if suffix != "" {
index, ok := suffixIndices[suffix]
if !ok {
index = uint32(len(pk.Suffixes))
suffixIndices[suffix] = index
pk.Suffixes = append(pk.Suffixes, suffix)
}
t = protocol.CommandArgSuffixed | index
} else {
t |= protocol.CommandArgValid
if len(enum.Options) > 0 || enum.Type != "" {
_, dynamic := softEnums[enum.Type]
if !dynamic {
index, ok := enumIndices[enum.Type]
if !ok {
index = uint32(len(enums))
enumIndices[enum.Type] = index
enums = append(enums, enum)
}
t |= protocol.CommandArgEnum | index
} else {
index, ok := dynamicEnumIndices[enum.Type]
if !ok {
index = uint32(len(dynamicEnums))
dynamicEnumIndices[enum.Type] = index
dynamicEnums = append(dynamicEnums, enum)
}
t |= protocol.CommandArgSoftEnum | index
}
}
}
overloads[i].Parameters = append(overloads[i].Parameters, protocol.CommandParameter{
Name: paramInfo.Name,
Type: t,
Optional: paramInfo.Optional,
Options: opt,
})
}
}
pk.Commands = append(pk.Commands, protocol.Command{
Name: c.Name(),
Description: c.Description(),
AliasesOffset: aliasesIndex,
PermissionLevel: protocol.CommandPermissionLevelAny,
Overloads: overloads,
})
}
pk.DynamicEnums = make([]protocol.DynamicEnum, 0, len(dynamicEnums))
for _, e := range dynamicEnums {
pk.DynamicEnums = append(pk.DynamicEnums, protocol.DynamicEnum{Type: e.Type, Values: e.Options})
}
enumValueIndices := make(map[string]uint32, len(enums)*3)
pk.EnumValues = make([]string, 0, len(enumValueIndices))
pk.Enums = make([]protocol.CommandEnum, 0, len(enums))
for _, enum := range enums {
protoEnum := protocol.CommandEnum{Type: enum.Type}
for _, opt := range enum.Options {
index, ok := enumValueIndices[opt]
if !ok {
index = uint32(len(pk.EnumValues))
enumValueIndices[opt] = index
pk.EnumValues = append(pk.EnumValues, opt)
}
protoEnum.ValueIndices = append(protoEnum.ValueIndices, index)
}
pk.Enums = append(pk.Enums, protoEnum)
}
return pk, m
}
// sendAvailableCommands sends all available commands of the server. Once sent, they will be visible in the
// /help list and will be auto-completed.
func (s *Session) sendAvailableCommands(co Controllable, softEnums map[string]struct{}) map[string]map[int]cmd.Runnable {
commands := cmd.Commands()
pk, m := BuildAvailableCommands(commands, co, softEnums)
s.writePacket(pk)
return m
}
type commandEnum struct {
Type string
Options []string
}
// valueToParamType finds the command argument type of the value passed and returns it, in addition to creating
// an enum if applicable.
func valueToParamType(i cmd.ParamInfo, source cmd.Source) (t uint32, enum commandEnum) {
switch i.Value.(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
return protocol.CommandArgTypeInt, enum
case float32, float64:
return protocol.CommandArgTypeFloat, enum
case string:
return protocol.CommandArgTypeString, enum
case cmd.Varargs:
return protocol.CommandArgTypeRawText, enum
case cmd.Target, []cmd.Target:
return protocol.CommandArgTypeTarget, enum
case bool:
return 0, commandEnum{
Type: "Boolean",
Options: []string{"true", "false"},
}
case mgl64.Vec3:
return protocol.CommandArgTypePosition, enum
case cmd.SubCommand:
return 0, commandEnum{
Type: "SubCommand" + i.Name,
Options: []string{i.Name},
}
}
if enum, ok := i.Value.(cmd.Enum); ok {
return 0, commandEnum{
Type: enum.Type(),
Options: enum.Options(source),
}
}
return protocol.CommandArgTypeValue, enum
}
// resendCommands resends all commands that a Session has access to if the map of runnable commands passed does not
// match with the commands that the Session is currently allowed to execute.
// True is returned if the commands were resent.
func (s *Session) resendCommands(
before map[string]map[int]cmd.Runnable,
co Controllable,
softEnums map[string]struct{},
) (map[string]map[int]cmd.Runnable, bool) {
commands := cmd.Commands()
m := make(map[string]map[int]cmd.Runnable, len(commands))
for alias, c := range commands {
if c.Name() == alias {
if run := c.Runnables(co); len(run) > 0 {
m[alias] = run
}
}
}
if len(before) != len(m) {
return s.sendAvailableCommands(co, softEnums), true
}
// First check for commands that were newly added.
for name, r := range m {
for k := range r {
if _, ok := before[name][k]; !ok {
return s.sendAvailableCommands(co, softEnums), true
}
}
}
// Then check for commands that a player could execute before, but no longer can.
for name, r := range before {
for k := range r {
if _, ok := m[name][k]; !ok {
return s.sendAvailableCommands(co, softEnums), true
}
}
}
return m, false
}
// enums returns a map of all enums exposed to the Session and records the values those enums currently hold.
func (s *Session) enums(co Controllable) (map[string]cmd.Enum, map[string][]string) {
enums, enumValues := make(map[string]cmd.Enum), make(map[string][]string)
for alias, c := range cmd.Commands() {
if c.Name() == alias {
for _, params := range c.Params(co) {
for _, paramInfo := range params {
if enum, ok := paramInfo.Value.(cmd.Enum); ok {
enums[enum.Type()] = enum
enumValues[enum.Type()] = enum.Options(co)
}
}
}
}
}
return enums, enumValues
}
// resendEnums checks the options of the enums passed against the values that were previously recorded. If they do not
// match, and the enum is in softEnums, the enum is resent via UpdateSoftEnum. If the enum is not yet in softEnums,
// it is added and the full AvailableCommands packet is resent.
func (s *Session) resendEnums(
enums map[string]cmd.Enum,
before map[string][]string,
softEnums map[string]struct{},
r map[string]map[int]cmd.Runnable,
c Controllable,
) map[string]map[int]cmd.Runnable {
for name, enum := range enums {
valuesBefore := before[name]
values := enum.Options(c)
before[name] = values
changed := false
if len(valuesBefore) != len(values) {
changed = true
} else {
for k, v := range values {
if valuesBefore[k] != v {
changed = true
break
}
}
}
if !changed {
continue
}
if _, dynamic := softEnums[name]; dynamic {
s.writePacket(&packet.UpdateSoftEnum{EnumType: name, Options: values, ActionType: packet.SoftEnumActionSet})
} else {
softEnums[name] = struct{}{}
r = s.sendAvailableCommands(c, softEnums)
}
}
return r
}
+127
View File
@@ -0,0 +1,127 @@
package session
import (
"github.com/df-mc/dragonfly/server/block/cube"
"github.com/df-mc/dragonfly/server/cmd"
"github.com/df-mc/dragonfly/server/entity/effect"
"github.com/df-mc/dragonfly/server/item"
"github.com/df-mc/dragonfly/server/item/inventory"
"github.com/df-mc/dragonfly/server/player/chat"
"github.com/df-mc/dragonfly/server/player/debug"
"github.com/df-mc/dragonfly/server/player/dialogue"
"github.com/df-mc/dragonfly/server/player/form"
"github.com/df-mc/dragonfly/server/player/hud"
"github.com/df-mc/dragonfly/server/player/skin"
"github.com/df-mc/dragonfly/server/world"
"github.com/go-gl/mathgl/mgl64"
"github.com/google/uuid"
"golang.org/x/text/language"
)
// Controllable represents an entity that may be controlled by a Session. Generally, Controllable is
// implemented in the form of a Player.
// Methods in Controllable will be added as Session needs them in order to handle packets.
type Controllable interface {
Name() string
world.Entity
item.User
dialogue.Submitter
form.Submitter
cmd.Source
chat.Subscriber
hud.Renderer
debug.Renderer
Locale() language.Tag
SetHeldItems(right, left item.Stack)
SetHeldSlot(slot int) error
Move(deltaPos mgl64.Vec3, deltaYaw, deltaPitch float64)
Speed() float64
FlightSpeed() float64
VerticalFlightSpeed() float64
Sleep(pos cube.Pos)
Wake()
Chat(msg ...any)
ExecuteCommand(commandLine string)
GameMode() world.GameMode
SetGameMode(mode world.GameMode)
Effects() []effect.Effect
UseItem()
ReleaseItem()
UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3)
UseItemOnEntity(e world.Entity) bool
BreakBlock(pos cube.Pos)
PickBlock(pos cube.Pos)
AttackEntity(e world.Entity) bool
Drop(s item.Stack) (n int)
SwingArm()
PunchAir()
Health() float64
MaxHealth() float64
Absorption() float64
Food() int
ExperienceLevel() int
ExperienceProgress() float64
SetExperienceLevel(level int)
EnchantmentSeed() int64
ResetEnchantmentSeed()
Respawn() *world.EntityHandle
Dead() bool
StartSneaking()
Sneaking() bool
StopSneaking()
StartSprinting()
Sprinting() bool
StopSprinting()
StartSwimming()
Swimming() bool
StopSwimming()
StartCrawling()
Crawling() bool
StopCrawling()
StartFlying()
Flying() bool
StopFlying()
StartGliding()
Gliding() bool
StopGliding()
Jump()
StartBreaking(pos cube.Pos, face cube.Face)
ContinueBreaking(face cube.Face)
FinishBreaking()
AbortBreaking()
Exhaust(points float64)
OpenSign(pos cube.Pos, frontSide bool)
EditSign(pos cube.Pos, frontText, backText string) error
TurnLecternPage(pos cube.Pos, page int) error
EnderChestInventory() *inventory.Inventory
MoveItemsToInventory()
// UUID returns the UUID of the controllable. It must be unique for all controllable entities present in
// the server.
UUID() uuid.UUID
// XUID returns the XBOX Live User ID of the controllable. Every controllable must have one of these if
// they are authenticated via XBOX Live, as they must be connected to an XBOX Live account.
XUID() string
// Skin returns the skin of the controllable. Each controllable must have a skin, as it defines how the
// entity looks in the world.
Skin() skin.Skin
SetSkin(skin.Skin)
UpdateDiagnostics(Diagnostics)
}
+29
View File
@@ -0,0 +1,29 @@
package session
// Diagnostics represents the latest diagnostics data of the client.
type Diagnostics struct {
// AverageFramesPerSecond is the average amount of frames per second that the client has been
// running at.
AverageFramesPerSecond float64
// AverageServerSimTickTime is the average time that the server spends simulating a single tick
// in milliseconds.
AverageServerSimTickTime float64
// AverageClientSimTickTime is the average time that the client spends simulating a single tick
// in milliseconds.
AverageClientSimTickTime float64
// AverageBeginFrameTime is the average time that the client spends beginning a frame in
// milliseconds.
AverageBeginFrameTime float64
// AverageInputTime is the average time that the client spends processing input in milliseconds.
AverageInputTime float64
// AverageRenderTime is the average time that the client spends rendering in milliseconds.
AverageRenderTime float64
// AverageEndFrameTime is the average time that the client spends ending a frame in milliseconds.
AverageEndFrameTime float64
// AverageRemainderTimePercent is the average percentage of time that the client spends on
// tasks that are not accounted for.
AverageRemainderTimePercent float64
// AverageUnaccountedTimePercent is the average percentage of time that the client spends on
// unaccounted tasks.
AverageUnaccountedTimePercent float64
}
+7
View File
@@ -0,0 +1,7 @@
// Code generated by .github/workflows/push.yml; DO NOT EDIT
package session
// enchantNames are names translated to the 'Standard Galactic Alphabet' client-side. The names generally have no meaning
// on the vanilla server implementation, so we can sneak some easter eggs in here without anyone noticing.
var enchantNames = []string{"aabstractt", "abimek", "aericio", "aimjel", "akmal fairuz", "alvin0319", "andreashgk", "assassin ghost yt", "atm85", "azvyl", "blackjack200", "cetfu", "cjmustard", "cooldogedev", "cqdetdev", "da pig guy", "daft0175", "dasciam", "deniel world", "didntpot", "driftlgtm", "eminarican", "endermanbugzjfc", "erkam246", "ethaniccc", "fdutch", "flonja", "game parrot", "gewinum", "hashim the arab", "hochbaum", "hydzilla", "im da real ani", "inotflying", "ipad54", "its zodia x", "ivan craft623", "javier leon9966", "just tal develops", "k4ties", "krivey", "manab-pr", "memoxiiii", "mmm545", "mohamed587100", "myma qc", "natuyasai natuo", "neutronic mc", "nonono697", "nope not dark", "provsalt", "restart fu", "riccskn", "robertdudaa", "royal mcpe", "sallypemdas", "sandertv", "schphe", "sculas", "sergittos", "smell-of-curry", "sqmatheus", "ssaini123456", "studgi", "superomarking", "t14 raptor", "tadhunt", "theaddonn", "thicksunny", "thunder33345", "tripple awap", "tristanmorgan", "twisted asylum mc", "unickorn", "unknown ore", "uramnoil", "wqrro", "x natsuri", "x toast-dev", "x4caa", "xd-pro"}
+306
View File
@@ -0,0 +1,306 @@
package session
import (
"math"
"time"
"github.com/df-mc/dragonfly/server/block/cube"
"github.com/df-mc/dragonfly/server/entity"
"github.com/df-mc/dragonfly/server/entity/effect"
"github.com/df-mc/dragonfly/server/internal/nbtconv"
"github.com/df-mc/dragonfly/server/item"
"github.com/df-mc/dragonfly/server/item/potion"
"github.com/df-mc/dragonfly/server/world"
"github.com/go-gl/mathgl/mgl64"
"github.com/google/uuid"
"github.com/sandertv/gophertunnel/minecraft/protocol"
)
// parseEntityMetadata returns an entity metadata object with default values. It is equivalent to setting
// all properties to their default values and disabling all flags.
func (s *Session) parseEntityMetadata(e world.Entity) protocol.EntityMetadata {
bb := e.H().Type().BBox(e)
m := protocol.NewEntityMetadata()
m[protocol.EntityDataKeyWidth] = float32(bb.Width())
m[protocol.EntityDataKeyHeight] = float32(bb.Height())
m[protocol.EntityDataKeyEffectColor] = int32(0)
m[protocol.EntityDataKeyEffectAmbience] = byte(0)
m[protocol.EntityDataKeyColorIndex] = byte(0)
m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagHasGravity)
m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagClimb)
if g, ok := e.H().Type().(glint); ok && g.Glint() {
m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagEnchanted)
}
if e.H().Type() == entity.LingeringPotionType {
m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagLingering)
}
s.addSpecificMetadata(e, m)
if ent, ok := e.(*entity.Ent); ok {
s.addSpecificMetadata(ent.Behaviour(), m)
}
return m
}
func (s *Session) addSpecificMetadata(e any, m protocol.EntityMetadata) {
if sn, ok := e.(sneaker); ok && sn.Sneaking() {
m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagSneaking)
}
if sp, ok := e.(sprinter); ok && sp.Sprinting() {
m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagSprinting)
}
if sw, ok := e.(swimmer); ok && sw.Swimming() {
m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagSwimming)
}
if cr, ok := e.(crawler); ok && cr.Crawling() {
m.SetFlag(protocol.EntityDataKeyFlagsTwo, protocol.EntityDataFlagCrawling&63)
}
if gl, ok := e.(glider); ok && gl.Gliding() {
m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagGliding)
}
if bb, ok := e.(baby); ok && bb.Baby() {
m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagBaby)
}
if b, ok := e.(breather); ok {
m[protocol.EntityDataKeyAirSupply] = int16(b.AirSupply().Milliseconds() / 50)
m[protocol.EntityDataKeyAirSupplyMax] = int16(b.MaxAirSupply().Milliseconds() / 50)
if b.Breathing() {
m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagBreathing)
}
}
if i, ok := e.(invisible); ok && i.Invisible() {
m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagInvisible)
}
if i, ok := e.(immobile); ok && i.Immobile() {
m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagNoAI)
}
if o, ok := e.(onFire); ok && o.OnFireDuration() > 0 {
m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagOnFire)
}
if u, ok := e.(using); ok && u.UsingItem() {
m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagUsingItem)
}
if c, ok := e.(arrow); ok && c.Critical() {
m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagCritical)
}
if g, ok := e.(gameMode); ok {
if g.GameMode().HasCollision() {
m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagHasCollision)
}
}
if o, ok := e.(orb); ok {
m[protocol.EntityDataKeyValue] = int32(o.Experience())
}
if f, ok := e.(firework); ok {
m[protocol.EntityDataKeyDisplayTileRuntimeID] = nbtconv.WriteItem(item.NewStack(f.Firework(), 1), false)
if o, ok := e.(owned); ok && f.Attached() && o.Owner() != nil {
m[protocol.EntityDataKeyCustomDisplay] = int64(s.handleRuntimeID(o.Owner()))
}
} else if o, ok := e.(owned); ok && o.Owner() != nil {
m[protocol.EntityDataKeyOwner] = int64(s.handleRuntimeID(o.Owner()))
}
if sc, ok := e.(scaled); ok {
m[protocol.EntityDataKeyScale] = float32(sc.Scale())
}
if t, ok := e.(tnt); ok {
m[protocol.EntityDataKeyFuseTime] = int32(t.Fuse().Milliseconds() / 50)
m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagIgnited)
}
if n, ok := e.(named); ok {
name := n.NameTag()
m[protocol.EntityDataKeyName] = name
if name == "" {
m[protocol.EntityDataKeyAlwaysShowNameTag] = uint8(0)
m.UnsetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagAlwaysShowName)
m.UnsetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagShowName)
} else {
m[protocol.EntityDataKeyAlwaysShowNameTag] = uint8(1)
m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagAlwaysShowName)
m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagShowName)
}
}
if sc, ok := e.(scoreTag); ok {
m[protocol.EntityDataKeyScore] = sc.ScoreTag()
}
if sl, ok := e.(sleeper); ok {
if pos, ok := sl.Sleeping(); ok {
m[protocol.EntityDataKeyBedPosition] = protocol.BlockPos{int32(pos[0]), int32(pos[1]), int32(pos[2])}
// For some reason there is no such flag in gophertunnel.
m.SetFlag(protocol.EntityDataKeyPlayerFlags, 1)
}
}
if c, ok := e.(areaEffectCloud); ok {
m[protocol.EntityDataKeyDataRadius] = float32(c.Radius())
// We purposely fill these in with invalid values to disable the client-sided shrinking of the cloud.
m[protocol.EntityDataKeyDataDuration] = int32(math.MaxInt32)
m[protocol.EntityDataKeyDataChangeOnPickup] = float32(math.SmallestNonzeroFloat32)
m[protocol.EntityDataKeyDataChangeRate] = float32(math.SmallestNonzeroFloat32)
colour, am := effect.ResultingColour(c.Effects())
m[protocol.EntityDataKeyEffectColor] = nbtconv.Int32FromRGBA(colour)
if am {
m[protocol.EntityDataKeyEffectAmbience] = byte(1)
} else {
m[protocol.EntityDataKeyEffectAmbience] = byte(0)
}
}
if l, ok := e.(living); ok && s.ent != nil && s.ent.UUID() == l.UUID() {
deathPos, deathDimension, died := l.DeathPosition()
if died {
dim, _ := world.DimensionID(deathDimension)
m[protocol.EntityDataKeyPlayerLastDeathPosition] = vec64To32(deathPos)
m[protocol.EntityDataKeyPlayerLastDeathDimension] = int32(dim)
}
m[protocol.EntityDataKeyPlayerHasDied] = boolByte(died)
}
if p, ok := e.(splash); ok {
m[protocol.EntityDataKeyAuxValueData] = int16(p.Potion().Uint8())
if tip := p.Potion().Uint8(); tip > 4 {
m[protocol.EntityDataKeyCustomDisplay] = tip + 1
}
}
if eff, ok := e.(effectBearer); ok {
var packedEffects int64
for _, ef := range eff.Effects() {
if !ef.ParticlesHidden() {
id, found := effect.ID(ef.Type())
if !found {
continue
}
packedEffects = (packedEffects << 7) | int64(id<<1)
if ef.Ambient() {
packedEffects |= 1
}
}
}
m[protocol.EntityDataKeyVisibleMobEffects] = packedEffects
}
if v, ok := e.(variable); ok {
m[protocol.EntityDataKeyVariant] = v.Variant()
}
if mv, ok := e.(markVariable); ok {
m[protocol.EntityDataKeyMarkVariant] = mv.MarkVariant()
}
}
type sneaker interface {
Sneaking() bool
}
type sprinter interface {
Sprinting() bool
}
type swimmer interface {
Swimming() bool
}
type crawler interface {
Crawling() bool
}
type glider interface {
Gliding() bool
}
type baby interface {
Baby() bool
}
type breather interface {
Breathing() bool
AirSupply() time.Duration
MaxAirSupply() time.Duration
}
type immobile interface {
Immobile() bool
}
type invisible interface {
Invisible() bool
}
type scaled interface {
Scale() float64
}
type owned interface {
Owner() *world.EntityHandle
}
type named interface {
NameTag() string
}
type scoreTag interface {
ScoreTag() string
}
type splash interface {
Potion() potion.Potion
}
type glint interface {
Glint() bool
}
type areaEffectCloud interface {
effectBearer
Radius() float64
}
type onFire interface {
OnFireDuration() time.Duration
}
type effectBearer interface {
Effects() []effect.Effect
}
type using interface {
UsingItem() bool
}
type arrow interface {
Critical() bool
}
type orb interface {
Experience() int
}
type firework interface {
Firework() item.Firework
Attached() bool
}
type gameMode interface {
GameMode() world.GameMode
}
type sleeper interface {
Sleeping() (cube.Pos, bool)
}
type tnt interface {
Fuse() time.Duration
}
type living interface {
UUID() uuid.UUID
DeathPosition() (mgl64.Vec3, world.Dimension, bool)
}
type variable interface {
Variant() int32
}
type markVariable interface {
MarkVariant() int32
}
+13
View File
@@ -0,0 +1,13 @@
package session
import (
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
// packetHandler represents a type that is able to handle a specific type of incoming packets from the client.
type packetHandler interface {
// Handle handles an incoming packet from the client. The session of the client is passed to the packetHandler.
// Handle returns an error if the packet was in any way invalid.
Handle(p packet.Packet, s *Session, tx *world.Tx, c Controllable) error
}
+270
View File
@@ -0,0 +1,270 @@
package session
import (
"fmt"
"math/rand/v2"
"github.com/df-mc/dragonfly/server/block"
"github.com/df-mc/dragonfly/server/item"
"github.com/df-mc/dragonfly/server/world"
"github.com/df-mc/dragonfly/server/world/sound"
"github.com/sandertv/gophertunnel/minecraft/protocol"
)
const (
// anvilInputSlot is the slot index of the input item in the anvil.
anvilInputSlot = 0x1
// anvilMaterialSlot is the slot index of the material in the anvil.
anvilMaterialSlot = 0x2
)
// handleCraftRecipeOptional handles the CraftRecipeOptional request action, sent when taking a result from an anvil
// menu. It also contains information such as the new name of the item and the multi-recipe network ID.
func (h *ItemStackRequestHandler) handleCraftRecipeOptional(a *protocol.CraftRecipeOptionalStackRequestAction, s *Session, filterStrings []string, co Controllable, tx *world.Tx) (err error) {
// First check if there actually is an anvil opened.
if !s.containerOpened.Load() {
return fmt.Errorf("no anvil container opened")
}
pos := *s.openedPos.Load()
anvil, ok := tx.Block(pos).(block.Anvil)
if !ok {
return fmt.Errorf("no anvil container opened")
}
if index := int(a.FilterStringIndex); len(filterStrings) > 0 && (index < 0 || index >= len(filterStrings)) {
return fmt.Errorf("filter string index %v is out of bounds", a.FilterStringIndex)
}
input, _ := h.itemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerAnvilInput},
Slot: anvilInputSlot,
}, s, tx)
if input.Empty() {
return fmt.Errorf("no item in input input slot")
}
material, _ := h.itemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerAnvilMaterial},
Slot: anvilMaterialSlot,
}, s, tx)
result := input
// The sum of the input's anvil cost as well as the material's anvil cost.
anvilCost := input.AnvilCost()
if !material.Empty() {
anvilCost += material.AnvilCost()
}
// The material input may be empty (if the player is only renaming, for example).
var actionCost, renameCost, repairCount int
if !material.Empty() {
// First check if we are trying to repair the item with a material.
if repairable, ok := input.Item().(item.Repairable); ok && repairable.RepairableBy(material) {
result, actionCost, repairCount, err = repairItemWithMaterial(input, material, result)
if err != nil {
return err
}
} else {
_, book := material.Item().(item.EnchantedBook)
_, durable := input.Item().(item.Durable)
// Ensure that the input item is repairable, or the material item is an enchanted book. If not, this is an
// invalid scenario, and we should return an error.
enchantedBook := book && len(material.Enchantments()) > 0
if !enchantedBook && (input.Item() != material.Item() || !durable) {
return fmt.Errorf("input item is not repairable/same type or material item is not an enchanted book")
}
// If the material is another durable item, we just need to increase the durability of the result by the
// material's durability at 12%.
if durable && !enchantedBook {
result, actionCost = repairItemWithDurable(input, material, result)
}
// Merge enchantments on the material item onto the result item.
var hasCompatible, hasIncompatible bool
result, hasCompatible, hasIncompatible, actionCost = mergeEnchantments(input, material, result, actionCost, enchantedBook)
// If we don't have any compatible enchantments and the input item isn't durable, then this is an invalid
// scenario, and we should return an error.
if !durable && hasIncompatible && !hasCompatible {
return fmt.Errorf("no compatible enchantments but have incompatible ones")
}
}
}
// If we have a filter string, then the client is intending to rename the item.
if len(filterStrings) > 0 {
renameCost = 1
actionCost += renameCost
result = result.WithCustomName(filterStrings[int(a.FilterStringIndex)])
}
// Calculate the total cost. (action cost + anvil cost)
cost := actionCost + anvilCost
if cost <= 0 {
return fmt.Errorf("no action was taken")
}
// If our only action was renaming, the cost should never exceed 40.
if renameCost == actionCost && renameCost > 0 && cost >= 40 {
cost = 39
}
// We can bypass the "impossible cost" limit if we're in creative mode.
c := co.GameMode().CreativeInventory()
if cost >= 40 && !c {
return fmt.Errorf("impossible cost")
}
// Ensure we have enough levels (or if we're in creative mode, ignore the cost) to perform the action.
level := co.ExperienceLevel()
if level < cost && !c {
return fmt.Errorf("not enough experience")
} else if !c {
co.SetExperienceLevel(level - cost)
}
// If we had a result item, we need to calculate the new anvil cost and update it on the item.
if !result.Empty() {
updatedAnvilCost := result.AnvilCost()
if !material.Empty() && updatedAnvilCost < material.AnvilCost() {
updatedAnvilCost = material.AnvilCost()
}
if renameCost != actionCost || renameCost == 0 {
updatedAnvilCost = updatedAnvilCost*2 + 1
}
result = result.WithAnvilCost(updatedAnvilCost)
}
// If we're not in creative mode, we have a 12% chance of the anvil degrading down one state. If that is the case, we
// need to play the related sound and update the block state. Otherwise, we play a regular anvil use sound.
if !c && rand.Float64() < 0.12 {
damaged := anvil.Break()
if _, ok := damaged.(block.Air); ok {
tx.PlaySound(pos.Vec3Centre(), sound.AnvilBreak{})
} else {
tx.PlaySound(pos.Vec3Centre(), sound.AnvilUse{})
}
defer tx.SetBlock(pos, damaged, nil)
} else {
tx.PlaySound(pos.Vec3Centre(), sound.AnvilUse{})
}
h.setItemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerAnvilInput},
Slot: anvilInputSlot,
}, item.Stack{}, s, tx)
if repairCount > 0 {
h.setItemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerAnvilMaterial},
Slot: anvilMaterialSlot,
}, material.Grow(-repairCount), s, tx)
} else {
h.setItemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerAnvilMaterial},
Slot: anvilMaterialSlot,
}, item.Stack{}, s, tx)
}
return h.createResults(s, tx, result)
}
// repairItemWithMaterial is a helper function that repairs an item stack with a given material stack. It returns the new item
// stack, the cost, and the repaired items count.
func repairItemWithMaterial(input item.Stack, material item.Stack, result item.Stack) (item.Stack, int, int, error) {
// Calculate the durability delta using the maximum durability and the current durability.
delta := min(input.MaxDurability()-input.Durability(), input.MaxDurability()/4)
if delta <= 0 {
return item.Stack{}, 0, 0, fmt.Errorf("input item is already fully repaired")
}
// While the durability delta is more than zero and the repaired count is under the material count, increase
// the durability of the result by the durability delta.
var cost, count int
for ; delta > 0 && count < material.Count(); count, delta = count+1, min(result.MaxDurability()-result.Durability(), result.MaxDurability()/4) {
result = result.WithDurability(result.Durability() + delta)
cost++
}
return result, cost, count, nil
}
// repairItemWithDurable is a helper function that repairs an item with another durable item stack.
func repairItemWithDurable(input item.Stack, durable item.Stack, result item.Stack) (item.Stack, int) {
durability := input.Durability() + durable.Durability() + input.MaxDurability()*12/100
if durability > input.MaxDurability() {
durability = input.MaxDurability()
}
// Ensure the durability is higher than the input's current durability.
var cost int
if durability > input.Durability() {
result = result.WithDurability(durability)
cost += 2
}
return result, cost
}
// mergeEnchantments merges the enchantments of the material item stack onto the result item stack and returns the result
// item stack, booleans indicating whether the enchantments had any compatible or incompatible enchantments, and the cost.
func mergeEnchantments(input item.Stack, material item.Stack, result item.Stack, cost int, enchantedBook bool) (item.Stack, bool, bool, int) {
var hasCompatible, hasIncompatible bool
for _, enchant := range material.Enchantments() {
// First ensure that the enchantment type is compatible with the input item.
enchantType := enchant.Type()
compatible := enchantType.CompatibleWithItem(input.Item())
if _, ok := input.Item().(item.EnchantedBook); ok {
compatible = true
}
// Then ensure that each input enchantment is compatible with this material enchantment. If one is not compatible,
// increase the cost by one.
for _, otherEnchant := range input.Enchantments() {
if otherType := otherEnchant.Type(); enchantType != otherType && !enchantType.CompatibleWithEnchantment(otherType) {
compatible = false
cost++
}
}
// Skip the enchantment if it isn't compatible with enchantments on the input item.
if !compatible {
hasIncompatible = true
continue
}
hasCompatible = true
resultLevel := enchant.Level()
levelCost := resultLevel
// Check if we have an enchantment of the same type on the input item.
if existingEnchant, ok := input.Enchantment(enchantType); ok {
if existingEnchant.Level() > resultLevel || (existingEnchant.Level() == resultLevel && resultLevel == enchantType.MaxLevel()) {
// The result level is either lower than the existing enchantment's level or is higher than the maximum
// level, so skip this enchantment.
hasIncompatible = true
continue
} else if existingEnchant.Level() == resultLevel {
// If the input level is equal to the material level, increase the result level by one.
resultLevel++
}
// Update the level cost. (result level - existing level)
levelCost = resultLevel - existingEnchant.Level()
}
// Now calculate the rarity cost. This is just the application cost of the rarity, however if the
// material is an enchanted book, then the rarity cost gets halved. If the new rarity cost is under one,
// it is set to one.
rarityCost := enchantType.Rarity().Cost()
if enchantedBook {
rarityCost = max(1, rarityCost/2)
}
// Update the result item with the new enchantment.
result = result.WithEnchantments(item.NewEnchantment(enchantType, resultLevel))
// Update the cost appropriately.
cost += rarityCost * levelCost
if input.Count() > 1 {
cost = 40
}
}
return result, hasCompatible, hasIncompatible, cost
}
+78
View File
@@ -0,0 +1,78 @@
package session
import (
"fmt"
"github.com/df-mc/dragonfly/server/block"
"github.com/df-mc/dragonfly/server/entity/effect"
"github.com/df-mc/dragonfly/server/item"
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol"
)
// beaconInputSlot is the slot index of the input item in the beacon.
const beaconInputSlot = 0x1b
// handleBeaconPayment handles the selection of effects in a beacon and the removal of the item used to pay
// for those effects.
func (h *ItemStackRequestHandler) handleBeaconPayment(a *protocol.BeaconPaymentStackRequestAction, s *Session, tx *world.Tx) error {
slot := protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerBeaconPayment},
Slot: beaconInputSlot,
}
// First check if there actually is a beacon opened.
if !s.containerOpened.Load() {
return fmt.Errorf("no beacon container opened")
}
pos := *s.openedPos.Load()
beacon, ok := tx.Block(pos).(block.Beacon)
if !ok {
return fmt.Errorf("no beacon container opened")
}
// Check if the item present in the beacon slot is valid.
payment, _ := h.itemInSlot(slot, s, tx)
if payable, ok := payment.Item().(item.BeaconPayment); !ok || !payable.PayableForBeacon() {
return fmt.Errorf("item %#v in beacon slot cannot be used as payment", payment)
}
// Check if the effects are valid and allowed for the beacon's level.
if !h.validBeaconEffect(a.PrimaryEffect, beacon) {
return fmt.Errorf("primary effect selected is not allowed: %v for level %v", a.PrimaryEffect, beacon.Level())
} else if !h.validBeaconEffect(a.SecondaryEffect, beacon) || (beacon.Level() < 4 && a.SecondaryEffect != 0) {
return fmt.Errorf("secondary effect selected is not allowed: %v for level %v", a.SecondaryEffect, beacon.Level())
}
primary, pOk := effect.ByID(int(a.PrimaryEffect))
secondary, sOk := effect.ByID(int(a.SecondaryEffect))
if pOk {
beacon.Primary = primary.(effect.LastingType)
}
if sOk {
beacon.Secondary = secondary.(effect.LastingType)
}
tx.SetBlock(pos, beacon, nil)
// The client will send a Destroy action after this action, but we can't rely on that because the client
// could just not send it.
// We just ignore the next Destroy action and set the item to air here.
h.setItemInSlot(slot, item.Stack{}, s, tx)
h.ignoreDestroy = true
return nil
}
// validBeaconEffect checks if the ID passed is a valid beacon effect.
func (h *ItemStackRequestHandler) validBeaconEffect(id int32, beacon block.Beacon) bool {
switch id {
case 1, 3:
return beacon.Level() >= 1
case 8, 11:
return beacon.Level() >= 2
case 5:
return beacon.Level() >= 3
case 10:
return beacon.Level() >= 4
case 0:
return true
}
return false
}
+111
View File
@@ -0,0 +1,111 @@
package session
import (
"fmt"
"github.com/df-mc/dragonfly/server/block"
"github.com/df-mc/dragonfly/server/block/cube"
"github.com/df-mc/dragonfly/server/entity"
"github.com/df-mc/dragonfly/server/world"
"github.com/go-gl/mathgl/mgl64"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
"strings"
"unicode/utf8"
)
// BlockActorDataHandler handles an incoming BlockActorData packet from the client, sent for some block entities like
// signs when they are edited.
type BlockActorDataHandler struct{}
// Handle ...
func (b BlockActorDataHandler) Handle(p packet.Packet, s *Session, tx *world.Tx, c Controllable) error {
pk := p.(*packet.BlockActorData)
if id, ok := pk.NBTData["id"]; ok {
pos := blockPosFromProtocol(pk.Position)
if !canReach(c, pos.Vec3Middle()) {
return fmt.Errorf("block at %v is not within reach", pos)
}
if id == "Sign" {
return b.handleSign(pk, pos, s, tx, c)
}
return fmt.Errorf("unhandled block actor data ID %v", id)
}
return fmt.Errorf("block actor data without 'id' tag: %v", pk.NBTData)
}
// handleSign handles the BlockActorData packet sent when editing a sign.
func (b BlockActorDataHandler) handleSign(pk *packet.BlockActorData, pos cube.Pos, s *Session, tx *world.Tx, co Controllable) error {
if _, ok := tx.Block(pos).(block.Sign); !ok {
s.conf.Log.Debug("no sign at position of sign block actor data", "pos", pos.String())
return nil
}
frontText, err := b.textFromNBTData(pk.NBTData, true)
if err != nil {
return err
}
backText, err := b.textFromNBTData(pk.NBTData, false)
if err != nil {
return err
}
if err := co.EditSign(pos, frontText, backText); err != nil {
return err
}
return nil
}
// textFromNBTData attempts to retrieve the text from the NBT data of specific sign from the BlockActorData packet.
func (b BlockActorDataHandler) textFromNBTData(data map[string]any, frontSide bool) (string, error) {
var sideData map[string]any
var side string
if frontSide {
frontSide, ok := data["FrontText"].(map[string]any)
if !ok {
return "", fmt.Errorf("sign block actor data 'FrontText' tag was not found or was not a map: %#v", data["FrontText"])
}
sideData = frontSide
side = "front"
} else {
backSide, ok := data["BackText"].(map[string]any)
if !ok {
return "", fmt.Errorf("sign block actor data 'BackText' tag was not found or was not a map: %#v", data["BackText"])
}
sideData = backSide
side = "back"
}
var text string
pkText, ok := sideData["Text"]
if !ok {
return "", fmt.Errorf("sign block actor data had no 'Text' tag for side %s", side)
}
if text, ok = pkText.(string); !ok {
return "", fmt.Errorf("sign block actor data 'Text' tag was not a string for side %s: %#v", side, pkText)
}
// Verify that the text was valid. It must be valid UTF8 and not more than 100 characters long.
text = strings.TrimRight(text, "\n")
if len(text) > 256 {
return "", fmt.Errorf("sign block actor data text was longer than 256 characters for side %s", side)
}
if !utf8.ValidString(text) {
return "", fmt.Errorf("sign block actor data text was not valid UTF8 for side %s", side)
}
return text, nil
}
// canReach checks if a player can reach a position with its current range. The range depends on if the player
// is either survival or creative mode.
func canReach(c Controllable, pos mgl64.Vec3) bool {
const (
creativeRange = 14.0
survivalRange = 8.0
)
if !c.GameMode().AllowsInteraction() {
return false
}
eyes := entity.EyePosition(c)
if c.GameMode().CreativeInventory() {
return eyes.Sub(pos).Len() <= creativeRange && !c.Dead()
}
return eyes.Sub(pos).Len() <= survivalRange && !c.Dead()
}
@@ -0,0 +1,17 @@
package session
import (
"github.com/df-mc/dragonfly/server/block/cube"
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
// BlockPickRequestHandler handles the BlockPickRequest packet.
type BlockPickRequestHandler struct{}
// Handle ...
func (b BlockPickRequestHandler) Handle(p packet.Packet, _ *Session, _ *world.Tx, c Controllable) error {
pk := p.(*packet.BlockPickRequest)
c.PickBlock(cube.Pos{int(pk.Position.X()), int(pk.Position.Y()), int(pk.Position.Z())})
return nil
}
+76
View File
@@ -0,0 +1,76 @@
package session
import (
"fmt"
"github.com/df-mc/dragonfly/server/item"
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
// BookEditHandler handles the BookEdit packet.
type BookEditHandler struct{}
// Handle ...
func (b BookEditHandler) Handle(p packet.Packet, s *Session, _ *world.Tx, _ Controllable) error {
pk := p.(*packet.BookEdit)
it, err := s.inv.Item(int(pk.InventorySlot))
if err != nil {
return fmt.Errorf("invalid inventory slot index %v", pk.InventorySlot)
}
book, ok := it.Item().(item.BookAndQuill)
if !ok {
return fmt.Errorf("inventory slot %v does not contain a writable book", pk.InventorySlot)
}
page := int(pk.PageNumber)
if page >= 50 || page < 0 {
return fmt.Errorf("page number %v is out of bounds", pk.PageNumber)
}
if len(pk.Text) > 256 {
return fmt.Errorf("text can not be longer than 256 bytes")
}
slot := int(pk.InventorySlot)
switch pk.ActionType {
case packet.BookActionReplacePage:
book = book.SetPage(page, pk.Text)
case packet.BookActionAddPage:
if len(book.Pages) >= 50 {
return fmt.Errorf("unable to add page beyond 50")
}
if page >= len(book.Pages) && page <= len(book.Pages)+2 {
book = book.SetPage(page, "")
break
}
if _, ok := book.Page(page); !ok {
return fmt.Errorf("unable to insert page at %v", pk.PageNumber)
}
book = book.InsertPage(page, pk.Text)
case packet.BookActionDeletePage:
if _, ok := book.Page(page); !ok {
// We break here instead of returning an error because the client can be a page or two ahead in the UI then
// the actual pages representation server side. The client still sends the deletion indexes.
break
}
book = book.DeletePage(page)
case packet.BookActionSwapPages:
if pk.SecondaryPageNumber >= 50 {
return fmt.Errorf("page number out of bounds")
}
_, ok := book.Page(page)
_, ok2 := book.Page(int(pk.SecondaryPageNumber))
if !ok || !ok2 {
// We break here instead of returning an error because the client can try to swap pages that don't exist.
// This happens as a result of the client being a page or two ahead in the UI then the actual pages
// representation server side. The client still sends the swap indexes.
break
}
book = book.SwapPages(page, int(pk.SecondaryPageNumber))
case packet.BookActionSign:
_ = s.inv.SetItem(slot, it.WithItem(item.WrittenBook{Title: pk.Title, Author: pk.Author, Pages: book.Pages, Generation: item.OriginalGeneration()}))
return nil
}
_ = s.inv.SetItem(slot, it.WithItem(book))
return nil
}
@@ -0,0 +1,54 @@
package session
import (
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
// ClientCacheBlobStatusHandler handles the ClientCacheBlobStatus packet.
type ClientCacheBlobStatusHandler struct {
}
// Handle ...
func (c *ClientCacheBlobStatusHandler) Handle(p packet.Packet, s *Session, _ *world.Tx, _ Controllable) error {
pk := p.(*packet.ClientCacheBlobStatus)
resp := &packet.ClientCacheMissResponse{Blobs: make([]protocol.CacheBlob, 0, len(pk.MissHashes))}
s.blobMu.Lock()
for _, hit := range pk.HitHashes {
delete(s.blobs, hit)
c.resolveBlob(hit, s)
}
for _, miss := range pk.MissHashes {
blob, ok := s.blobs[miss]
if !ok {
// This is expected to happen sometimes, for example when we send the same block storage or biomes a lot of
// times in a short timeframe. There is no need to log this, it'll just cause unnecessary noise that doesn't
// actually aid in terms of information.
continue
}
resp.Blobs = append(resp.Blobs, protocol.CacheBlob{Hash: miss, Payload: blob})
c.resolveBlob(miss, s)
}
s.blobMu.Unlock()
if len(resp.Blobs) > 0 {
s.writePacket(resp)
}
return nil
}
// resolveBlob resolves a blob hash in the session passed. It assumes s.blobMu is locked upon calling.
func (c *ClientCacheBlobStatusHandler) resolveBlob(hash uint64, s *Session) {
leftover := make([]map[uint64]struct{}, 0, len(s.openChunkTransactions))
for _, m := range s.openChunkTransactions {
delete(m, hash)
if len(m) != 0 {
leftover = append(leftover, m)
}
}
s.openChunkTransactions = leftover
delete(s.blobs, hash)
}
+25
View File
@@ -0,0 +1,25 @@
package session
import (
"fmt"
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
// CommandRequestHandler handles the CommandRequest packet.
type CommandRequestHandler struct {
origin protocol.CommandOrigin
}
// Handle ...
func (h *CommandRequestHandler) Handle(p packet.Packet, _ *Session, _ *world.Tx, c Controllable) error {
pk := p.(*packet.CommandRequest)
if pk.Internal {
return fmt.Errorf("command request packet must never have the internal field set to true")
}
h.origin = pk.CommandOrigin
c.ExecuteCommand(pk.CommandLine)
return nil
}
+40
View File
@@ -0,0 +1,40 @@
package session
import (
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
// ContainerCloseHandler handles the ContainerClose packet.
type ContainerCloseHandler struct{}
// Handle ...
func (h *ContainerCloseHandler) Handle(p packet.Packet, s *Session, tx *world.Tx, c Controllable) error {
pk := p.(*packet.ContainerClose)
c.MoveItemsToInventory()
var containerType byte
switch pk.WindowID {
case 0:
// Closing of the normal inventory.
s.invOpened = false
case byte(s.openedWindowID.Load()):
containerType = byte(s.openedContainerID.Load())
s.closeCurrentContainer(tx, true)
case 0xff:
// Sent when an inventory/container is opened at the same time as chat.
s.invOpened = false
if s.containerOpened.Load() {
s.closeCurrentContainer(tx, false)
}
return nil
default:
containerType = pk.ContainerType
}
s.writePacket(&packet.ContainerClose{
WindowID: pk.WindowID,
ContainerType: containerType,
})
return nil
}
+312
View File
@@ -0,0 +1,312 @@
package session
import (
"fmt"
"github.com/df-mc/dragonfly/server/item"
"github.com/df-mc/dragonfly/server/item/creative"
"github.com/df-mc/dragonfly/server/item/inventory"
"github.com/df-mc/dragonfly/server/item/recipe"
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol"
"math"
"slices"
)
// handleCraft handles the CraftRecipe request action.
func (h *ItemStackRequestHandler) handleCraft(a *protocol.CraftRecipeStackRequestAction, s *Session, tx *world.Tx) error {
craft, ok := s.recipes[a.RecipeNetworkID]
if !ok {
// Try dynamic recipes if no static recipe matches
return h.tryDynamicCraft(s, tx, int(a.NumberOfCrafts))
}
_, shaped := craft.(recipe.Shaped)
_, shapeless := craft.(recipe.Shapeless)
if !shaped && !shapeless {
return fmt.Errorf("recipe with network id %v is not a shaped or shapeless recipe", a.RecipeNetworkID)
}
if craft.Block() != "crafting_table" {
return fmt.Errorf("recipe with network id %v is not a crafting table recipe", a.RecipeNetworkID)
}
timesCrafted := int(a.NumberOfCrafts)
if timesCrafted < 1 {
return fmt.Errorf("times crafted must be at least 1")
}
size := s.craftingSize()
offset := s.craftingOffset()
consumed := make([]bool, size)
for _, expected := range craft.Input() {
var processed bool
for slot := offset; slot < offset+size; slot++ {
if consumed[slot-offset] {
// We've already consumed this slot, skip it.
continue
}
has, _ := s.ui.Item(int(slot))
if has.Empty() != expected.Empty() || has.Count() < expected.Count()*timesCrafted {
// We can't process this item, as it's not a part of the recipe.
continue
}
if !matchingStacks(has, expected) {
// Not the same item.
continue
}
processed, consumed[slot-offset] = true, true
st := has.Grow(-expected.Count() * timesCrafted)
h.setItemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerCraftingInput},
Slot: byte(slot),
}, st, s, tx)
break
}
if !processed {
return fmt.Errorf("recipe %v: could not consume expected item: %v", a.RecipeNetworkID, expected)
}
}
return h.createResults(s, tx, repeatStacks(craft.Output(), timesCrafted)...)
}
// handleAutoCraft handles the AutoCraftRecipe request action.
func (h *ItemStackRequestHandler) handleAutoCraft(a *protocol.AutoCraftRecipeStackRequestAction, s *Session, tx *world.Tx) error {
craft, ok := s.recipes[a.RecipeNetworkID]
if !ok {
// Try dynamic recipes if no static recipe matches
return h.tryDynamicCraft(s, tx, int(a.TimesCrafted))
}
_, shaped := craft.(recipe.Shaped)
_, shapeless := craft.(recipe.Shapeless)
if !shaped && !shapeless {
return fmt.Errorf("recipe with network id %v is not a shaped or shapeless recipe", a.RecipeNetworkID)
}
if craft.Block() != "crafting_table" {
return fmt.Errorf("recipe with network id %v is not a crafting table recipe", a.RecipeNetworkID)
}
timesCrafted := int(a.TimesCrafted)
if timesCrafted < 1 {
return fmt.Errorf("times crafted must be at least 1")
}
flattenedInputs := make([]recipe.Item, 0, len(craft.Input()))
for _, i := range craft.Input() {
if i.Empty() {
// We don't actually need this item - it's empty, so avoid putting it in our flattened inputs.
continue
}
if ind := slices.IndexFunc(flattenedInputs, func(it recipe.Item) bool {
return matchingStacks(it, i)
}); ind >= 0 {
flattenedInputs[ind] = grow(i, flattenedInputs[ind].Count())
continue
}
flattenedInputs = append(flattenedInputs, i)
}
for _, expected := range flattenedInputs {
remaining := expected.Count() * timesCrafted
for id, inv := range map[byte]*inventory.Inventory{
protocol.ContainerCraftingInput: s.ui,
protocol.ContainerCombinedHotBarAndInventory: s.inv,
} {
for slot, has := range inv.Slots() {
if has.Empty() {
// We don't have this item, skip it.
continue
}
if !matchingStacks(has, expected) {
// Not the same item.
continue
}
removal := has.Count()
if remaining < removal {
removal = remaining
}
remaining -= removal
has = has.Grow(-removal)
h.setItemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: id},
Slot: byte(slot),
}, has, s, tx)
if remaining == 0 {
// Consumed this item, so go to the next one.
break
}
}
if remaining == 0 {
// Consumed this item, so go to the next one.
break
}
}
if remaining != 0 {
return fmt.Errorf("recipe %v: could not consume expected item: %v", a.RecipeNetworkID, expected)
}
}
return h.createResults(s, tx, repeatStacks(craft.Output(), timesCrafted)...)
}
// handleCreativeCraft handles the CreativeCraft request action.
func (h *ItemStackRequestHandler) handleCreativeCraft(a *protocol.CraftCreativeStackRequestAction, s *Session, tx *world.Tx, c Controllable) error {
if !c.GameMode().CreativeInventory() {
return fmt.Errorf("can only craft creative items in gamemode creative/spectator")
}
index := a.CreativeItemNetworkID - 1
if int(index) >= len(creative.Items()) {
return fmt.Errorf("creative item with network ID %v does not exist", index)
}
it := creative.Items()[index].Stack
it = it.Grow(it.MaxCount() - 1)
return h.createResults(s, tx, it)
}
// craftingSize gets the crafting size based on the opened container ID.
func (s *Session) craftingSize() uint32 {
if s.openedContainerID.Load() == 1 {
return craftingGridSizeLarge
}
return craftingGridSizeSmall
}
// craftingOffset gets the crafting offset based on the opened container ID.
func (s *Session) craftingOffset() uint32 {
if s.openedContainerID.Load() == 1 {
return craftingGridLargeOffset
}
return craftingGridSmallOffset
}
// matchingStacks returns true if the two stacks are the same in a crafting scenario.
func matchingStacks(has, expected recipe.Item) bool {
switch expected := expected.(type) {
case item.Stack:
switch has := has.(type) {
case recipe.ItemTag:
name, _ := expected.Item().EncodeItem()
return has.Contains(name)
case item.Stack:
_, variants := expected.Value("variants")
if !variants {
return has.Comparable(expected)
}
nameOne, _ := has.Item().EncodeItem()
nameTwo, _ := expected.Item().EncodeItem()
return nameOne == nameTwo
}
panic(fmt.Errorf("client has unexpected recipe item %T", has))
case recipe.ItemTag:
switch has := has.(type) {
case item.Stack:
name, _ := has.Item().EncodeItem()
return expected.Contains(name)
case recipe.ItemTag:
return has.Tag() == expected.Tag()
}
panic(fmt.Errorf("client has unexpected recipe item %T", has))
}
panic(fmt.Errorf("tried to match with unexpected recipe item %T", expected))
}
// repeatStacks multiplies the count of all item stacks provided by the number of repetitions provided. Item
// stacks where the new count would exceed the item's max count are split into multiple item stacks.
func repeatStacks(items []item.Stack, repetitions int) []item.Stack {
output := make([]item.Stack, 0, len(items))
for _, o := range items {
count, maxCount := o.Count(), o.MaxCount()
total := count * repetitions
stacks := int(math.Ceil(float64(total) / float64(maxCount)))
for i := 0; i < stacks; i++ {
inc := min(total, maxCount)
total -= inc
output = append(output, o.Grow(inc-count))
}
}
return output
}
func grow(i recipe.Item, count int) recipe.Item {
switch i := i.(type) {
case item.Stack:
return i.Grow(count)
case recipe.ItemTag:
return recipe.NewItemTag(i.Tag(), i.Count()+count)
}
panic(fmt.Errorf("unexpected recipe item %T", i))
}
// tryDynamicCraft attempts to match the items in the crafting grid with any registered dynamic recipes.
func (h *ItemStackRequestHandler) tryDynamicCraft(s *Session, tx *world.Tx, timesCrafted int) error {
if timesCrafted < 1 {
return fmt.Errorf("times crafted must be at least 1")
}
size := s.craftingSize()
offset := s.craftingOffset()
// Collect all items from the crafting grid
input := make([]recipe.Item, size)
for i := uint32(0); i < size; i++ {
slot := offset + i
it, _ := s.ui.Item(int(slot))
if it.Empty() {
input[i] = item.Stack{}
} else {
input[i] = it
}
}
// Try to match with any dynamic recipe
for _, dynamicRecipe := range recipe.DynamicRecipes() {
if dynamicRecipe.Block() != "crafting_table" {
continue
}
output, ok := dynamicRecipe.Match(input)
if !ok {
continue
}
// Found a matching dynamic recipe! Now validate ingredient counts and consume the items
// For dynamic recipes, we consume all non-empty slots, but we need to ensure each slot
// has enough items to craft timesCrafted times.
minStackCount := math.MaxInt
for i := uint32(0); i < size; i++ {
slot := offset + i
it, _ := s.ui.Item(int(slot))
if !it.Empty() {
if it.Count() < minStackCount {
minStackCount = it.Count()
}
}
}
// Cap timesCrafted to the minimum available stack count to prevent item duplication
if minStackCount < timesCrafted {
timesCrafted = minStackCount
}
// Now consume the validated amount from each non-empty slot
for i := uint32(0); i < size; i++ {
slot := offset + i
it, _ := s.ui.Item(int(slot))
if !it.Empty() {
// Consume one item from this slot per craft
st := it.Grow(-1 * timesCrafted)
h.setItemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerCraftingInput},
Slot: byte(slot),
}, st, s, tx)
}
}
return h.createResults(s, tx, repeatStacks(output, timesCrafted)...)
}
return fmt.Errorf("no matching recipe found for crafting grid")
}
+34
View File
@@ -0,0 +1,34 @@
package session
import (
"github.com/df-mc/dragonfly/server/world"
"github.com/google/uuid"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
"time"
)
// EmoteHandler handles the Emote packet.
type EmoteHandler struct {
LastEmote time.Time
}
// Handle ...
func (h *EmoteHandler) Handle(p packet.Packet, _ *Session, tx *world.Tx, c Controllable) error {
pk := p.(*packet.Emote)
if pk.EntityRuntimeID != selfEntityRuntimeID {
return errSelfRuntimeID
}
if time.Since(h.LastEmote) < time.Second {
return nil
}
h.LastEmote = time.Now()
emote, err := uuid.Parse(pk.EmoteID)
if err != nil {
return err
}
for _, viewer := range tx.Viewers(c.Position()) {
viewer.ViewEmote(c, emote)
}
return nil
}
+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
}
+127
View File
@@ -0,0 +1,127 @@
package session
import (
"fmt"
"github.com/df-mc/dragonfly/server/world"
"math"
"math/rand/v2"
"github.com/df-mc/dragonfly/server/block"
"github.com/df-mc/dragonfly/server/entity"
"github.com/df-mc/dragonfly/server/item"
"github.com/sandertv/gophertunnel/minecraft/protocol"
)
const (
// grindstoneFirstInputSlot is the slot index of the first input item in the grindstone.
grindstoneFirstInputSlot = 0x10
// grindstoneSecondInputSlot is the slot index of the second input item in the grindstone.
grindstoneSecondInputSlot = 0x11
)
// handleGrindstoneCraft handles a CraftGrindstoneRecipe stack request action made using a grindstone.
func (h *ItemStackRequestHandler) handleGrindstoneCraft(s *Session, tx *world.Tx, c Controllable) error {
// First check if there actually is a grindstone opened.
if !s.containerOpened.Load() {
return fmt.Errorf("no grindstone container opened")
}
if _, ok := tx.Block(*s.openedPos.Load()).(block.Grindstone); !ok {
return fmt.Errorf("no grindstone container opened")
}
// Next, get both input items and ensure they are comparable.
firstInput, _ := h.itemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerGrindstoneInput},
Slot: grindstoneFirstInputSlot,
}, s, tx)
secondInput, _ := h.itemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerGrindstoneAdditional},
Slot: grindstoneSecondInputSlot,
}, s, tx)
if firstInput.Empty() && secondInput.Empty() {
return fmt.Errorf("input item(s) are empty")
}
if firstInput.Count() > 1 || secondInput.Count() > 1 {
return fmt.Errorf("input item(s) are not single items")
}
resultStack := nonZeroItem(firstInput, secondInput)
if !firstInput.Empty() && !secondInput.Empty() {
name, meta := firstInput.Item().EncodeItem()
name2, meta2 := secondInput.Item().EncodeItem()
if name != name2 || meta != meta2 {
return fmt.Errorf("input items must be the same type")
}
if _, ok := firstInput.Item().(item.Durable); !ok {
return fmt.Errorf("input items must be durable")
}
// We add the enchantments to the result stack in order to calculate the gained experience. These enchantments
// are stripped when creating the result.
resultStack = firstInput.WithEnchantments(secondInput.Enchantments()...)
// Merge the durability of the two input items at 5%.
maxDurability := firstInput.MaxDurability()
firstDurability, secondDurability := firstInput.Durability(), secondInput.Durability()
resultStack = resultStack.WithDurability(firstDurability + secondDurability + maxDurability*5/100)
}
for _, o := range entity.NewExperienceOrbs(entity.EyePosition(c), experienceFromEnchantments(resultStack)) {
tx.AddEntity(o)
}
h.setItemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerGrindstoneInput},
Slot: grindstoneFirstInputSlot,
}, item.Stack{}, s, tx)
h.setItemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerGrindstoneAdditional},
Slot: grindstoneSecondInputSlot,
}, item.Stack{}, s, tx)
return h.createResults(s, tx, stripPossibleEnchantments(resultStack))
}
// curseEnchantment represents an enchantment that may be a curse enchantment.
type curseEnchantment interface {
Curse() bool
}
// experienceFromEnchantments returns the amount of experience that is gained from the enchantments on the given stack.
func experienceFromEnchantments(stack item.Stack) int {
var totalCost int
for _, enchant := range stack.Enchantments() {
if _, ok := enchant.Type().(curseEnchantment); ok {
continue
}
cost, _ := enchant.Type().Cost(enchant.Level())
totalCost += cost
}
if totalCost == 0 {
// No cost, no experience.
return 0
}
minExperience := int(math.Ceil(float64(totalCost) / 2))
return minExperience + rand.IntN(minExperience)
}
// stripPossibleEnchantments strips all enchantments possible, excluding curses.
func stripPossibleEnchantments(stack item.Stack) item.Stack {
for _, enchant := range stack.Enchantments() {
if _, ok := enchant.Type().(curseEnchantment); ok {
continue
}
stack = stack.WithoutEnchantments(enchant.Type())
}
return stack.WithAnvilCost(0)
}
// nonZeroItem returns the item.Stack that exists out of two input items. The function expects at least one of the
// items to be non-empty.
func nonZeroItem(first, second item.Stack) item.Stack {
if first.Empty() {
return second
}
return first
}
+42
View File
@@ -0,0 +1,42 @@
package session
import (
"fmt"
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
// InteractHandler handles the packet.Interact.
type InteractHandler struct{}
// Handle ...
func (h *InteractHandler) Handle(p packet.Packet, s *Session, _ *world.Tx, c Controllable) error {
pk := p.(*packet.Interact)
pos := c.Position()
switch pk.ActionType {
case packet.InteractActionMouseOverEntity:
// We don't need this action.
case packet.InteractActionOpenInventory:
if s.invOpened {
// When there is latency, this might end up being sent multiple times. If we send a ContainerOpen
// multiple times, the client crashes.
return nil
}
s.invOpened = true
s.writePacket(&packet.ContainerOpen{
WindowID: 0,
ContainerType: 0xff,
ContainerEntityUniqueID: -1,
ContainerPosition: protocol.BlockPos{
int32(pos[0]),
int32(pos[1]),
int32(pos[2]),
},
})
default:
return fmt.Errorf("unexpected interact packet action %v", pk.ActionType)
}
return nil
}
@@ -0,0 +1,205 @@
package session
import (
"fmt"
"github.com/df-mc/dragonfly/server/block/cube"
"github.com/df-mc/dragonfly/server/event"
"github.com/df-mc/dragonfly/server/item"
"github.com/df-mc/dragonfly/server/item/inventory"
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
// InventoryTransactionHandler handles the InventoryTransaction packet.
type InventoryTransactionHandler struct{}
// Handle ...
func (h *InventoryTransactionHandler) Handle(p packet.Packet, s *Session, tx *world.Tx, c Controllable) (err error) {
pk := p.(*packet.InventoryTransaction)
if len(pk.LegacySetItemSlots) > 2 {
return fmt.Errorf("too many slot sync requests in inventory transaction")
}
defer func() {
// The client has requested the server to resend the specified slots even if they haven't changed server-side.
// Handling these requests is necessary to ensure the client's inventory remains in sync with the server.
for _, slot := range pk.LegacySetItemSlots {
if len(slot.Slots) > 2 {
err = fmt.Errorf("too many slots in slot sync request")
return
}
switch slot.ContainerID {
case protocol.ContainerOffhand:
s.sendInv(s.offHand, protocol.WindowIDOffHand)
case protocol.ContainerInventory:
for _, slot := range slot.Slots {
if i, err := s.inv.Item(int(slot)); err == nil {
s.sendItem(i, int(slot), protocol.WindowIDInventory)
}
}
}
}
}()
switch data := pk.TransactionData.(type) {
case *protocol.NormalTransactionData:
h.resendInventories(s)
// Always resend inventories with normal transactions. Most of the time we do not use these
// transactions, so we're best off making sure the client and server stay in sync.
if err := h.handleNormalTransaction(pk, s, c); err != nil {
s.conf.Log.Debug("process packet: InventoryTransaction: verify Normal transaction actions: " + err.Error())
}
return
case *protocol.MismatchTransactionData:
// Just resend the inventory and don't do anything.
h.resendInventories(s)
return
case *protocol.UseItemOnEntityTransactionData:
if err = s.VerifyAndSetHeldSlot(int(data.HotBarSlot), stackToItem(s.br, data.HeldItem.Stack), c); err != nil {
return
}
return h.handleUseItemOnEntityTransaction(data, s, tx, c)
case *protocol.UseItemTransactionData:
if err = s.VerifyAndSetHeldSlot(int(data.HotBarSlot), stackToItem(s.br, data.HeldItem.Stack), c); err != nil {
return
}
return h.handleUseItemTransaction(data, s, c)
case *protocol.ReleaseItemTransactionData:
if err = s.VerifyAndSetHeldSlot(int(data.HotBarSlot), stackToItem(s.br, data.HeldItem.Stack), c); err != nil {
return
}
return h.handleReleaseItemTransaction(c)
}
return fmt.Errorf("unhandled inventory transaction type %T", pk.TransactionData)
}
// resendInventories resends all inventories of the player.
func (h *InventoryTransactionHandler) resendInventories(s *Session) {
s.sendInv(s.inv, protocol.WindowIDInventory)
s.sendInv(s.ui, protocol.WindowIDUI)
s.sendInv(s.offHand, protocol.WindowIDOffHand)
s.sendInv(s.armour.Inventory(), protocol.WindowIDArmour)
}
// handleNormalTransaction ...
func (h *InventoryTransactionHandler) handleNormalTransaction(pk *packet.InventoryTransaction, s *Session, c Controllable) error {
if len(pk.Actions) != 2 {
return fmt.Errorf("expected two actions for dropping an item, got %d", len(pk.Actions))
}
var (
slot int
count int
expected item.Stack
)
for _, action := range pk.Actions {
switch {
case action.SourceType == protocol.InventoryActionSourceWorld && action.InventorySlot == 0:
if old := stackToItem(s.br, action.OldItem.Stack); !old.Empty() {
return fmt.Errorf("unexpected non-empty old item in transaction action: %#v", action.OldItem)
}
count = int(action.NewItem.Stack.Count)
case action.SourceType == protocol.InventoryActionSourceContainer && action.WindowID == protocol.WindowIDInventory:
if expected = stackToItem(s.br, action.OldItem.Stack); expected.Empty() {
return fmt.Errorf("unexpected empty old item in transaction action: %#v", action.OldItem)
}
slot = int(action.InventorySlot)
default:
return fmt.Errorf("unexpected action type in drop item transaction")
}
}
actual, _ := s.inv.Item(slot)
if count < 1 {
return fmt.Errorf("expected at least one item to be dropped, got %d", count)
}
if count > actual.Count() {
return fmt.Errorf("tried to throw %v items, but held only %v in slot", count, actual.Count())
}
if !expected.Equal(actual) {
return fmt.Errorf("different item thrown than held in slot: %#v was thrown but held %#v", expected, actual)
}
// Explicitly don't re-use the thrown variable. This item was supplied by the user, and if some
// logic in the Comparable() method was flawed, users would be able to cheat with item properties.
// Only grow or shrink the held item to prevent any such issues.
res := actual.Grow(count - actual.Count())
if err := call(event.C(inventory.Holder(c)), int(*s.heldSlot), res, s.inv.Handler().HandleDrop); err != nil {
return err
}
n := c.Drop(res)
_ = s.inv.SetItem(slot, actual.Grow(-n))
return nil
}
// handleUseItemOnEntityTransaction ...
func (h *InventoryTransactionHandler) handleUseItemOnEntityTransaction(data *protocol.UseItemOnEntityTransactionData, s *Session, tx *world.Tx, c Controllable) error {
s.swingingArm.Store(true)
defer s.swingingArm.Store(false)
if data.TargetEntityRuntimeID == selfEntityRuntimeID {
return fmt.Errorf("invalid entity interaction: players cannot interact with themselves")
}
handle, ok := s.entityFromRuntimeID(data.TargetEntityRuntimeID)
if !ok {
// In some cases, for example when a falling block entity solidifies, latency may allow attacking an entity that
// no longer exists server side. This is expected, so we shouldn't kick the player.
s.conf.Log.Debug("invalid entity interaction: no entity with runtime ID", "ID", data.TargetEntityRuntimeID)
return nil
}
e, ok := handle.Entity(tx)
if !ok {
s.conf.Log.Debug("invalid entity interaction: entity is not in the same world (anymore)", "ID", data.TargetEntityRuntimeID)
return nil
}
var valid bool
switch data.ActionType {
case protocol.UseItemOnEntityActionInteract:
valid = c.UseItemOnEntity(e)
case protocol.UseItemOnEntityActionAttack:
valid = c.AttackEntity(e)
default:
return fmt.Errorf("unhandled UseItemOnEntity ActionType %v", data.ActionType)
}
if !valid {
slot := int(*s.heldSlot)
it, _ := s.inv.Item(slot)
s.sendItem(it, slot, protocol.WindowIDInventory)
}
return nil
}
// handleUseItemTransaction ...
func (h *InventoryTransactionHandler) handleUseItemTransaction(data *protocol.UseItemTransactionData, s *Session, c Controllable) error {
pos := cube.Pos{int(data.BlockPosition[0]), int(data.BlockPosition[1]), int(data.BlockPosition[2])}
s.swingingArm.Store(true)
defer s.swingingArm.Store(false)
// We reset the inventory so that we can send the held item update without the client already
// having done that client-side.
// Because of the new inventory system, the client will expect a transaction confirmation, but instead of doing that
// it's much easier to just resend the inventory.
h.resendInventories(s)
switch data.ActionType {
case protocol.UseItemActionBreakBlock:
c.BreakBlock(pos)
case protocol.UseItemActionClickBlock:
c.UseItemOnBlock(pos, cube.Face(data.BlockFace), vec32To64(data.ClickedPosition))
case protocol.UseItemActionClickAir:
c.UseItem()
default:
return fmt.Errorf("unhandled UseItem ActionType %v", data.ActionType)
}
return nil
}
// handleReleaseItemTransaction ...
func (h *InventoryTransactionHandler) handleReleaseItemTransaction(c Controllable) error {
c.ReleaseItem()
return nil
}
@@ -0,0 +1,511 @@
package session
import (
"fmt"
"github.com/df-mc/dragonfly/server/block"
"github.com/df-mc/dragonfly/server/entity"
"github.com/df-mc/dragonfly/server/event"
"github.com/df-mc/dragonfly/server/item"
"github.com/df-mc/dragonfly/server/item/inventory"
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
"math"
"time"
)
// ItemStackRequestHandler handles the ItemStackRequest packet. It handles the actions done within the
// inventory.
type ItemStackRequestHandler struct {
currentRequest int32
changes map[byte]map[byte]changeInfo
responseChanges map[int32]map[*inventory.Inventory]map[byte]responseChange
pendingResults []item.Stack
current time.Time
ignoreDestroy bool
}
// responseChange represents a change in a specific item stack response. It holds the timestamp of the
// response which is used to get rid of changes that the client will have received.
type responseChange struct {
id int32
timestamp time.Time
}
// changeInfo holds information on a slot change initiated by an item stack request. It holds both the new and the old
// item information and is used for reverting and verifying.
type changeInfo struct {
after protocol.StackResponseSlotInfo
before item.Stack
}
// Handle ...
func (h *ItemStackRequestHandler) Handle(p packet.Packet, s *Session, tx *world.Tx, c Controllable) error {
pk := p.(*packet.ItemStackRequest)
h.current = time.Now()
s.inTransaction.Store(true)
defer s.inTransaction.Store(false)
for _, req := range pk.Requests {
if err := h.handleRequest(req, s, tx, c); err != nil {
// Item stacks being out of sync isn't uncommon, so don't error. Just debug the error and let the
// revert do its work.
s.conf.Log.Debug("process packet: ItemStackRequest: resolve item stack request: " + err.Error())
}
}
return nil
}
// handleRequest resolves a single item stack request from the client.
func (h *ItemStackRequestHandler) handleRequest(req protocol.ItemStackRequest, s *Session, tx *world.Tx, c Controllable) (err error) {
h.currentRequest = req.RequestID
defer func() {
if err != nil {
h.reject(req.RequestID, s, tx)
return
}
h.resolve(req.RequestID, s)
h.ignoreDestroy = false
}()
for _, action := range req.Actions {
switch a := action.(type) {
case *protocol.TakeStackRequestAction:
err = h.handleTake(a, s, tx, c)
case *protocol.PlaceStackRequestAction:
err = h.handlePlace(a, s, tx, c)
case *protocol.SwapStackRequestAction:
err = h.handleSwap(a, s, tx, c)
case *protocol.DestroyStackRequestAction:
err = h.handleDestroy(a, s, tx, c)
case *protocol.DropStackRequestAction:
err = h.handleDrop(a, s, tx, c)
case *protocol.BeaconPaymentStackRequestAction:
err = h.handleBeaconPayment(a, s, tx)
case *protocol.CraftRecipeStackRequestAction:
if s.containerOpened.Load() {
var special bool
switch tx.Block(*s.openedPos.Load()).(type) {
case block.SmithingTable:
err, special = h.handleSmithing(a, s, tx), true
case block.Stonecutter:
err, special = h.handleStonecutting(a, s, tx), true
case block.EnchantingTable:
err, special = h.handleEnchant(a, s, tx, c), true
}
if special {
// This was a "special action" and was handled, so we can move onto the next action.
break
}
}
err = h.handleCraft(a, s, tx)
case *protocol.AutoCraftRecipeStackRequestAction:
err = h.handleAutoCraft(a, s, tx)
case *protocol.CraftRecipeOptionalStackRequestAction:
err = h.handleCraftRecipeOptional(a, s, req.FilterStrings, c, tx)
case *protocol.CraftLoomRecipeStackRequestAction:
err = h.handleLoomCraft(a, s, tx)
case *protocol.CraftGrindstoneRecipeStackRequestAction:
err = h.handleGrindstoneCraft(s, tx, c)
case *protocol.CraftCreativeStackRequestAction:
err = h.handleCreativeCraft(a, s, tx, c)
case *protocol.MineBlockStackRequestAction:
err = h.handleMineBlock(a, s, tx)
case *protocol.CreateStackRequestAction:
err = h.handleCreate(a, s, tx)
case *protocol.ConsumeStackRequestAction, *protocol.CraftResultsDeprecatedStackRequestAction:
// Don't do anything with this.
default:
return fmt.Errorf("unhandled stack request action %#v", action)
}
if err != nil {
err = fmt.Errorf("%T: %w", action, err)
return
}
}
return
}
// handleTake handles a Take stack request action.
func (h *ItemStackRequestHandler) handleTake(a *protocol.TakeStackRequestAction, s *Session, tx *world.Tx, c Controllable) error {
return h.handleTransfer(a.Source, a.Destination, a.Count, s, tx, c)
}
// handlePlace handles a Place stack request action.
func (h *ItemStackRequestHandler) handlePlace(a *protocol.PlaceStackRequestAction, s *Session, tx *world.Tx, c Controllable) error {
return h.handleTransfer(a.Source, a.Destination, a.Count, s, tx, c)
}
// handleTransfer handles the transferring of x count from a source slot to a destination slot.
func (h *ItemStackRequestHandler) handleTransfer(from, to protocol.StackRequestSlotInfo, count byte, s *Session, tx *world.Tx, c Controllable) error {
if err := h.verifySlots(s, tx, from, to); err != nil {
return fmt.Errorf("source slot out of sync: %w", err)
}
i, _ := h.itemInSlot(from, s, tx)
dest, _ := h.itemInSlot(to, s, tx)
if !i.Comparable(dest) {
return fmt.Errorf("client tried transferring %v to %v, but the stacks are incomparable", i, dest)
}
if i.Count() < int(count) {
return fmt.Errorf("client tried subtracting %v from item count, but there are only %v", count, i.Count())
}
if (dest.Count()+int(count) > dest.MaxCount()) && !dest.Empty() {
return fmt.Errorf("client tried adding %v to item count %v, but max is %v", count, dest.Count(), dest.MaxCount())
}
if dest.Empty() {
dest = i.Grow(-math.MaxInt32)
}
invA, _ := s.invByID(int32(from.Container.ContainerID), tx)
invB, _ := s.invByID(int32(to.Container.ContainerID), tx)
ctx := event.C(inventory.Holder(c))
_ = call(ctx, int(from.Slot), i.Grow(int(count)-i.Count()), invA.Handler().HandleTake)
err := call(ctx, int(to.Slot), i.Grow(int(count)-i.Count()), invB.Handler().HandlePlace)
if err != nil {
return err
}
h.setItemInSlot(from, i.Grow(-int(count)), s, tx)
h.setItemInSlot(to, dest.Grow(int(count)), s, tx)
h.collectRewards(s, invA, int(from.Slot), tx, c)
return nil
}
// handleSwap handles a Swap stack request action.
func (h *ItemStackRequestHandler) handleSwap(a *protocol.SwapStackRequestAction, s *Session, tx *world.Tx, c Controllable) error {
if err := h.verifySlots(s, tx, a.Source, a.Destination); err != nil {
return fmt.Errorf("slot out of sync: %w", err)
}
i, _ := h.itemInSlot(a.Source, s, tx)
dest, _ := h.itemInSlot(a.Destination, s, tx)
invA, _ := s.invByID(int32(a.Source.Container.ContainerID), tx)
invB, _ := s.invByID(int32(a.Destination.Container.ContainerID), tx)
ctx := event.C(inventory.Holder(c))
_ = call(ctx, int(a.Source.Slot), i, invA.Handler().HandleTake)
_ = call(ctx, int(a.Source.Slot), dest, invA.Handler().HandlePlace)
_ = call(ctx, int(a.Destination.Slot), dest, invB.Handler().HandleTake)
err := call(ctx, int(a.Destination.Slot), i, invB.Handler().HandlePlace)
if err != nil {
return err
}
h.setItemInSlot(a.Source, dest, s, tx)
h.setItemInSlot(a.Destination, i, s, tx)
h.collectRewards(s, invA, int(a.Source.Slot), tx, c)
h.collectRewards(s, invA, int(a.Destination.Slot), tx, c)
return nil
}
// collectRewards checks if the source inventory has rewards for the player, for example, experience rewards when
// smelting. If it does, it will drop the rewards at the player's location.
func (h *ItemStackRequestHandler) collectRewards(s *Session, inv *inventory.Inventory, slot int, tx *world.Tx, c Controllable) {
if inv == s.openedWindow.Load() && s.containerOpened.Load() && slot == inv.Size()-1 {
if f, ok := tx.Block(*s.openedPos.Load()).(smelter); ok {
for _, o := range entity.NewExperienceOrbs(entity.EyePosition(c), f.ResetExperience()) {
tx.AddEntity(o)
}
}
}
}
// handleDestroy handles the destroying of an item by moving it into the creative inventory.
func (h *ItemStackRequestHandler) handleDestroy(a *protocol.DestroyStackRequestAction, s *Session, tx *world.Tx, c Controllable) error {
if h.ignoreDestroy {
return nil
}
if !c.GameMode().CreativeInventory() {
return fmt.Errorf("can only destroy items in gamemode creative/spectator")
}
if err := h.verifySlot(a.Source, s, tx); err != nil {
return fmt.Errorf("source slot out of sync: %w", err)
}
i, _ := h.itemInSlot(a.Source, s, tx)
if i.Count() < int(a.Count) {
return fmt.Errorf("client attempted to destroy %v items, but only %v present", a.Count, i.Count())
}
h.setItemInSlot(a.Source, i.Grow(-int(a.Count)), s, tx)
return nil
}
// handleDrop handles the dropping of an item by moving it outside the inventory while having the
// inventory opened.
func (h *ItemStackRequestHandler) handleDrop(a *protocol.DropStackRequestAction, s *Session, tx *world.Tx, c Controllable) error {
if err := h.verifySlot(a.Source, s, tx); err != nil {
return fmt.Errorf("source slot out of sync: %w", err)
}
i, _ := h.itemInSlot(a.Source, s, tx)
if i.Count() < int(a.Count) {
return fmt.Errorf("client attempted to drop %v items, but only %v present", a.Count, i.Count())
}
inv, _ := s.invByID(int32(a.Source.Container.ContainerID), tx)
if err := call(event.C(inventory.Holder(c)), int(a.Source.Slot), i.Grow(int(a.Count)-i.Count()), inv.Handler().HandleDrop); err != nil {
return err
}
n := c.Drop(i.Grow(int(a.Count) - i.Count()))
h.setItemInSlot(a.Source, i.Grow(-n), s, tx)
return nil
}
// handleMineBlock handles the action associated with a block being mined by the player. This seems to be a workaround
// by Mojang to deal with the durability changes client-side.
func (h *ItemStackRequestHandler) handleMineBlock(a *protocol.MineBlockStackRequestAction, s *Session, tx *world.Tx) error {
slot := protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerInventory},
Slot: byte(a.HotbarSlot),
StackNetworkID: a.StackNetworkID,
}
if err := h.verifySlot(slot, s, tx); err != nil {
return err
}
// Update the slots through ItemStackResponses, don't actually do anything special with this action.
i, _ := h.itemInSlot(slot, s, tx)
h.setItemInSlot(slot, i, s, tx)
return nil
}
// handleCreate handles the CreateStackRequestAction sent by the client when a recipe outputs more than one item. It
// contains a result slot, which should map to one of the output items. From there, the server should create the relevant
// output as usual.
func (h *ItemStackRequestHandler) handleCreate(a *protocol.CreateStackRequestAction, s *Session, tx *world.Tx) error {
slot := int(a.ResultsSlot)
if slot >= len(h.pendingResults) {
return fmt.Errorf("invalid pending result slot: %v", a.ResultsSlot)
}
res := h.pendingResults[slot]
if res.Empty() {
return fmt.Errorf("tried duplicating created result: %v", slot)
}
h.pendingResults[slot] = item.Stack{}
h.setItemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerCreatedOutput},
Slot: craftingResult,
}, res, s, tx)
return nil
}
// defaultCreation represents the CreateStackRequestAction used for single-result crafts.
var defaultCreation = &protocol.CreateStackRequestAction{}
// createResults creates a new craft result and adds it to the list of pending craft results.
func (h *ItemStackRequestHandler) createResults(s *Session, tx *world.Tx, result ...item.Stack) error {
h.pendingResults = append(h.pendingResults, result...)
if len(result) > 1 {
// With multiple results, the client notifies the server on when to create the results.
return nil
}
return h.handleCreate(defaultCreation, s, tx)
}
// verifySlots verifies a list of slots passed.
func (h *ItemStackRequestHandler) verifySlots(s *Session, tx *world.Tx, slots ...protocol.StackRequestSlotInfo) error {
for _, slot := range slots {
if err := h.verifySlot(slot, s, tx); err != nil {
return err
}
}
return nil
}
// verifySlot checks if the slot passed by the client is the same as that expected by the server.
func (h *ItemStackRequestHandler) verifySlot(slot protocol.StackRequestSlotInfo, s *Session, tx *world.Tx) error {
if err := h.tryAcknowledgeChanges(s, tx, slot); err != nil {
return err
}
if len(h.responseChanges) > 256 {
return fmt.Errorf("too many unacknowledged request slot changes")
}
inv, _ := s.invByID(int32(slot.Container.ContainerID), tx)
i, err := h.itemInSlot(slot, s, tx)
if err != nil {
return err
}
clientID, err := h.resolveID(inv, slot)
if err != nil {
return err
}
// The client seems to send negative stack network IDs for predictions, which we can ignore. We'll simply
// override this network ID later.
if id := item_id(i); id != clientID {
return fmt.Errorf("stack ID mismatch: client expected %v, but server had %v", clientID, id)
}
return nil
}
// resolveID resolves the stack network ID in the slot passed. If it is negative, it points to an earlier
// request, in which case it will look it up in the changes of an earlier response to a request to find the
// actual stack network ID in the slot. If it is positive, the ID will be returned again.
func (h *ItemStackRequestHandler) resolveID(inv *inventory.Inventory, slot protocol.StackRequestSlotInfo) (int32, error) {
if slot.StackNetworkID >= 0 {
return slot.StackNetworkID, nil
}
containerChanges, ok := h.responseChanges[slot.StackNetworkID]
if !ok {
return 0, fmt.Errorf("slot pointed to stack request %v, but request could not be found", slot.StackNetworkID)
}
changes, ok := containerChanges[inv]
if !ok {
return 0, fmt.Errorf("slot pointed to stack request %v with container %v, but that container was not changed in the request", slot.StackNetworkID, slot.Container.ContainerID)
}
actual, ok := changes[slot.Slot]
if !ok {
return 0, fmt.Errorf("slot pointed to stack request %v with container %v and slot %v, but that slot was not changed in the request", slot.StackNetworkID, slot.Container.ContainerID, slot.Slot)
}
return actual.id, nil
}
// tryAcknowledgeChanges iterates through all cached response changes and checks if the stack request slot
// info passed from the client has the right stack network ID in any of the stored slots. If this is the case,
// that entry is removed, so that the maps are cleaned up eventually.
func (h *ItemStackRequestHandler) tryAcknowledgeChanges(s *Session, tx *world.Tx, slot protocol.StackRequestSlotInfo) error {
inv, ok := s.invByID(int32(slot.Container.ContainerID), tx)
if !ok {
return fmt.Errorf("could not find container with id %v", slot.Container.ContainerID)
}
for requestID, containerChanges := range h.responseChanges {
for newInv, changes := range containerChanges {
for slotIndex, val := range changes {
if (slot.Slot == slotIndex && slot.StackNetworkID >= 0 && newInv == inv) || h.current.Sub(val.timestamp) > time.Second*5 {
delete(changes, slotIndex)
}
}
if len(changes) == 0 {
delete(containerChanges, newInv)
}
}
if len(containerChanges) == 0 {
delete(h.responseChanges, requestID)
}
}
return nil
}
// itemInSlot looks for the item in the slot as indicated by the slot info passed.
func (h *ItemStackRequestHandler) itemInSlot(slot protocol.StackRequestSlotInfo, s *Session, tx *world.Tx) (item.Stack, error) {
inv, ok := s.invByID(int32(slot.Container.ContainerID), tx)
if !ok {
return item.Stack{}, fmt.Errorf("unable to find container with ID %v", slot.Container.ContainerID)
}
sl := int(slot.Slot)
if inv == s.offHand {
sl = 0
}
i, err := inv.Item(sl)
if err != nil {
return i, err
}
return i, nil
}
// setItemInSlot sets an item stack in the slot of a container present in the slot info.
func (h *ItemStackRequestHandler) setItemInSlot(slot protocol.StackRequestSlotInfo, i item.Stack, s *Session, tx *world.Tx) {
inv, _ := s.invByID(int32(slot.Container.ContainerID), tx)
sl := int(slot.Slot)
if inv == s.offHand {
sl = 0
}
before, _ := inv.Item(sl)
_ = inv.SetItem(sl, i)
respSlot := protocol.StackResponseSlotInfo{
Slot: slot.Slot,
HotbarSlot: slot.Slot,
Count: byte(i.Count()),
StackNetworkID: item_id(i),
DurabilityCorrection: int32(i.MaxDurability() - i.Durability()),
}
if h.changes[slot.Container.ContainerID] == nil {
h.changes[slot.Container.ContainerID] = map[byte]changeInfo{}
}
h.changes[slot.Container.ContainerID][slot.Slot] = changeInfo{
after: respSlot,
before: before,
}
if h.responseChanges[h.currentRequest] == nil {
h.responseChanges[h.currentRequest] = map[*inventory.Inventory]map[byte]responseChange{}
}
if h.responseChanges[h.currentRequest][inv] == nil {
h.responseChanges[h.currentRequest][inv] = map[byte]responseChange{}
}
h.responseChanges[h.currentRequest][inv][slot.Slot] = responseChange{
id: respSlot.StackNetworkID,
timestamp: h.current,
}
}
// resolve resolves the request with the ID passed.
func (h *ItemStackRequestHandler) resolve(id int32, s *Session) {
info := make([]protocol.StackResponseContainerInfo, 0, len(h.changes))
for container, slotInfo := range h.changes {
slots := make([]protocol.StackResponseSlotInfo, 0, len(slotInfo))
for _, slot := range slotInfo {
slots = append(slots, slot.after)
}
info = append(info, protocol.StackResponseContainerInfo{
Container: protocol.FullContainerName{ContainerID: container},
SlotInfo: slots,
})
}
s.writePacket(&packet.ItemStackResponse{Responses: []protocol.ItemStackResponse{{
Status: protocol.ItemStackResponseStatusOK,
RequestID: id,
ContainerInfo: info,
}}})
h.changes = map[byte]map[byte]changeInfo{}
h.pendingResults = nil
}
// reject rejects the item stack request sent by the client so that it is reverted client-side.
func (h *ItemStackRequestHandler) reject(id int32, s *Session, tx *world.Tx) {
s.writePacket(&packet.ItemStackResponse{
Responses: []protocol.ItemStackResponse{{
Status: protocol.ItemStackResponseStatusError,
RequestID: id,
}},
})
// Revert changes that we already made for valid actions.
for container, slots := range h.changes {
for slot, info := range slots {
inv, _ := s.invByID(int32(container), tx)
_ = inv.SetItem(int(slot), info.before)
}
}
h.changes = map[byte]map[byte]changeInfo{}
h.pendingResults = nil
}
// call uses an event.Context, slot and item.Stack to call the event handler function passed. An error is returned if
// the event.Context was cancelled either before or after the call.
func call(ctx *inventory.Context, slot int, it item.Stack, f func(ctx *inventory.Context, slot int, it item.Stack)) error {
if ctx.Cancelled() {
return fmt.Errorf("action was cancelled")
}
f(ctx, slot, it)
if ctx.Cancelled() {
return fmt.Errorf("action was cancelled")
}
return nil
}
+24
View File
@@ -0,0 +1,24 @@
package session
import (
"fmt"
"github.com/df-mc/dragonfly/server/block"
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
// LecternUpdateHandler handles the LecternUpdate packet, sent when a player interacts with a lectern.
type LecternUpdateHandler struct{}
// Handle ...
func (LecternUpdateHandler) Handle(p packet.Packet, _ *Session, tx *world.Tx, c Controllable) error {
pk := p.(*packet.LecternUpdate)
pos := blockPosFromProtocol(pk.Position)
if !canReach(c, pos.Vec3Middle()) {
return fmt.Errorf("block at %v is not within reach", pos)
}
if _, ok := tx.Block(pos).(block.Lectern); !ok {
return fmt.Errorf("block at %v is not a lectern", pos)
}
return c.TurnLecternPage(pos, int(pk.Page))
}
+100
View File
@@ -0,0 +1,100 @@
package session
import (
"fmt"
"github.com/df-mc/dragonfly/server/block"
"github.com/df-mc/dragonfly/server/item"
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol"
)
const (
// loomInputSlot is the slot index of the input item in the loom table.
loomInputSlot = 0x09
// loomDyeSlot is the slot index of the dye item in the loom table.
loomDyeSlot = 0x0a
// loomPatternSlot is the slot index of the pattern item in the loom table.
loomPatternSlot = 0x0b
)
// handleLoomCraft handles a CraftLoomRecipe stack request action made using a loom table.
func (h *ItemStackRequestHandler) handleLoomCraft(a *protocol.CraftLoomRecipeStackRequestAction, s *Session, tx *world.Tx) error {
// First check if there actually is a loom opened.
if _, ok := tx.Block(*s.openedPos.Load()).(block.Loom); !ok || !s.containerOpened.Load() {
return fmt.Errorf("no loom container opened")
}
timesCrafted := int(a.TimesCrafted)
if timesCrafted < 1 {
return fmt.Errorf("times crafted must be least 1")
}
// Next, check if the input slot has a valid banner item.
input, _ := h.itemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerLoomInput},
Slot: loomInputSlot,
}, s, tx)
if input.Count() < timesCrafted {
return fmt.Errorf("input item count is less than times crafted")
}
b, ok := input.Item().(block.Banner)
if !ok {
return fmt.Errorf("input item is not a banner")
}
if b.Illager {
return fmt.Errorf("input item is an illager banner")
}
// Do the same with the input dye.
dye, _ := h.itemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerLoomDye},
Slot: loomDyeSlot,
}, s, tx)
if dye.Count() < timesCrafted {
return fmt.Errorf("dye item count is less than times crafted")
}
d, ok := dye.Item().(item.Dye)
if !ok {
return fmt.Errorf("dye item is not a dye")
}
// The action contains the pattern that the client wanted to apply, so parse the ID and check if it is a valid
// pattern.
expectedPattern, exists := block.BannerPatternByID(a.Pattern)
if !exists {
return fmt.Errorf("unknown banner pattern id %q", a.Pattern)
}
// Some banner patterns have equivalent banner pattern items that are required to craft the pattern. If the expected
// pattern has a pattern item, check if the player input the correct pattern item.
pattern, _ := h.itemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerLoomMaterial},
Slot: loomPatternSlot,
}, s, tx)
if expectedPatternItem, hasPatternItem := expectedPattern.Item(); hasPatternItem {
if pattern.Empty() {
return fmt.Errorf("pattern item is empty but the pattern is required")
}
p, ok := pattern.Item().(item.BannerPattern)
if !ok {
return fmt.Errorf("pattern item is not a banner pattern")
}
if expectedPatternItem != p.Type {
return fmt.Errorf("pattern item does not match the expected pattern")
}
}
// Add a new pattern layer onto the banner, and create the result.
b.Patterns = append(b.Patterns, block.BannerPatternLayer{
Type: expectedPattern,
Colour: d.Colour,
})
h.setItemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerLoomInput},
Slot: loomInputSlot,
}, input.Grow(-timesCrafted), s, tx)
h.setItemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerLoomDye},
Slot: loomDyeSlot,
}, dye.Grow(-timesCrafted), s, tx)
return h.createResults(s, tx, input.WithItem(b))
}
+29
View File
@@ -0,0 +1,29 @@
package session
import (
"fmt"
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
// MobEquipmentHandler handles the MobEquipment packet.
type MobEquipmentHandler struct{}
// Handle ...
func (*MobEquipmentHandler) Handle(p packet.Packet, s *Session, tx *world.Tx, c Controllable) error {
pk := p.(*packet.MobEquipment)
if pk.EntityRuntimeID != selfEntityRuntimeID {
return errSelfRuntimeID
}
switch pk.WindowID {
case protocol.WindowIDOffHand:
// This window ID is expected, but we don't handle it.
return nil
case protocol.WindowIDInventory:
return s.VerifyAndSetHeldSlot(int(pk.InventorySlot), stackToItem(s.br, pk.NewItem.Stack), c)
default:
return fmt.Errorf("only main inventory should be involved in slot change, got window ID %v", pk.WindowID)
}
}
@@ -0,0 +1,45 @@
package session
import (
"fmt"
"github.com/df-mc/dragonfly/server/player/form"
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
"sync"
"sync/atomic"
)
// ModalFormResponseHandler handles the ModalFormResponse packet.
type ModalFormResponseHandler struct {
mu sync.Mutex
forms map[uint32]form.Form
currentID atomic.Uint32
}
// Handle ...
func (h *ModalFormResponseHandler) Handle(p packet.Packet, _ *Session, tx *world.Tx, c Controllable) error {
pk := p.(*packet.ModalFormResponse)
h.mu.Lock()
f, ok := h.forms[pk.FormID]
delete(h.forms, pk.FormID)
h.mu.Unlock()
resp, exists := pk.ResponseData.Value()
if !ok && !exists {
// Sometimes the client seems to send a second response with no data, which would cause the player to be kicked
// by the server. This should patch that.
return nil
}
if !exists || len(resp) == 0 {
// The form was cancelled: The cross in the top right corner was clicked.
resp = nil
}
if !ok {
return fmt.Errorf("no form with ID %v currently opened", pk.FormID)
}
if err := f.SubmitJSON(resp, c, tx); err != nil {
return fmt.Errorf("error submitting form data: %w", err)
}
return nil
}
+32
View File
@@ -0,0 +1,32 @@
package session
import (
"fmt"
"github.com/df-mc/dragonfly/server/player/dialogue"
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
// NPCRequestHandler handles the NPCRequest packet.
type NPCRequestHandler struct {
dialogue dialogue.Dialogue
entityRuntimeID uint64
}
// Handle ...
func (h *NPCRequestHandler) Handle(p packet.Packet, s *Session, tx *world.Tx, c Controllable) error {
pk := p.(*packet.NPCRequest)
if h.entityRuntimeID == 0 {
// No dialogue is currently open for this session, so there is nothing to submit or close.
return nil
}
switch pk.RequestType {
case packet.NPCRequestActionExecuteAction:
if err := h.dialogue.Submit(uint(pk.ActionType), c, tx); err != nil {
return fmt.Errorf("error submitting dialogue: %w", err)
}
case packet.NPCRequestActionExecuteClosingCommands:
h.dialogue.Close(c, tx)
}
return nil
}
+63
View File
@@ -0,0 +1,63 @@
package session
import (
"fmt"
"github.com/df-mc/dragonfly/server/block/cube"
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
// PlayerActionHandler handles the PlayerAction packet.
type PlayerActionHandler struct{}
// Handle ...
func (*PlayerActionHandler) Handle(p packet.Packet, s *Session, _ *world.Tx, c Controllable) error {
pk := p.(*packet.PlayerAction)
return handlePlayerAction(pk.ActionType, pk.BlockFace, pk.BlockPosition, pk.EntityRuntimeID, s, c)
}
// handlePlayerAction handles an action performed by a player, found in packet.PlayerAction and packet.PlayerAuthInput.
func handlePlayerAction(action int32, face int32, pos protocol.BlockPos, entityRuntimeID uint64, s *Session, c Controllable) error {
if entityRuntimeID != selfEntityRuntimeID {
return errSelfRuntimeID
}
switch action {
case protocol.PlayerActionStartSleeping, protocol.PlayerActionRespawn, protocol.PlayerActionDimensionChangeDone:
// Don't do anything for these actions.
case protocol.PlayerActionStopSleeping:
c.Wake()
case protocol.PlayerActionStartBreak, protocol.PlayerActionContinueDestroyBlock:
s.swingingArm.Store(true)
defer s.swingingArm.Store(false)
s.breakingPos = cube.Pos{int(pos[0]), int(pos[1]), int(pos[2])}
c.StartBreaking(s.breakingPos, cube.Face(face))
case protocol.PlayerActionAbortBreak:
c.AbortBreaking()
case protocol.PlayerActionPredictDestroyBlock, protocol.PlayerActionStopBreak:
s.swingingArm.Store(true)
defer s.swingingArm.Store(false)
c.FinishBreaking()
case protocol.PlayerActionCrackBreak:
// Don't do anything for this action. It is no longer used. Block
// cracking is done fully server-side.
case protocol.PlayerActionStartItemUseOn:
// TODO: Properly utilize these actions.
case protocol.PlayerActionStopItemUseOn:
c.ReleaseItem()
case protocol.PlayerActionStartBuildingBlock:
// Don't do anything for this action.
case protocol.PlayerActionCreativePlayerDestroyBlock:
// Don't do anything for this action.
case protocol.PlayerActionMissedSwing:
s.swingingArm.Store(true)
defer s.swingingArm.Store(false)
c.PunchAir()
default:
return fmt.Errorf("unhandled ActionType %v", action)
}
return nil
}
+182
View File
@@ -0,0 +1,182 @@
package session
import (
"fmt"
"math"
"github.com/df-mc/dragonfly/server/block/cube"
"github.com/df-mc/dragonfly/server/world"
"github.com/go-gl/mathgl/mgl32"
"github.com/go-gl/mathgl/mgl64"
"github.com/sandertv/gophertunnel/minecraft/protocol"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
// PlayerAuthInputHandler handles the PlayerAuthInput packet.
type PlayerAuthInputHandler struct{}
// Handle ...
func (h PlayerAuthInputHandler) Handle(p packet.Packet, s *Session, tx *world.Tx, c Controllable) error {
pk := p.(*packet.PlayerAuthInput)
if err := h.handleMovement(pk, s, c); err != nil {
return err
}
return h.handleActions(pk, s, tx, c)
}
// handleMovement handles the movement part of the packet.PlayerAuthInput.
func (h PlayerAuthInputHandler) handleMovement(pk *packet.PlayerAuthInput, s *Session, c Controllable) error {
yaw, pitch := c.Rotation().Elem()
pos := c.Position()
reference := []float64{pitch, yaw, yaw, pos[0], pos[1], pos[2]}
for i, v := range [...]*float32{&pk.Pitch, &pk.Yaw, &pk.HeadYaw, &pk.Position[0], &pk.Position[1], &pk.Position[2]} {
f := float64(*v)
if math.IsNaN(f) || math.IsInf(f, 1) || math.IsInf(f, 0) {
// Sometimes, the PlayerAuthInput packet is in fact sent with NaN/INF after being teleported (to another
// world), see #425. For this reason, we don't actually return an error if this happens, because this will
// result in the player being kicked. Just log it and replace the NaN value with the one we have tracked
// server-side.
s.conf.Log.Debug("process packet: PlayerAuthInput: found nan/inf values. assuming server-side values", "pos", fmt.Sprint(pk.Position), "yaw", pk.Yaw, "head-yaw", pk.HeadYaw, "pitch", pk.Pitch)
*v = float32(reference[i])
}
}
pk.Position = pk.Position.Sub(mgl32.Vec3{0, 1.62}) // Sub the base offset of players from the pos.
newPos := vec32To64(pk.Position)
deltaPos, deltaYaw, deltaPitch := newPos.Sub(pos), float64(pk.Yaw)-yaw, float64(pk.Pitch)-pitch
if mgl64.FloatEqual(deltaPos.Len(), 0) && mgl64.FloatEqual(deltaYaw, 0) && mgl64.FloatEqual(deltaPitch, 0) {
// The PlayerAuthInput packet is sent every tick, so don't do anything if the position and rotation
// were unchanged.
return nil
}
if expected := s.teleportPos.Load(); expected != nil {
if newPos.Sub(*expected).Len() > 1 {
// The player has moved before it received the teleport packet. Ignore this movement entirely and
// wait for the client to sync itself back to the server. Once we get a movement that is close
// enough to the teleport position, we'll allow the player to move around again.
return nil
}
s.teleportPos.Store(nil)
}
s.moving = true
c.Move(deltaPos, deltaYaw, deltaPitch)
return nil
}
// handleActions handles the actions with the world that are present in the PlayerAuthInput packet.
func (h PlayerAuthInputHandler) handleActions(pk *packet.PlayerAuthInput, s *Session, tx *world.Tx, c Controllable) error {
if pk.InputData.Load(packet.InputFlagPerformItemInteraction) {
if err := h.handleUseItemData(pk.ItemInteractionData, s, c); err != nil {
return err
}
}
if pk.InputData.Load(packet.InputFlagPerformBlockActions) {
if err := h.handleBlockActions(pk.BlockActions, s, c); err != nil {
return err
}
}
h.handleInputFlags(pk.InputData, s, c)
if pk.InputData.Load(packet.InputFlagPerformItemStackRequest) {
s.inTransaction.Store(true)
defer s.inTransaction.Store(false)
// As of 1.18 this is now used for sending item stack requests such as when mining a block.
sh := s.handlers[packet.IDItemStackRequest].(*ItemStackRequestHandler)
if err := sh.handleRequest(pk.ItemStackRequest, s, tx, c); err != nil {
// Item stacks being out of sync isn't uncommon, so don't error. Just debug the error and let the
// revert do its work.
s.conf.Log.Debug("process packet: PlayerAuthInput: resolve item stack request: " + err.Error())
}
}
return nil
}
// handleInputFlags handles the toggleable input flags set in a PlayerAuthInput packet.
func (h PlayerAuthInputHandler) handleInputFlags(flags protocol.Bitset, s *Session, c Controllable) {
if flags.Load(packet.InputFlagStartSprinting) {
c.StartSprinting()
}
if flags.Load(packet.InputFlagStopSprinting) {
c.StopSprinting()
}
if flags.Load(packet.InputFlagStartSneaking) {
c.StartSneaking()
}
if flags.Load(packet.InputFlagStopSneaking) {
c.StopSneaking()
}
if flags.Load(packet.InputFlagStartSwimming) {
c.StartSwimming()
}
if flags.Load(packet.InputFlagStopSwimming) {
c.StopSwimming()
}
if flags.Load(packet.InputFlagStartGliding) {
c.StartGliding()
}
if flags.Load(packet.InputFlagStopGliding) {
c.StopGliding()
}
if flags.Load(packet.InputFlagStartJumping) {
c.Jump()
}
if flags.Load(packet.InputFlagStartCrawling) {
c.StartCrawling()
}
if flags.Load(packet.InputFlagStopCrawling) {
c.StopCrawling()
}
if flags.Load(packet.InputFlagMissedSwing) {
s.swingingArm.Store(true)
defer s.swingingArm.Store(false)
c.PunchAir()
}
if flags.Load(packet.InputFlagStartFlying) {
if !c.GameMode().AllowsFlying() {
s.conf.Log.Debug("process packet: PlayerAuthInput: flying flag enabled while unable to fly")
s.SendAbilities(c)
} else {
c.StartFlying()
}
}
if flags.Load(packet.InputFlagStopFlying) {
c.StopFlying()
}
}
// handleUseItemData handles the protocol.UseItemTransactionData found in a packet.PlayerAuthInput.
func (h PlayerAuthInputHandler) handleUseItemData(data protocol.UseItemTransactionData, s *Session, c Controllable) error {
s.swingingArm.Store(true)
defer s.swingingArm.Store(false)
held, _ := c.HeldItems()
if !held.Equal(stackToItem(s.br, data.HeldItem.Stack)) {
s.conf.Log.Debug("process packet: PlayerAuthInput: UseItemTransaction: mismatch between actual held item and client held item")
return nil
}
pos := cube.Pos{int(data.BlockPosition[0]), int(data.BlockPosition[1]), int(data.BlockPosition[2])}
// Seems like this is only used for breaking blocks at the moment.
switch data.ActionType {
case protocol.UseItemActionBreakBlock:
c.BreakBlock(pos)
default:
return fmt.Errorf("unhandled UseItem ActionType for PlayerAuthInput packet %v", data.ActionType)
}
return nil
}
// handleBlockActions handles a slice of protocol.PlayerBlockAction present in a PlayerAuthInput packet.
func (h PlayerAuthInputHandler) handleBlockActions(a []protocol.PlayerBlockAction, s *Session, c Controllable) error {
for _, action := range a {
if err := handlePlayerAction(action.Action, action.Face, action.BlockPos, selfEntityRuntimeID, s, c); err != nil {
return err
}
}
return nil
}
+24
View File
@@ -0,0 +1,24 @@
package session
import (
"fmt"
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
// PlayerSkinHandler handles the PlayerSkin packet.
type PlayerSkinHandler struct{}
// Handle ...
func (PlayerSkinHandler) Handle(p packet.Packet, _ *Session, _ *world.Tx, c Controllable) error {
pk := p.(*packet.PlayerSkin)
playerSkin, err := protocolToSkin(pk.Skin)
if err != nil {
return fmt.Errorf("error decoding skin: %w", err)
}
c.SetSkin(playerSkin)
return nil
}
+23
View File
@@ -0,0 +1,23 @@
package session
import (
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
// RequestAbilityHandler handles the RequestAbility packet.
type RequestAbilityHandler struct{}
// Handle ...
func (a RequestAbilityHandler) Handle(p packet.Packet, s *Session, _ *world.Tx, c Controllable) error {
pk := p.(*packet.RequestAbility)
if pk.Ability == packet.AbilityFlying {
if !c.GameMode().AllowsFlying() {
s.conf.Log.Debug("process packet: RequestAbility: flying flag enabled while unable to fly")
s.SendAbilities(c)
return nil
}
c.StartFlying()
}
return nil
}
@@ -0,0 +1,24 @@
package session
import (
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
// RequestChunkRadiusHandler handles the RequestChunkRadius packet.
type RequestChunkRadiusHandler struct{}
// Handle ...
func (*RequestChunkRadiusHandler) Handle(p packet.Packet, s *Session, tx *world.Tx, _ Controllable) error {
pk := p.(*packet.RequestChunkRadius)
if pk.ChunkRadius > s.maxChunkRadius {
pk.ChunkRadius = s.maxChunkRadius
}
s.chunkRadius = pk.ChunkRadius
s.chunkLoader.ChangeRadius(tx, int(pk.ChunkRadius))
s.writePacket(&packet.ChunkRadiusUpdated{ChunkRadius: s.chunkRadius})
return nil
}
+23
View File
@@ -0,0 +1,23 @@
package session
import (
"fmt"
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
// RespawnHandler handles the Respawn packet.
type RespawnHandler struct{}
// Handle ...
func (*RespawnHandler) Handle(p packet.Packet, _ *Session, _ *world.Tx, c Controllable) error {
pk := p.(*packet.Respawn)
if pk.EntityRuntimeID != selfEntityRuntimeID {
return errSelfRuntimeID
}
if pk.State != packet.RespawnStateClientReadyToSpawn {
return fmt.Errorf("respawn state must always be %v, but got %v", packet.RespawnStateClientReadyToSpawn, pk.State)
}
c.Respawn()
return nil
}
@@ -0,0 +1,26 @@
package session
import (
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
// ServerBoundDiagnosticsHandler handles diagnostic updates from the client.
type ServerBoundDiagnosticsHandler struct{}
// Handle ...
func (h *ServerBoundDiagnosticsHandler) Handle(p packet.Packet, _ *Session, _ *world.Tx, c Controllable) error {
pk := p.(*packet.ServerBoundDiagnostics)
c.UpdateDiagnostics(Diagnostics{
AverageFramesPerSecond: float64(pk.AverageFramesPerSecond),
AverageServerSimTickTime: float64(pk.AverageServerSimTickTime),
AverageClientSimTickTime: float64(pk.AverageClientSimTickTime),
AverageBeginFrameTime: float64(pk.AverageBeginFrameTime),
AverageInputTime: float64(pk.AverageInputTime),
AverageRenderTime: float64(pk.AverageRenderTime),
AverageEndFrameTime: float64(pk.AverageEndFrameTime),
AverageRemainderTimePercent: float64(pk.AverageRemainderTimePercent),
AverageUnaccountedTimePercent: float64(pk.AverageUnaccountedTimePercent),
})
return nil
}
@@ -0,0 +1,34 @@
package session
import (
"fmt"
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
"sync/atomic"
)
// ServerBoundLoadingScreenHandler handles loading screen updates from the clients. It is used to ensure that
// the server knows when the client is loading a screen, and when it is done loading it.
type ServerBoundLoadingScreenHandler struct {
currentID atomic.Uint32
expectedID atomic.Uint32
}
// Handle ...
func (h *ServerBoundLoadingScreenHandler) Handle(p packet.Packet, s *Session, _ *world.Tx, _ Controllable) error {
pk := p.(*packet.ServerBoundLoadingScreen)
v, ok := pk.LoadingScreenID.Value()
expected := h.expectedID.Load()
switch {
case !ok || expected == 0:
return nil
case v != expected:
return fmt.Errorf("expected loading screen ID %d, got %d", expected, v)
case pk.Type == packet.LoadingScreenTypeEnd:
s.changingDimension.Store(false)
h.expectedID.Store(0)
}
return nil
}
+91
View File
@@ -0,0 +1,91 @@
package session
import (
"fmt"
"github.com/df-mc/dragonfly/server/item"
"github.com/df-mc/dragonfly/server/item/recipe"
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol"
)
const (
// smithingInputSlot is the slot index of the input item in the smithing table.
smithingInputSlot = 0x33
// smithingMaterialSlot is the slot index of the material in the smithing table.
smithingMaterialSlot = 0x34
// smithingTemplateSlot is the slot index of the template item in the smithing table.
smithingTemplateSlot = 0x35
)
// handleSmithing handles a CraftRecipe stack request action made using a smithing table.
func (h *ItemStackRequestHandler) handleSmithing(a *protocol.CraftRecipeStackRequestAction, s *Session, tx *world.Tx) error {
// First, check the recipe and ensure it is valid for the smithing table.
craft, ok := s.recipes[a.RecipeNetworkID]
if !ok {
return fmt.Errorf("recipe with network id %v does not exist", a.RecipeNetworkID)
}
if craft.Block() != "smithing_table" {
return fmt.Errorf("recipe with network id %v is not a smithing table recipe", a.RecipeNetworkID)
}
switch craft.(type) {
case recipe.SmithingTransform, recipe.SmithingTrim:
default:
return fmt.Errorf("recipe with network id %v is not a smithing recipe", a.RecipeNetworkID)
}
// Check if the input item and material item match what the recipe requires.
expectedInputs := craft.Input()
input, _ := h.itemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerSmithingTableInput},
Slot: smithingInputSlot,
}, s, tx)
if !matchingStacks(input, expectedInputs[0]) {
return fmt.Errorf("input item is not the same as expected input")
}
material, _ := h.itemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerSmithingTableMaterial},
Slot: smithingMaterialSlot,
}, s, tx)
if !matchingStacks(material, expectedInputs[1]) {
return fmt.Errorf("material item is not the same as expected material")
}
template, _ := h.itemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerSmithingTableTemplate},
Slot: smithingTemplateSlot,
}, s, tx)
if !matchingStacks(template, expectedInputs[2]) {
return fmt.Errorf("template item is not the same as expected template")
}
// Create the output using the input stack as reference and the recipe's output item type.
h.setItemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerSmithingTableInput},
Slot: smithingInputSlot,
}, input.Grow(-1), s, tx)
h.setItemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerSmithingTableMaterial},
Slot: smithingMaterialSlot,
}, material.Grow(-1), s, tx)
h.setItemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerSmithingTableTemplate},
Slot: smithingTemplateSlot,
}, template.Grow(-1), s, tx)
if _, ok = craft.(recipe.SmithingTrim); ok {
var trim item.ArmourTrim
if t, ok := template.Item().(item.SmithingTemplate); ok {
trim.Template = t.Template
} else {
return fmt.Errorf("template item is not a smithing template")
}
if trim.Material, ok = material.Item().(item.ArmourTrimMaterial); !ok {
return fmt.Errorf("material item is not an armour trim material")
}
trimmable, ok := input.Item().(item.Trimmable)
if !ok {
return fmt.Errorf("input item is not trimmable")
}
return h.createResults(s, tx, input.WithItem(trimmable.WithTrim(trim)))
}
return h.createResults(s, tx, input.WithItem(craft.Output()[0].Item()))
}
+49
View File
@@ -0,0 +1,49 @@
package session
import (
"fmt"
"github.com/df-mc/dragonfly/server/item/recipe"
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol"
)
// stonecutterInputSlot is the slot index of the input item in the stonecutter.
const stonecutterInputSlot = 0x03
// handleStonecutting handles a CraftRecipe stack request action made using a stonecutter.
func (h *ItemStackRequestHandler) handleStonecutting(a *protocol.CraftRecipeStackRequestAction, s *Session, tx *world.Tx) error {
craft, ok := s.recipes[a.RecipeNetworkID]
if !ok {
return fmt.Errorf("recipe with network id %v does not exist", a.RecipeNetworkID)
}
if _, shapeless := craft.(recipe.Shapeless); !shapeless {
return fmt.Errorf("recipe with network id %v is not a shapeless recipe", a.RecipeNetworkID)
}
if craft.Block() != "stonecutter" {
return fmt.Errorf("recipe with network id %v is not a stonecutter recipe", a.RecipeNetworkID)
}
timesCrafted := int(a.NumberOfCrafts)
if timesCrafted < 1 {
return fmt.Errorf("times crafted must be at least 1")
}
expectedInputs := craft.Input()
input, _ := h.itemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerStonecutterInput},
Slot: stonecutterInputSlot,
}, s, tx)
if input.Count() < timesCrafted {
return fmt.Errorf("input item count is less than number of crafts")
}
if !matchingStacks(input, expectedInputs[0]) {
return fmt.Errorf("input item is not the same as expected input")
}
output := craft.Output()
h.setItemInSlot(protocol.StackRequestSlotInfo{
Container: protocol.FullContainerName{ContainerID: protocol.ContainerStonecutterInput},
Slot: stonecutterInputSlot,
}, input.Grow(-timesCrafted), s, tx)
return h.createResults(s, tx, repeatStacks(output, timesCrafted)...)
}
@@ -0,0 +1,28 @@
package session
import (
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
// SubChunkRequestHandler handles sub-chunk requests from the client. The server will respond with a packet containing
// the requested sub-chunks.
type SubChunkRequestHandler struct{}
// Handle ...
func (*SubChunkRequestHandler) Handle(p packet.Packet, s *Session, tx *world.Tx, _ Controllable) error {
pk := p.(*packet.SubChunkRequest)
if dimID, _ := world.DimensionID(tx.World().Dimension()); pk.Dimension != int32(dimID) {
// Outdated sub chunk request from a previous dimension.
s.writePacket(&packet.SubChunk{
Dimension: pk.Dimension,
Position: pk.Position,
CacheEnabled: s.conn.ClientCacheEnabled(),
SubChunkEntries: []protocol.SubChunkEntry{},
})
return nil
}
s.ViewSubChunks(world.SubChunkPos(pk.Position), pk.Offsets, tx)
return nil
}
+27
View File
@@ -0,0 +1,27 @@
package session
import (
"fmt"
"github.com/df-mc/dragonfly/server/world"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
// TextHandler handles the Text packet.
type TextHandler struct{}
// Handle ...
func (TextHandler) Handle(p packet.Packet, s *Session, _ *world.Tx, c Controllable) error {
pk := p.(*packet.Text)
if pk.TextType != packet.TextTypeChat {
return fmt.Errorf("TextType should always be Chat (%v), but got %v", packet.TextTypeChat, pk.TextType)
}
if pk.SourceName != s.conn.IdentityData().DisplayName {
return fmt.Errorf("SourceName must be equal to DisplayName")
}
if pk.XUID != s.conn.IdentityData().XUID {
return fmt.Errorf("XUID must be equal to player's XUID")
}
c.Chat(pk.Message)
return nil
}
File diff suppressed because it is too large Load Diff
+598
View File
@@ -0,0 +1,598 @@
package session
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"sync"
"sync/atomic"
"time"
"github.com/df-mc/dragonfly/server/block/cube"
"github.com/df-mc/dragonfly/server/cmd"
"github.com/df-mc/dragonfly/server/item/inventory"
"github.com/df-mc/dragonfly/server/item/recipe"
"github.com/df-mc/dragonfly/server/player/chat"
"github.com/df-mc/dragonfly/server/player/debug"
"github.com/df-mc/dragonfly/server/player/form"
"github.com/df-mc/dragonfly/server/player/hud"
"github.com/df-mc/dragonfly/server/player/skin"
"github.com/df-mc/dragonfly/server/world"
"github.com/go-gl/mathgl/mgl64"
"github.com/google/uuid"
"github.com/sandertv/gophertunnel/minecraft"
"github.com/sandertv/gophertunnel/minecraft/nbt"
"github.com/sandertv/gophertunnel/minecraft/protocol"
"github.com/sandertv/gophertunnel/minecraft/protocol/login"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
// Session handles incoming packets from connections and sends outgoing packets by providing a thin layer
// of abstraction over direct packets. A Session basically 'controls' an entity.
type Session struct {
conf Config
once, connOnce sync.Once
ent *world.EntityHandle
conn Conn
handlers map[uint32]packetHandler
packets chan packet.Packet
currentScoreboard atomic.Pointer[string]
currentLines atomic.Pointer[[]string]
chunkLoader *world.Loader
chunkRadius, maxChunkRadius int32
emoteChatMuted bool
teleportPos atomic.Pointer[mgl64.Vec3]
entityMutex sync.RWMutex
// currentEntityRuntimeID holds the runtime ID assigned to the last entity. It is incremented for every
// entity spawned to the session.
currentEntityRuntimeID uint64
// entityRuntimeIDs holds the runtime IDs of entities shown to the session.
entityRuntimeIDs map[*world.EntityHandle]uint64
entities map[uint64]*world.EntityHandle
hiddenEntities map[uuid.UUID]struct{}
// heldSlot is the slot in the inventory that the controllable is holding.
heldSlot *uint32
inv, offHand, enderChest, ui *inventory.Inventory
armour *inventory.Armour
// joinSkin is the first skin that the player joined with. It is sent on
// spawn for the player list, but otherwise updated immediately when the
// player is viewed.
joinSkin skin.Skin
breakingPos cube.Pos
inTransaction, containerOpened atomic.Bool
openedWindowID atomic.Uint32
openedContainerID atomic.Uint32
openedWindow atomic.Pointer[inventory.Inventory]
openedPos atomic.Pointer[cube.Pos]
swingingArm atomic.Bool
changingSlot atomic.Bool
changingDimension atomic.Bool
moving bool
lastChunkPos world.ChunkPos
recipes map[uint32]recipe.Recipe
blobMu sync.Mutex
blobs map[uint64][]byte
openChunkTransactions []map[uint64]struct{}
invOpened bool
hudMu sync.RWMutex
hudUpdates map[hud.Element]bool
hiddenHud map[hud.Element]struct{}
debugShapesMu sync.RWMutex
debugShapes map[int]debug.Shape
debugShapeUpdates []debugShapeUpdate
viewLayer *world.ViewLayer
closeBackground chan struct{}
br world.BlockRegistry
}
// debugShapeUpdate represents a pending debug shape mutation. If shape is nil, the update removes the
// debug shape with the matching ID. Updates are applied in order when the session sends debug shapes.
type debugShapeUpdate struct {
id int
shape debug.Shape
}
// Conn represents a connection that packets are read from and written to by a Session. In addition, it holds some
// information on the identity of the Session.
type Conn interface {
io.Closer
// IdentityData returns the login.IdentityData of a Conn. It contains the UUID, XUID and username of the connection.
IdentityData() login.IdentityData
// ClientData returns the login.ClientData of a Conn. This includes less sensitive data of the player like its skin,
// language code and other non-essential information.
ClientData() login.ClientData
// ClientCacheEnabled specifies if the Conn has the client cache, used for caching chunks client-side, enabled or
// not. Some platforms, like the Nintendo Switch, have this disabled at all times.
ClientCacheEnabled() bool
// ChunkRadius returns the chunk radius as requested by the client at the other end of the Conn.
ChunkRadius() int
// Latency returns the current latency measured over the Conn.
Latency() time.Duration
// Flush flushes the packets buffered by the Conn, sending all of them out immediately.
Flush() error
// RemoteAddr returns the remote network address.
RemoteAddr() net.Addr
// ReadPacket reads a packet.Packet from the Conn. An error is returned if a deadline was set that was
// exceeded or if the Conn was closed while awaiting a packet.
ReadPacket() (pk packet.Packet, err error)
// WritePacket writes a packet.Packet to the Conn. An error is returned if the Conn was closed before sending the
// packet.
WritePacket(pk packet.Packet) error
// StartGameContext starts the game for the Conn with a context to cancel it.
StartGameContext(ctx context.Context, data minecraft.GameData) error
}
// Nop represents a no-operation session. It does not do anything when sending a packet to it.
var Nop = &Session{conf: Config{Log: slog.New(slog.DiscardHandler)}}
// selfEntityRuntimeID is the entity runtime (or unique) ID of the controllable that the session holds.
const selfEntityRuntimeID = 1
// errSelfRuntimeID is an error returned during packet handling for fields that refer to the player itself and
// must therefore always be 1.
var errSelfRuntimeID = errors.New("invalid entity runtime ID: runtime ID for self must always be 1")
type Config struct {
Log *slog.Logger
MaxChunkRadius int
EmoteChatMuted bool
JoinMessage, QuitMessage chat.Translation
HandleStop func(*world.Tx, Controllable)
// BlockRegistry overrides the registry used for network serialization. If nil, world.DefaultBlockRegistry is used.
BlockRegistry world.BlockRegistry
}
func (conf Config) New(conn Conn) *Session {
r := conn.ChunkRadius()
if r > conf.MaxChunkRadius {
r = conf.MaxChunkRadius
_ = conn.WritePacket(&packet.ChunkRadiusUpdated{ChunkRadius: int32(r)})
}
if conf.Log == nil {
conf.Log = slog.Default()
}
conf.Log = conf.Log.With("name", conn.IdentityData().DisplayName, "uuid", conn.IdentityData().Identity, "raddr", conn.RemoteAddr().String())
s := &Session{}
*s = Session{
openChunkTransactions: make([]map[uint64]struct{}, 0, 8),
closeBackground: make(chan struct{}),
handlers: map[uint32]packetHandler{},
packets: make(chan packet.Packet, 256),
entityRuntimeIDs: map[*world.EntityHandle]uint64{},
entities: map[uint64]*world.EntityHandle{},
hiddenEntities: map[uuid.UUID]struct{}{},
blobs: map[uint64][]byte{},
chunkRadius: int32(r),
maxChunkRadius: int32(conf.MaxChunkRadius),
emoteChatMuted: conf.EmoteChatMuted,
conn: conn,
currentEntityRuntimeID: 1,
heldSlot: new(uint32),
recipes: make(map[uint32]recipe.Recipe),
conf: conf,
hudUpdates: make(map[hud.Element]bool),
hiddenHud: make(map[hud.Element]struct{}),
debugShapes: make(map[int]debug.Shape),
debugShapeUpdates: make([]debugShapeUpdate, 0, 256),
}
s.viewLayer = world.NewViewLayer(s)
s.openedWindow.Store(inventory.New(1, nil))
s.openedPos.Store(&cube.Pos{})
var scoreboardName string
var scoreboardLines []string
s.currentScoreboard.Store(&scoreboardName)
s.currentLines.Store(&scoreboardLines)
if conf.BlockRegistry == nil {
s.br = world.DefaultBlockRegistry
} else {
s.br = conf.BlockRegistry
}
s.registerHandlers()
s.sendBiomes()
groups, items := creativeContent(s.br)
s.writePacket(&packet.CreativeContent{Groups: groups, Items: items})
s.sendRecipes()
s.sendArmourTrimData()
s.SendSpeed(0.1)
go func() {
for {
select {
case <-s.closeBackground:
return
case pk := <-s.packets:
_ = conn.WritePacket(pk)
}
}
}()
return s
}
// SetHandle sets the world.EntityHandle of the Session and attaches a skin to
// other players on join.
func (s *Session) SetHandle(handle *world.EntityHandle, skin skin.Skin) {
s.ent = handle
s.entityRuntimeIDs[handle] = selfEntityRuntimeID
s.entities[selfEntityRuntimeID] = handle
s.joinSkin = skin
sessions.Add(s)
}
// Spawn makes the Controllable passed spawn in the world.World.
// The function passed will be called when the session stops running.
func (s *Session) Spawn(c Controllable, tx *world.Tx) {
s.SendHealth(c.Health(), c.MaxHealth(), c.Absorption())
s.SendExperience(c.ExperienceLevel(), c.ExperienceProgress())
s.SendFood(c.Food(), 0, 0)
pos := c.Position()
s.chunkLoader = world.NewLoader(int(s.chunkRadius), tx.World(), s)
s.chunkLoader.Move(tx, pos)
s.writePacket(&packet.NetworkChunkPublisherUpdate{
Position: protocol.BlockPos{int32(pos[0]), int32(pos[1]), int32(pos[2])},
Radius: uint32(s.chunkRadius) << 4,
})
s.sendAvailableEntities(tx.World())
c.SetGameMode(c.GameMode())
for _, e := range c.Effects() {
s.SendEffect(e)
}
s.ViewEntityState(c)
s.sendInv(s.inv, protocol.WindowIDInventory)
s.sendInv(s.ui, protocol.WindowIDUI)
s.sendInv(s.offHand, protocol.WindowIDOffHand)
s.sendInv(s.armour.Inventory(), protocol.WindowIDArmour)
chat.Global.Subscribe(c)
if !s.conf.JoinMessage.Zero() {
chat.Global.Writet(s.conf.JoinMessage, s.conn.IdentityData().DisplayName)
}
go s.background()
go s.handlePackets()
}
// Close closes the session, which in turn closes the controllable and the connection that the session
// manages. Close ensures the method only runs code on the first call.
func (s *Session) Close(tx *world.Tx, c Controllable) {
s.once.Do(func() {
s.close(tx, c)
})
}
// close closes the session, which in turn closes the controllable and the connection that the session
// manages.
func (s *Session) close(tx *world.Tx, c Controllable) {
c.MoveItemsToInventory()
s.closeCurrentContainer(tx, false)
if s.viewLayer != nil {
_ = s.viewLayer.Close()
}
s.conf.HandleStop(tx, c)
// Clear the inventories so that they no longer hold references to the connection.
_ = s.inv.Close()
_ = s.offHand.Close()
_ = s.armour.Close()
s.chunkLoader.Close(tx)
if !s.conf.QuitMessage.Zero() {
chat.Global.Writet(s.conf.QuitMessage, s.conn.IdentityData().DisplayName)
}
chat.Global.Unsubscribe(c)
// Note: Be aware of where RemoveEntity is called. This must not be done too
// early.
tx.RemoveEntity(c)
_ = s.ent.Close()
// This should always be called last due to the timing of the removal of
// entity runtime IDs.
sessions.Remove(s, c)
s.entityMutex.Lock()
clear(s.entityRuntimeIDs)
clear(s.entities)
s.entityMutex.Unlock()
}
// CloseConnection closes the underlying connection of the session so that the session ends up being closed
// eventually.
func (s *Session) CloseConnection() {
s.connOnce.Do(func() {
_ = s.conn.Close()
close(s.closeBackground)
})
}
// Addr returns the net.Addr of the client.
func (s *Session) Addr() net.Addr {
return s.conn.RemoteAddr()
}
// Latency returns the latency of the connection.
func (s *Session) Latency() time.Duration {
return s.conn.Latency()
}
// ClientData returns the login.ClientData of the underlying *minecraft.Conn.
func (s *Session) ClientData() login.ClientData {
return s.conn.ClientData()
}
// handlePackets continuously handles incoming packets from the connection. It processes them accordingly.
// Once the connection is closed, handlePackets will return.
func (s *Session) handlePackets() {
defer func() {
// First close the Controllable. This might lead to a world change
// (player might be dead while disconnecting, in which case it will
// respawn first).
s.ent.ExecWorld(func(tx *world.Tx, e world.Entity) {
_ = e.(Controllable).Close()
})
// Because the player might no longer be in the same world after
// closing, we create a new transaction
s.ent.ExecWorld(func(tx *world.Tx, e world.Entity) {
s.Close(tx, e.(Controllable))
})
}()
for {
pk, err := s.conn.ReadPacket()
if err != nil {
return
}
s.ent.ExecWorld(func(tx *world.Tx, e world.Entity) {
err = s.handlePacket(pk, tx, e.(Controllable))
})
if err != nil {
s.conf.Log.Debug("process packet: " + err.Error())
return
}
}
}
// background performs background tasks of the Session. This includes chunk sending and automatic command updating.
// background returns when the Session's connection is closed using CloseConnection.
func (s *Session) background() {
var (
r map[string]map[int]cmd.Runnable
enums map[string]cmd.Enum
enumValues map[string][]string
softEnums = make(map[string]struct{})
ok bool
i int
)
s.ent.ExecWorld(func(tx *world.Tx, e world.Entity) {
co := e.(Controllable)
r = s.sendAvailableCommands(co, softEnums)
enums, enumValues = s.enums(co)
})
t := time.NewTicker(time.Second / 20)
defer t.Stop()
for {
select {
case <-t.C:
s.ent.ExecWorld(func(tx *world.Tx, e world.Entity) {
c := e.(Controllable)
if i++; i%20 == 0 {
// Enum resending happens relatively often and frequent updates are more important than with full
// command changes. Those are generally only related to permission changes, which doesn't happen often.
r = s.resendEnums(enums, enumValues, softEnums, r, c)
}
if i%100 == 0 {
// Try to resend commands only every 5 seconds.
if r, ok = s.resendCommands(r, c, softEnums); ok {
enums, enumValues = s.enums(c)
}
}
s.sendChunks(tx, c)
})
case <-s.closeBackground:
return
}
}
}
// sendChunks sends the next up to 4 chunks to the connection. What chunks are loaded depends on the connection of
// the chunk loader and the chunks that were previously loaded.
func (s *Session) sendChunks(tx *world.Tx, c Controllable) {
var worldSwitched bool
if w := tx.World(); s.chunkLoader.World() != w && w != nil {
worldSwitched = true
s.handleWorldSwitch(w, tx, c)
}
pos := c.Position()
s.chunkLoader.Move(tx, pos)
chunkPos := world.ChunkPos{int32(pos[0]) << 4, int32(pos[2]) << 4}
if s.lastChunkPos != chunkPos || worldSwitched {
s.lastChunkPos = chunkPos
s.writePacket(&packet.NetworkChunkPublisherUpdate{
Position: protocol.BlockPos{int32(pos[0]), int32(pos[1]), int32(pos[2])},
Radius: uint32(s.chunkRadius) << 4,
})
}
s.blobMu.Lock()
const maxChunkTransactions = 8
toLoad := maxChunkTransactions - len(s.openChunkTransactions)
s.blobMu.Unlock()
if toLoad > 4 {
toLoad = 4
}
s.chunkLoader.Load(tx, toLoad)
}
// handleWorldSwitch handles the player of the Session switching worlds.
func (s *Session) handleWorldSwitch(w *world.World, tx *world.Tx, c Controllable) {
if s.conn.ClientCacheEnabled() {
s.blobMu.Lock()
s.blobs = map[uint64][]byte{}
s.openChunkTransactions = nil
s.blobMu.Unlock()
}
dim, _ := world.DimensionID(w.Dimension())
same := w.Dimension() == s.chunkLoader.World().Dimension()
if !same {
s.changeDimension(int32(dim), false, c)
}
s.ViewEntityTeleport(c, c.Position())
s.chunkLoader.ChangeWorld(tx, w)
}
// changeDimension changes the dimension of the client. If silent is set to true, the portal noise will be stopped
// immediately.
func (s *Session) changeDimension(dim int32, silent bool, c Controllable) {
s.changingDimension.Store(true)
h := s.handlers[packet.IDServerBoundLoadingScreen].(*ServerBoundLoadingScreenHandler)
id := h.currentID.Add(1)
h.expectedID.Store(id)
s.writePacket(&packet.ChangeDimension{
Dimension: dim,
Position: vec64To32(c.Position().Add(entityOffset(c))),
LoadingScreenID: protocol.Option(id),
})
s.writePacket(&packet.StopSound{StopAll: silent})
s.writePacket(&packet.PlayStatus{Status: packet.PlayStatusPlayerSpawn})
// As of v1.19.50, the dimension ack that is meant to be sent by the client is now sent by the server. The client
// still sends the ack, but after the server has sent it. Thanks to Mojang for another groundbreaking change.
s.writePacket(&packet.PlayerAction{
EntityRuntimeID: selfEntityRuntimeID,
ActionType: protocol.PlayerActionDimensionChangeDone,
})
}
// ChangingDimension returns whether the session is currently changing dimension or not.
func (s *Session) ChangingDimension() bool {
return s.changingDimension.Load()
}
// ChunkRadius returns the chunk radius of the session.
func (s *Session) ChunkRadius() int32 {
return s.chunkRadius
}
// handlePacket handles an incoming packet, processing it accordingly. If the packet had invalid data or was
// otherwise not valid in its context, an error is returned.
func (s *Session) handlePacket(pk packet.Packet, tx *world.Tx, c Controllable) (err error) {
handler, ok := s.handlers[pk.ID()]
if !ok {
s.conf.Log.Debug("unhandled packet", "packet", fmt.Sprintf("%T", pk), "data", fmt.Sprintf("%+v", pk)[1:])
return nil
}
if handler == nil {
// A nil handler means it was explicitly unhandled.
return nil
}
if err := handler.Handle(pk, s, tx, c); err != nil {
return fmt.Errorf("%T: %w", pk, err)
}
return nil
}
// registerHandlers registers all packet handlers found in the packetHandler package.
func (s *Session) registerHandlers() {
s.handlers = map[uint32]packetHandler{
packet.IDActorEvent: nil,
packet.IDAdventureSettings: nil, // Deprecated, the client still sends this though.
packet.IDAnimate: nil,
packet.IDAnvilDamage: nil,
packet.IDBlockActorData: &BlockActorDataHandler{},
packet.IDBlockPickRequest: &BlockPickRequestHandler{},
packet.IDBookEdit: &BookEditHandler{},
packet.IDBossEvent: nil,
packet.IDClientCacheBlobStatus: &ClientCacheBlobStatusHandler{},
packet.IDCommandRequest: &CommandRequestHandler{},
packet.IDContainerClose: &ContainerCloseHandler{},
packet.IDEmote: &EmoteHandler{},
packet.IDEmoteList: nil,
packet.IDFilterText: nil,
packet.IDInteract: &InteractHandler{},
packet.IDInventoryTransaction: &InventoryTransactionHandler{},
packet.IDItemStackRequest: &ItemStackRequestHandler{changes: map[byte]map[byte]changeInfo{}, responseChanges: map[int32]map[*inventory.Inventory]map[byte]responseChange{}},
packet.IDLecternUpdate: &LecternUpdateHandler{},
packet.IDMobEquipment: &MobEquipmentHandler{},
packet.IDModalFormResponse: &ModalFormResponseHandler{forms: make(map[uint32]form.Form)},
packet.IDMovePlayer: nil,
packet.IDNPCRequest: &NPCRequestHandler{},
packet.IDPlayerAction: &PlayerActionHandler{},
packet.IDPlayerAuthInput: &PlayerAuthInputHandler{},
packet.IDPlayerSkin: &PlayerSkinHandler{},
packet.IDRequestAbility: &RequestAbilityHandler{},
packet.IDRequestChunkRadius: &RequestChunkRadiusHandler{},
packet.IDRespawn: &RespawnHandler{},
packet.IDSetPlayerInventoryOptions: nil,
packet.IDSubChunkRequest: &SubChunkRequestHandler{},
packet.IDText: &TextHandler{},
packet.IDServerBoundLoadingScreen: &ServerBoundLoadingScreenHandler{},
packet.IDServerBoundDiagnostics: &ServerBoundDiagnosticsHandler{},
}
}
// writePacket writes a packet to the session's connection if it is not Nop.
func (s *Session) writePacket(pk packet.Packet) {
if s == Nop {
return
}
select {
case s.packets <- pk:
case <-s.closeBackground:
}
}
// actorIdentifier represents the structure of an actor identifier sent over the network.
type actorIdentifier struct {
// ID is a unique namespaced identifier for the entity.
ID string `nbt:"id"`
}
// sendAvailableEntities sends all registered entities to the player.
func (s *Session) sendAvailableEntities(w *world.World) {
var identifiers []actorIdentifier
for _, t := range w.EntityRegistry().Types() {
identifiers = append(identifiers, actorIdentifier{ID: t.EncodeEntity()})
}
serialisedEntityData, err := nbt.Marshal(map[string]any{"idlist": identifiers})
if err != nil {
panic("should never happen")
}
s.writePacket(&packet.AvailableActorIdentifiers{SerialisedEntityIdentifiers: serialisedEntityData})
}
+149
View File
@@ -0,0 +1,149 @@
package session
import (
"slices"
"sync"
"github.com/df-mc/dragonfly/server/internal/sliceutil"
"github.com/df-mc/dragonfly/server/player/skin"
"github.com/df-mc/dragonfly/server/world"
"github.com/google/uuid"
"github.com/sandertv/gophertunnel/minecraft/protocol"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
)
var sessions = new(sessionList)
type sessionList struct {
mu sync.Mutex
s []*Session
}
func (l *sessionList) Add(s *Session) {
l.mu.Lock()
defer l.mu.Unlock()
for _, other := range l.s {
// Show all sessions to the new session and the new session to all
// existing sessions.
l.sendSessionTo(s, other)
l.sendSessionTo(other, s)
}
// Show the new session to itself.
l.sendSessionTo(s, s)
l.s = append(l.s, s)
}
func (l *sessionList) Remove(s *Session, entity world.Entity) {
l.mu.Lock()
removedFrom := slices.Clone(l.s)
for _, other := range l.s {
l.unsendSessionFrom(s, other)
}
l.s = sliceutil.DeleteVal(l.s, s)
l.mu.Unlock()
if entity == nil {
return
}
for _, other := range removedFrom {
if other.viewLayer != nil {
other.viewLayer.Remove(entity)
}
}
}
func (l *sessionList) Lookup(id uuid.UUID) (*Session, bool) {
l.mu.Lock()
defer l.mu.Unlock()
if index := slices.IndexFunc(l.s, func(session *Session) bool {
return session.ent.UUID() == id
}); index != -1 {
return l.s[index], true
}
return nil, false
}
func (l *sessionList) sendSessionTo(s, to *Session) {
runtimeID := uint64(selfEntityRuntimeID)
to.entityMutex.Lock()
if s != to {
to.currentEntityRuntimeID += 1
runtimeID = to.currentEntityRuntimeID
}
to.entityRuntimeIDs[s.ent] = runtimeID
to.entities[runtimeID] = s.ent
to.entityMutex.Unlock()
to.writePacket(&packet.PlayerList{
ActionType: packet.PlayerListActionAdd,
Entries: []protocol.PlayerListEntry{{
UUID: s.ent.UUID(),
EntityUniqueID: int64(runtimeID),
Username: s.conn.IdentityData().DisplayName,
XUID: s.conn.IdentityData().XUID,
Skin: skinToProtocol(s.joinSkin),
}},
})
}
func (l *sessionList) unsendSessionFrom(s, from *Session) {
from.entityMutex.Lock()
delete(from.entities, from.entityRuntimeIDs[s.ent])
delete(from.entityRuntimeIDs, s.ent)
from.entityMutex.Unlock()
from.writePacket(&packet.PlayerList{
ActionType: packet.PlayerListActionRemove,
Entries: []protocol.PlayerListEntry{{UUID: s.ent.UUID()}},
})
}
// skinToProtocol converts a skin to its protocol representation.
func skinToProtocol(s skin.Skin) protocol.Skin {
var animations []protocol.SkinAnimation
for _, animation := range s.Animations {
protocolAnim := protocol.SkinAnimation{
ImageWidth: uint32(animation.Bounds().Max.X),
ImageHeight: uint32(animation.Bounds().Max.Y),
ImageData: animation.Pix,
FrameCount: float32(animation.FrameCount),
}
switch animation.Type() {
case skin.AnimationHead:
protocolAnim.AnimationType = protocol.SkinAnimationHead
case skin.AnimationBody32x32:
protocolAnim.AnimationType = protocol.SkinAnimationBody32x32
case skin.AnimationBody128x128:
protocolAnim.AnimationType = protocol.SkinAnimationBody128x128
}
protocolAnim.ExpressionType = uint32(animation.AnimationExpression)
animations = append(animations, protocolAnim)
}
fullID := s.FullID
if fullID == "" {
fullID = uuid.New().String()
}
return protocol.Skin{
PlayFabID: s.PlayFabID,
SkinID: uuid.New().String(),
SkinResourcePatch: s.ModelConfig.Encode(),
SkinImageWidth: uint32(s.Bounds().Max.X),
SkinImageHeight: uint32(s.Bounds().Max.Y),
SkinData: s.Pix,
CapeImageWidth: uint32(s.Cape.Bounds().Max.X),
CapeImageHeight: uint32(s.Cape.Bounds().Max.Y),
CapeData: s.Cape.Pix,
SkinGeometry: s.Model,
PersonaSkin: s.Persona,
CapeID: uuid.New().String(),
FullID: fullID,
Animations: animations,
Trusted: true,
OverrideAppearance: true,
GeometryDataEngineVersion: []byte(protocol.CurrentVersion),
}
}
+186
View File
@@ -0,0 +1,186 @@
package session
import (
"time"
"github.com/df-mc/dragonfly/server/player/chat"
"github.com/df-mc/dragonfly/server/player/scoreboard"
"github.com/sandertv/gophertunnel/minecraft/protocol"
"github.com/sandertv/gophertunnel/minecraft/protocol/packet"
"golang.org/x/text/language"
)
// SendMessage ...
func (s *Session) SendMessage(message string) {
s.writePacket(&packet.Text{
TextType: packet.TextTypeRaw,
Message: message,
})
}
// SendTranslation sends a translation localised for a specific language.Tag.
func (s *Session) SendTranslation(t chat.Translation, l language.Tag, a []any) {
tr := t.F(a...)
s.writePacket(&packet.Text{
TextType: packet.TextTypeTranslation,
NeedsTranslation: true,
Message: tr.Resolve(l),
Parameters: tr.Params(l),
})
}
// SendTip ...
func (s *Session) SendTip(message string) {
s.writePacket(&packet.Text{
TextType: packet.TextTypeTip,
Message: message,
})
}
// SendAnnouncement ...
func (s *Session) SendAnnouncement(message string) {
s.writePacket(&packet.Text{
TextType: packet.TextTypeAnnouncement,
Message: message,
})
}
// SendPopup ...
func (s *Session) SendPopup(message string) {
s.writePacket(&packet.Text{
TextType: packet.TextTypePopup,
Message: message,
})
}
// SendJukeboxPopup ...
func (s *Session) SendJukeboxPopup(message string) {
s.writePacket(&packet.Text{
TextType: packet.TextTypeJukeboxPopup,
Message: message,
})
}
// SendToast ...
func (s *Session) SendToast(title, message string) {
s.writePacket(&packet.ToastRequest{
Title: title,
Message: message,
})
}
// SendScoreboard ...
func (s *Session) SendScoreboard(sb *scoreboard.Scoreboard) {
if s == Nop {
return
}
currentName, currentLines := *s.currentScoreboard.Load(), *s.currentLines.Load()
if currentName != sb.Name() {
s.RemoveScoreboard()
pk := &packet.SetDisplayObjective{
DisplaySlot: "sidebar",
ObjectiveName: sb.Name(),
DisplayName: sb.Name(),
CriteriaName: "dummy",
}
if sb.Descending() {
pk.SortOrder = packet.ScoreboardSortOrderDescending
} else {
pk.SortOrder = packet.ScoreboardSortOrderAscending
}
s.writePacket(pk)
name, lines := sb.Name(), append([]string(nil), sb.Lines()...)
s.currentScoreboard.Store(&name)
s.currentLines.Store(&lines)
} else {
// Remove all current lines from the scoreboard. We can't replace them without removing them.
pk := &packet.SetScore{ActionType: packet.ScoreboardActionRemove}
for i := range currentLines {
pk.Entries = append(pk.Entries, protocol.ScoreboardEntry{
EntryID: int64(i),
ObjectiveName: currentName,
Score: int32(i),
})
}
if len(pk.Entries) > 0 {
s.writePacket(pk)
}
}
pk := &packet.SetScore{ActionType: packet.ScoreboardActionModify}
for k, line := range sb.Lines() {
if len(line) == 0 {
line = "§" + colours[k]
}
pk.Entries = append(pk.Entries, protocol.ScoreboardEntry{
EntryID: int64(k),
ObjectiveName: sb.Name(),
Score: int32(k),
IdentityType: protocol.ScoreboardIdentityFakePlayer,
DisplayName: line,
})
}
if len(pk.Entries) > 0 {
s.writePacket(pk)
}
}
// colours holds a list of colour codes to be filled out for empty lines in a scoreboard.
var colours = [15]string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"}
// RemoveScoreboard ...
func (s *Session) RemoveScoreboard() {
s.writePacket(&packet.RemoveObjective{ObjectiveName: *s.currentScoreboard.Load()})
var name string
var lines []string
s.currentScoreboard.Store(&name)
s.currentLines.Store(&lines)
}
// SendBossBar sends a boss bar to the player with the text passed and the health percentage of the bar.
// SendBossBar removes any boss bar that might be active before sending the new one.
func (s *Session) SendBossBar(text string, colour uint8, healthPercentage float64) {
s.RemoveBossBar()
s.writePacket(&packet.BossEvent{
BossEntityUniqueID: selfEntityRuntimeID,
EventType: packet.BossEventShow,
BossBarTitle: text,
HealthPercentage: float32(healthPercentage),
Colour: colour,
})
}
// RemoveBossBar removes any boss bar currently active on the player's screen.
func (s *Session) RemoveBossBar() {
s.writePacket(&packet.BossEvent{
BossEntityUniqueID: selfEntityRuntimeID,
EventType: packet.BossEventHide,
})
}
const tickLength = time.Second / 20
// SetTitleDurations ...
func (s *Session) SetTitleDurations(fadeInDuration, remainDuration, fadeOutDuration time.Duration) {
s.writePacket(&packet.SetTitle{
ActionType: packet.TitleActionSetDurations,
FadeInDuration: int32(fadeInDuration / tickLength),
RemainDuration: int32(remainDuration / tickLength),
FadeOutDuration: int32(fadeOutDuration / tickLength),
})
}
// SendTitle ...
func (s *Session) SendTitle(text string) {
s.writePacket(&packet.SetTitle{ActionType: packet.TitleActionSetTitle, Text: text})
}
// SendSubtitle ...
func (s *Session) SendSubtitle(text string) {
s.writePacket(&packet.SetTitle{ActionType: packet.TitleActionSetSubtitle, Text: text})
}
// SendActionBarMessage ...
func (s *Session) SendActionBarMessage(text string) {
s.writePacket(&packet.SetTitle{ActionType: packet.TitleActionSetActionBar, Text: text})
}
+73
View File
@@ -0,0 +1,73 @@
package session
import "github.com/df-mc/dragonfly/server/world"
// ViewLayer returns the session's ViewLayer. The layer may be used to override how entities are viewed
// by this session, such as with a different name tag or visibility state.
func (s *Session) ViewLayer() *world.ViewLayer {
return s.viewLayer
}
// ViewNameTag overwrites the public name tag of the entity and immediately refreshes it for this session.
func (s *Session) ViewNameTag(entity world.Entity, nameTag string) {
if s.viewLayer == nil {
return
}
s.viewLayer.ViewNameTag(entity, nameTag)
}
// ViewPublicNameTag removes the name tag override from the entity and immediately refreshes it for this session.
func (s *Session) ViewPublicNameTag(entity world.Entity) {
if s.viewLayer == nil {
return
}
s.viewLayer.ViewPublicNameTag(entity)
}
// ViewScoreTag overwrites the public score tag of the entity and immediately refreshes it for this session.
func (s *Session) ViewScoreTag(entity world.Entity, scoreTag string) {
if s.viewLayer == nil {
return
}
s.viewLayer.ViewScoreTag(entity, scoreTag)
}
// ViewPublicScoreTag removes the score tag override from the entity and immediately refreshes it for this session.
func (s *Session) ViewPublicScoreTag(entity world.Entity) {
if s.viewLayer == nil {
return
}
s.viewLayer.ViewPublicScoreTag(entity)
}
// ViewVisibility overwrites the public visibility of the entity and immediately refreshes it for this session.
func (s *Session) ViewVisibility(entity world.Entity, level world.VisibilityLevel) {
if s.viewLayer == nil {
return
}
s.viewLayer.ViewVisibility(entity, level)
}
// RemoveViewLayer removes all overrides for the entity and immediately refreshes it for this session.
func (s *Session) RemoveViewLayer(entity world.Entity) {
if s.viewLayer == nil {
return
}
s.viewLayer.Remove(entity)
}
// ViewLayerEntityChanged refreshes the entity metadata for this session if the entity is currently visible.
func (s *Session) ViewLayerEntityChanged(e world.Entity) {
if s.entityHidden(e) || !s.viewingEntity(e.H()) {
return
}
s.ViewEntityState(e)
}
// viewingEntity checks if this session currently has a runtime ID assigned to the entity handle.
func (s *Session) viewingEntity(handle *world.EntityHandle) bool {
s.entityMutex.RLock()
_, ok := s.entityRuntimeIDs[handle]
s.entityMutex.RUnlock()
return ok
}
File diff suppressed because it is too large Load Diff