1.21.8 data
Some checks failed
CodeQL / Analyze (go) (push) Has been cancelled
Go / Test (1.22) (push) Has been cancelled
Go / Test (^1.22) (push) Has been cancelled

This commit is contained in:
2025-08-22 06:17:33 +08:00
parent 133e3fab4a
commit 0958972953
173 changed files with 13782 additions and 406 deletions

91
bot/basic/basic.go Normal file
View File

@ -0,0 +1,91 @@
// Package basic provides some basic packet handler which client needs.
//
// # [Player]
//
// The [Player] is attached to a [Client] by calling [NewPlayer] before the client joins a server.
//
// There is 4 kinds of clientbound packet is handled by this package.
// - LoginPacket, for cache player info. The player info will be stored in [Player.PlayerInfo].
// - KeepAlivePacket, for avoid the client to be kicked by the server.
// - PlayerPosition, is only received when server teleporting the player.
// - Respawn, for updating player info, which may change when player respawned.
//
// # [EventsListener]
//
// Handles some basic event you probably need.
// - GameStart
// - Disconnect
// - HealthChange
// - Death
package basic
import (
"github.com/Tnze/go-mc/bot"
"github.com/Tnze/go-mc/data/packetid"
pk "github.com/Tnze/go-mc/net/packet"
)
type Player struct {
c *bot.Client
Settings Settings
PlayerInfo
WorldInfo
}
// NewPlayer create a new Player manager.
func NewPlayer(c *bot.Client, settings Settings, events EventsListener) *Player {
p := &Player{c: c, Settings: settings}
c.Events.AddListener(
bot.PacketHandler{Priority: 0, ID: packetid.ClientboundLogin, F: p.handleLoginPacket},
bot.PacketHandler{Priority: 0, ID: packetid.ClientboundKeepAlive, F: p.handleKeepAlivePacket},
bot.PacketHandler{Priority: 0, ID: packetid.ClientboundRespawn, F: p.handleRespawnPacket},
bot.PacketHandler{Priority: 0, ID: packetid.ClientboundPing, F: p.handlePingPacket},
bot.PacketHandler{Priority: 0, ID: packetid.ClientboundCookieRequest, F: p.handleCookieRequestPacket},
bot.PacketHandler{Priority: 0, ID: packetid.ClientboundStoreCookie, F: p.handleStoreCookiePacket},
bot.PacketHandler{Priority: 0, ID: packetid.ClientboundUpdateTags, F: p.handleUpdateTags},
)
events.attach(p)
return p
}
// Respawn is used to send a respawn packet to the server.
// Typically, you should call this method when the player is dead (in the [Death] event handler).
func (p *Player) Respawn() error {
const PerformRespawn = 0
err := p.c.Conn.WritePacket(pk.Marshal(
packetid.ServerboundClientCommand,
pk.VarInt(PerformRespawn),
))
if err != nil {
return Error{err}
}
return nil
}
// AcceptTeleportation is used to send a teleport confirmation packet to the server.
// Typically, you should call this method when received a ClientboundPlayerPosition packet (in the [Teleported] event handler).
func (p *Player) AcceptTeleportation(teleportID pk.VarInt) error {
err := p.c.Conn.WritePacket(pk.Marshal(
packetid.ServerboundAcceptTeleportation,
teleportID,
))
if err != nil {
return Error{err}
}
return nil
}
type Error struct {
Err error
}
func (e Error) Error() string {
return "bot/basic: " + e.Err.Error()
}
func (e Error) Unwrap() error {
return e.Err
}

37
bot/basic/cookie.go Normal file
View File

@ -0,0 +1,37 @@
package basic
import (
"github.com/Tnze/go-mc/data/packetid"
pk "github.com/Tnze/go-mc/net/packet"
)
func (p *Player) handleCookieRequestPacket(packet pk.Packet) error {
var key pk.Identifier
err := packet.Scan(&key)
if err != nil {
return Error{err}
}
cookieContent := p.c.Cookies[string(key)]
err = p.c.Conn.WritePacket(pk.Marshal(
packetid.ServerboundCookieResponse,
key, pk.OptionEncoder[pk.ByteArray]{
Has: cookieContent != nil,
Val: pk.ByteArray(cookieContent),
},
))
if err != nil {
return Error{err}
}
return nil
}
func (p *Player) handleStoreCookiePacket(packet pk.Packet) error {
var key pk.Identifier
var payload pk.ByteArray
err := packet.Scan(&key, &payload)
if err != nil {
return Error{err}
}
p.c.Cookies[string(key)] = []byte(payload)
return nil
}

163
bot/basic/events.go Normal file
View File

@ -0,0 +1,163 @@
package basic
import (
"github.com/Tnze/go-mc/bot"
"github.com/Tnze/go-mc/chat"
"github.com/Tnze/go-mc/data/packetid"
pk "github.com/Tnze/go-mc/net/packet"
)
// EventsListener is a collection of event handlers.
// Fill the fields with your handler functions and pass it to [NewPlayer] to create the Player manager.
// For the event you don't want to handle, just leave it nil.
type EventsListener struct {
// GameStart event is called when the login process is completed and the player is ready to play.
//
// If you want to do some action when the bot joined the server like sending a chat message,
// this event is the right place to do it.
GameStart func() error
// Disconnect event is called before the server disconnects your client.
// When the server willfully disconnects the client, it will send a ClientboundDisconnect packet and tell you why.
// On vanilla client, the reason is displayed in the disconnect screen.
//
// This information may be very useful for debugging, and generally you should record it into the log.
//
// If the connection is disconnected due to network reasons or the client's initiative,
// this event will not be triggered.
Disconnect func(reason chat.Message) error
// HealthChange event is called when the player's health or food changed.
HealthChange func(health float32, foodLevel int32, foodSaturation float32) error
// Death event is a special case of HealthChange.
// It will be called after HealthChange handler called (if it isn't nil)
// when the player's health is less than or equal to 0.
//
// Typically, you should call [Player.Respawn] in this handler.
Death func() error
// Teleported event is called when the server think the player position in the client side is wrong,
// and send a ClientboundPlayerPosition packet to correct the client.
//
// Typically, you need to do two things in this handler:
// - Update the player's position and rotation you tracked to the correct position.
// - Call [Player.AcceptTeleportation] to send a teleport confirmation packet to the server.
//
// Before you confirm the teleportation, the server will not accept any player motion packets.
//
// The position coordinates and rotation are absolute or relative depends on the flags.
// The flag byte is a bitfield, specifies whether each coordinate value is absolute or relative.
// For more information, see https://wiki.vg/Protocol#Synchronize_Player_Position
Teleported func(x, y, z float64, yaw, pitch float32, flags byte, teleportID int32) error
}
// attach your event listener to the client.
// The functions are copied when attaching, and modify on [EventListener] doesn't affect after that.
func (e EventsListener) attach(p *Player) {
if e.GameStart != nil {
attachJoinGameHandler(p.c, e.GameStart)
}
if e.Disconnect != nil {
attachDisconnect(p.c, e.Disconnect)
}
if e.HealthChange != nil || e.Death != nil {
attachUpdateHealth(p.c, e.HealthChange, e.Death)
}
if e.Teleported != nil {
attachPlayerPosition(p.c, e.Teleported)
}
}
func attachJoinGameHandler(c *bot.Client, handler func() error) {
c.Events.AddListener(bot.PacketHandler{
Priority: 64, ID: packetid.ClientboundLogin,
F: func(_ pk.Packet) error {
return handler()
},
})
}
func attachDisconnect(c *bot.Client, handler func(reason chat.Message) error) {
c.Events.AddListener(bot.PacketHandler{
Priority: 64, ID: packetid.ClientboundDisconnect,
F: func(p pk.Packet) error {
var reason chat.Message
if err := p.Scan(&reason); err != nil {
return Error{err}
}
return handler(chat.Message(reason))
},
})
}
func attachUpdateHealth(c *bot.Client, healthChangeHandler func(health float32, food int32, saturation float32) error, deathHandler func() error) {
c.Events.AddListener(bot.PacketHandler{
Priority: 64, ID: packetid.ClientboundSetHealth,
F: func(p pk.Packet) error {
var health pk.Float
var food pk.VarInt
var saturation pk.Float
if err := p.Scan(&health, &food, &saturation); err != nil {
return Error{err}
}
var healthChangeErr, deathErr error
if healthChangeHandler != nil {
healthChangeErr = healthChangeHandler(float32(health), int32(food), float32(saturation))
}
if deathHandler != nil && health <= 0 {
deathErr = deathHandler()
}
if healthChangeErr != nil || deathErr != nil {
return updateHealthError{healthChangeErr, deathErr}
}
return nil
},
})
}
func attachPlayerPosition(c *bot.Client, handler func(x, y, z float64, yaw, pitch float32, flag byte, teleportID int32) error) {
c.Events.AddListener(bot.PacketHandler{
Priority: 64, ID: packetid.ClientboundPlayerPosition,
F: func(p pk.Packet) error {
var (
X, Y, Z pk.Double
Yaw, Pitch pk.Float
Flags pk.Byte
TeleportID pk.VarInt
)
if err := p.Scan(&X, &Y, &Z, &Yaw, &Pitch, &Flags, &TeleportID); err != nil {
return Error{err}
}
return handler(float64(X), float64(Y), float64(Z), float32(Yaw), float32(Pitch), byte(Flags), int32(TeleportID))
},
})
}
type updateHealthError struct {
healthChangeErr, deathErr error
}
func (u updateHealthError) Unwrap() error {
if u.healthChangeErr != nil {
return u.healthChangeErr
}
if u.deathErr != nil {
return u.deathErr
}
return nil
}
func (u updateHealthError) Error() string {
switch {
case u.healthChangeErr != nil && u.deathErr != nil:
return "[" + u.healthChangeErr.Error() + ", " + u.deathErr.Error() + "]"
case u.healthChangeErr != nil:
return u.healthChangeErr.Error()
case u.deathErr != nil:
return u.deathErr.Error()
default:
return "nil"
}
}

100
bot/basic/info.go Normal file
View File

@ -0,0 +1,100 @@
package basic
import (
"unsafe"
"github.com/Tnze/go-mc/data/packetid"
pk "github.com/Tnze/go-mc/net/packet"
)
// WorldInfo content player info in server.
type WorldInfo struct {
DimensionType int32
DimensionNames []string // Identifiers for all worlds on the server.
DimensionName string // Name of the world being spawned into.
HashedSeed int64 // First 8 bytes of the SHA-256 hash of the world's seed. Used client side for biome noise
MaxPlayers int32 // Was once used by the client to draw the player list, but now is ignored.
ViewDistance int32 // Render distance (2-32).
SimulationDistance int32 // The distance that the client will process specific things, such as entities.
ReducedDebugInfo bool // If true, a vanilla client shows reduced information on the debug screen. For servers in development, this should almost always be false.
EnableRespawnScreen bool // Set to false when the doImmediateRespawn gamerule is true.
IsDebug bool // True if the world is a debug mode world; debug mode worlds cannot be modified and have predefined blocks.
IsFlat bool // True if the world is a superflat world; flat worlds have different void fog and a horizon at y=0 instead of y=63.
DoLimitCrafting bool // Whether players can only craft recipes they have already unlocked. Currently unused by the client.
}
type PlayerInfo struct {
EID int32 // The player's Entity ID (EID).
Hardcore bool // Is hardcore
Gamemode byte // Gamemode. 0: Survival, 1: Creative, 2: Adventure, 3: Spectator.
PrevGamemode int8 // Previous Gamemode
}
func (p *Player) handleLoginPacket(packet pk.Packet) error {
err := packet.Scan(
(*pk.Int)(&p.EID),
(*pk.Boolean)(&p.Hardcore),
pk.Array((*[]pk.Identifier)(unsafe.Pointer(&p.DimensionNames))),
(*pk.VarInt)(&p.MaxPlayers),
(*pk.VarInt)(&p.ViewDistance),
(*pk.VarInt)(&p.SimulationDistance),
(*pk.Boolean)(&p.ReducedDebugInfo),
(*pk.Boolean)(&p.EnableRespawnScreen),
(*pk.Boolean)(&p.DoLimitCrafting),
(*pk.VarInt)(&p.WorldInfo.DimensionType),
(*pk.Identifier)(&p.DimensionName),
(*pk.Long)(&p.HashedSeed),
(*pk.UnsignedByte)(&p.Gamemode),
(*pk.Byte)(&p.PrevGamemode),
(*pk.Boolean)(&p.IsDebug),
(*pk.Boolean)(&p.IsFlat),
// Death dimension & Death location & Portal cooldown are ignored
)
if err != nil {
return Error{err}
}
err = p.c.Conn.WritePacket(pk.Marshal( // PluginMessage packet
packetid.ServerboundCustomPayload,
pk.Identifier("minecraft:brand"),
pk.String(p.Settings.Brand),
))
if err != nil {
return Error{err}
}
err = p.c.Conn.WritePacket(pk.Marshal(
packetid.ServerboundClientInformation, // Client settings
pk.String(p.Settings.Locale),
pk.Byte(p.Settings.ViewDistance),
pk.VarInt(p.Settings.ChatMode),
pk.Boolean(p.Settings.ChatColors),
pk.UnsignedByte(p.Settings.DisplayedSkinParts),
pk.VarInt(p.Settings.MainHand),
pk.Boolean(p.Settings.EnableTextFiltering),
pk.Boolean(p.Settings.AllowListing),
))
if err != nil {
return Error{err}
}
p.resetKeepAliveDeadline()
return nil
}
func (p *Player) handleRespawnPacket(packet pk.Packet) error {
var copyMeta bool
err := packet.Scan(
(*pk.VarInt)(&p.DimensionType),
(*pk.Identifier)(&p.DimensionName),
(*pk.Long)(&p.HashedSeed),
(*pk.UnsignedByte)(&p.Gamemode),
(*pk.Byte)(&p.PrevGamemode),
(*pk.Boolean)(&p.IsDebug),
(*pk.Boolean)(&p.IsFlat),
(*pk.Boolean)(&copyMeta),
)
if err != nil {
return Error{err}
}
return nil
}

34
bot/basic/keepalive.go Normal file
View File

@ -0,0 +1,34 @@
package basic
import (
"time"
"github.com/Tnze/go-mc/data/packetid"
pk "github.com/Tnze/go-mc/net/packet"
)
const keepAliveDuration = time.Second * 20
func (p *Player) resetKeepAliveDeadline() {
newDeadline := time.Now().Add(keepAliveDuration)
p.c.Conn.Socket.SetDeadline(newDeadline)
}
func (p *Player) handleKeepAlivePacket(packet pk.Packet) error {
var KeepAliveID pk.Long
if err := packet.Scan(&KeepAliveID); err != nil {
return Error{err}
}
p.resetKeepAliveDeadline()
// Response
err := p.c.Conn.WritePacket(pk.Packet{
ID: int32(packetid.ServerboundKeepAlive),
Data: packet.Data,
})
if err != nil {
return Error{err}
}
return nil
}

23
bot/basic/ping.go Normal file
View File

@ -0,0 +1,23 @@
package basic
import (
"github.com/Tnze/go-mc/data/packetid"
pk "github.com/Tnze/go-mc/net/packet"
)
func (p *Player) handlePingPacket(packet pk.Packet) error {
var pingID pk.Int
if err := packet.Scan(&pingID); err != nil {
return Error{err}
}
// Response
err := p.c.Conn.WritePacket(pk.Packet{
ID: int32(packetid.ServerboundPong),
Data: packet.Data,
})
if err != nil {
return Error{err}
}
return nil
}

45
bot/basic/settings.go Normal file
View File

@ -0,0 +1,45 @@
package basic
// Settings of client
type Settings struct {
Locale string // 地区
ViewDistance int // 视距
ChatMode int // 聊天模式
ChatColors bool // 聊天颜色
DisplayedSkinParts uint8 // 皮肤显示
MainHand int // 主手
// Enables filtering of text on signs and written book titles.
// Currently, always false (i.e. the filtering is disabled)
EnableTextFiltering bool
AllowListing bool
// The brand string presented to the server.
Brand string
}
// Used by Settings.DisplayedSkinParts.
// For each bit set if shows match part.
const (
_ = 1 << iota
Jacket
LeftSleeve
RightSleeve
LeftPantsLeg
RightPantsLeg
Hat
)
// DefaultSettings are the default settings of client
var DefaultSettings = Settings{
Locale: "zh_CN", // ^_^
ViewDistance: 15,
ChatMode: 0,
DisplayedSkinParts: Jacket | LeftSleeve | RightSleeve | LeftPantsLeg | RightPantsLeg | Hat,
MainHand: 1,
EnableTextFiltering: false,
AllowListing: true,
Brand: "vanilla",
}

37
bot/basic/tags.go Normal file
View File

@ -0,0 +1,37 @@
package basic
import (
"bytes"
"errors"
pk "github.com/Tnze/go-mc/net/packet"
)
func (p *Player) handleUpdateTags(packet pk.Packet) error {
r := bytes.NewReader(packet.Data)
var length pk.VarInt
_, err := length.ReadFrom(r)
if err != nil {
return Error{err}
}
var registryID pk.Identifier
for i := 0; i < int(length); i++ {
_, err = registryID.ReadFrom(r)
if err != nil {
return Error{err}
}
registry := p.c.Registries.Registry(string(registryID))
if registry == nil {
return Error{errors.New("unknown registry: " + string(registryID))}
}
_, err = registry.ReadTagsFrom(r)
if err != nil {
return Error{err}
}
}
return nil
}

136
bot/client.go Normal file
View File

@ -0,0 +1,136 @@
package bot
import (
"errors"
"sync"
"github.com/google/uuid"
"github.com/Tnze/go-mc/data/packetid"
"github.com/Tnze/go-mc/net"
pk "github.com/Tnze/go-mc/net/packet"
"github.com/Tnze/go-mc/net/queue"
"github.com/Tnze/go-mc/registry"
)
// Client is used to access Minecraft server
type Client struct {
Conn *Conn
Auth Auth
// These are filled when login process
Name string
UUID uuid.UUID
Registries registry.Registries
Cookies map[string][]byte
// Ingame packet handlers
Events Events
// Login plugins
LoginPlugin map[string]CustomPayloadHandler
// Configuration handler
ConfigHandler
CustomReportDetails map[string]string
}
// CustomPayloadHandler is a function handling custom payload
type CustomPayloadHandler func(data []byte) ([]byte, error)
func (c *Client) Close() error {
return c.Conn.Close()
}
// NewClient init and return a new Client.
//
// A new Client has default name "Steve" and zero UUID.
// It is usable for an offline-mode game.
//
// For online-mode, you need login your Mojang account
// and load your Name, UUID and AccessToken to client.
func NewClient() *Client {
return &Client{
Auth: Auth{Name: "Steve"},
Registries: registry.NewNetworkCodec(),
Events: Events{handlers: make([][]PacketHandler, packetid.ClientboundPacketIDGuard)},
LoginPlugin: make(map[string]CustomPayloadHandler),
ConfigHandler: NewDefaultConfigHandler(),
CustomReportDetails: make(map[string]string),
}
}
// Conn is a concurrently-safe warpper of net.Conn with packet queue.
// Note that not all methods are concurrently-safe.
type Conn struct {
*net.Conn
send, recv queue.Queue[pk.Packet]
pool sync.Pool // pool of recv packet data
rerr error
}
func warpConn(c *net.Conn, qr, qw queue.Queue[pk.Packet]) *Conn {
wc := Conn{
Conn: c,
send: qw,
recv: qr,
pool: sync.Pool{New: func() any { return []byte{} }},
rerr: nil,
}
go func() {
for {
// take a buffer from pool, after the packet is handled we put it back
p := pk.Packet{Data: wc.pool.Get().([]byte)}
if err := c.ReadPacket(&p); err != nil {
wc.rerr = err
break
}
if ok := wc.recv.Push(p); !ok {
wc.rerr = errors.New("receive queue is full")
break
}
}
wc.recv.Close()
}()
go func() {
for {
p, ok := wc.send.Pull()
if !ok {
break
}
if err := c.WritePacket(p); err != nil {
break
}
}
}()
return &wc
}
func (c *Conn) ReadPacket(p *pk.Packet) error {
packet, ok := c.recv.Pull()
if !ok {
return c.rerr
}
*p = packet
return nil
}
func (c *Conn) WritePacket(p pk.Packet) error {
ok := c.send.Push(p)
if !ok {
return errors.New("queue is full")
}
return nil
}
func (c *Conn) Close() error {
c.send.Close()
return c.Conn.Close()
}
// Position is a 3D vector.
type Position struct {
X, Y, Z int
}

396
bot/configuration.go Normal file
View File

@ -0,0 +1,396 @@
package bot
import (
"bytes"
"errors"
"fmt"
"io"
"github.com/Tnze/go-mc/chat"
"github.com/Tnze/go-mc/data/packetid"
"github.com/Tnze/go-mc/net"
pk "github.com/Tnze/go-mc/net/packet"
)
type ConfigHandler interface {
EnableFeature(features []pk.Identifier)
PushResourcePack(res ResourcePack)
PopResourcePack(id pk.UUID)
PopAllResourcePack()
SelectDataPacks(packs []DataPack) []DataPack
}
type ResourcePack struct {
ID pk.UUID
URL string
Hash string
Forced bool
PromptMessage *chat.Message // Optional
}
type ConfigErr struct {
Stage string
Err error
}
func (l ConfigErr) Error() string {
return "bot: configuration error: [" + l.Stage + "] " + l.Err.Error()
}
func (l ConfigErr) Unwrap() error {
return l.Err
}
func (c *Client) joinConfiguration(conn *net.Conn) error {
for {
var p pk.Packet
if err := conn.ReadPacket(&p); err != nil {
return ConfigErr{"config custom payload", err}
}
switch packetid.ClientboundPacketID(p.ID) {
case packetid.ClientboundConfigCookieRequest:
var key pk.Identifier
err := p.Scan(&key)
if err != nil {
return ConfigErr{"cookie request", err}
}
cookieContent := c.Cookies[string(key)]
err = conn.WritePacket(pk.Marshal(
packetid.ServerboundConfigCookieResponse,
key, pk.OptionEncoder[pk.ByteArray]{
Has: cookieContent != nil,
Val: pk.ByteArray(cookieContent),
},
))
if err != nil {
return ConfigErr{"cookie response", err}
}
case packetid.ClientboundConfigCustomPayload:
var channel pk.Identifier
var data pk.PluginMessageData
err := p.Scan(&channel, &data)
if err != nil {
return ConfigErr{"custom payload", err}
}
// TODO: Provide configuration custom data handling interface
//
// There are two types of Custom packet.
// One for Login stage, the other for config and play stage.
// The first one called "Custom Query", and the second one called "Custom Payload".
// We can know the different by their name, the "query" is one request to one response, paired.
// But the second one can be sent in any order.
//
// And the custome payload packet seems to be same in config stage and play stage.
// How do we provide API for that?
case packetid.ClientboundConfigDisconnect:
const ErrStage = "disconnect"
var reason chat.Message
err := p.Scan(&reason)
if err != nil {
return ConfigErr{ErrStage, err}
}
return ConfigErr{ErrStage, DisconnectErr(reason)}
case packetid.ClientboundConfigFinishConfiguration:
err := conn.WritePacket(pk.Marshal(
packetid.ServerboundConfigFinishConfiguration,
))
if err != nil {
return ConfigErr{"finish config", err}
}
return nil
case packetid.ClientboundConfigKeepAlive:
const ErrStage = "keep alive"
var keepAliveID pk.Long
err := p.Scan(&keepAliveID)
if err != nil {
return ConfigErr{ErrStage, err}
}
// send it back
err = conn.WritePacket(pk.Marshal(
packetid.ServerboundConfigKeepAlive,
keepAliveID,
))
if err != nil {
return ConfigErr{ErrStage, err}
}
case packetid.ClientboundConfigPing:
var pingID pk.Int
err := p.Scan(&pingID)
if err != nil {
return ConfigErr{"ping", err}
}
// send it back
err = conn.WritePacket(pk.Marshal(
packetid.ServerboundConfigPong,
pingID,
))
if err != nil {
return ConfigErr{"pong", err}
}
case packetid.ClientboundConfigResetChat:
// TODO
case packetid.ClientboundConfigRegistryData:
const ErrStage = "registry"
var registryID pk.Identifier
r := bytes.NewReader(p.Data)
_, err := registryID.ReadFrom(r)
if err != nil {
return ConfigErr{ErrStage, err}
}
registry := c.Registries.Registry(string(registryID))
if registry == nil {
return ConfigErr{ErrStage, errors.New("unknown registry: " + string(registryID))}
}
_, err = registry.ReadFrom(r)
if err != nil {
return ConfigErr{ErrStage, fmt.Errorf("failed to read registry %s: %w", registryID, err)}
}
case packetid.ClientboundConfigResourcePackPop:
var id pk.Option[pk.UUID, *pk.UUID]
err := p.Scan(&id)
if err != nil {
return ConfigErr{"resource pack pop", err}
}
case packetid.ClientboundConfigResourcePackPush:
var id pk.UUID
var Url, Hash pk.String
var Forced pk.Boolean
var PromptMessage pk.Option[chat.Message, *chat.Message]
err := p.Scan(
&id,
&Url,
&Hash,
&Forced,
&PromptMessage,
)
if err != nil {
return ConfigErr{"resource pack", err}
}
res := ResourcePack{
ID: id,
URL: string(Url),
Hash: string(Hash),
Forced: bool(Forced),
}
if PromptMessage.Has {
res.PromptMessage = &PromptMessage.Val
}
c.ConfigHandler.PushResourcePack(res)
case packetid.ClientboundConfigStoreCookie:
var key pk.Identifier
var payload pk.ByteArray
err := p.Scan(&key, &payload)
if err != nil {
return ConfigErr{"store cookie", err}
}
c.Cookies[string(key)] = []byte(payload)
case packetid.ClientboundConfigTransfer:
var host pk.String
var port pk.VarInt
err := p.Scan(&host, &port)
if err != nil {
return ConfigErr{"transfer", err}
}
// TODO: trnasfer to the specific server
// How does it work? Just connect the new server, and re-start at handshake?
case packetid.ClientboundConfigUpdateEnabledFeatures:
features := []pk.Identifier{}
err := p.Scan(pk.Array(&features))
if err != nil {
return ConfigErr{"update enabled features", err}
}
c.ConfigHandler.EnableFeature(features)
case packetid.ClientboundConfigUpdateTags:
const ErrStage = "update tags"
r := bytes.NewReader(p.Data)
var length pk.VarInt
_, err := length.ReadFrom(r)
if err != nil {
return ConfigErr{ErrStage, err}
}
var registryID pk.Identifier
for i := 0; i < int(length); i++ {
_, err = registryID.ReadFrom(r)
if err != nil {
return ConfigErr{ErrStage, err}
}
registry := c.Registries.Registry(string(registryID))
if registry == nil {
// TODO: Sice our registry system is incompelted, ignore all tags bind to non-exist registry
_, err = idleTagsDecoder{}.ReadFrom(r)
if err != nil {
return ConfigErr{ErrStage, err}
}
continue
// return ConfigErr{ErrStage, errors.New("unknown registry: " + string(registryID))}
}
_, err = registry.ReadTagsFrom(r)
if err != nil {
return ConfigErr{ErrStage, err}
}
}
case packetid.ClientboundConfigSelectKnownPacks:
const ErrStage = "select known packs"
packs := []DataPack{}
err := p.Scan(pk.Array(&packs))
if err != nil {
return ConfigErr{ErrStage, err}
}
knwonPacks := c.ConfigHandler.SelectDataPacks(packs)
err = conn.WritePacket(pk.Marshal(
packetid.ServerboundConfigSelectKnownPacks,
pk.Array(knwonPacks),
))
if err != nil {
return ConfigErr{ErrStage, err}
}
case packetid.ClientboundConfigCustomReportDetails:
const ErrStage = "custom report details"
var length pk.VarInt
var title, description pk.String
r := bytes.NewReader(p.Data)
_, err := length.ReadFrom(r)
if err != nil {
return ConfigErr{ErrStage, err}
}
for i := 0; i < int(length); i++ {
_, err = title.ReadFrom(r)
if err != nil {
return ConfigErr{ErrStage, err}
}
_, err = description.ReadFrom(r)
if err != nil {
return ConfigErr{ErrStage, err}
}
c.CustomReportDetails[string(title)] = string(description)
}
case packetid.ClientboundConfigServerLinks:
// TODO
}
}
}
type DataPack struct {
Namespace string
ID string
Version string
}
func (d DataPack) WriteTo(w io.Writer) (n int64, err error) {
n, err = pk.String(d.Namespace).WriteTo(w)
if err != nil {
return n, err
}
n1, err := pk.String(d.ID).WriteTo(w)
if err != nil {
return n + n1, err
}
n2, err := pk.String(d.Version).WriteTo(w)
return n + n1 + n2, err
}
func (d *DataPack) ReadFrom(r io.Reader) (n int64, err error) {
n, err = (*pk.String)(&d.Namespace).ReadFrom(r)
if err != nil {
return n, err
}
n1, err := (*pk.String)(&d.ID).ReadFrom(r)
if err != nil {
return n + n1, err
}
n2, err := (*pk.String)(&d.Version).ReadFrom(r)
return n + n1 + n2, err
}
type DefaultConfigHandler struct {
resourcesPack []ResourcePack
}
func NewDefaultConfigHandler() *DefaultConfigHandler {
return &DefaultConfigHandler{
resourcesPack: make([]ResourcePack, 0),
}
}
func (d *DefaultConfigHandler) EnableFeature(features []pk.Identifier) {}
func (d *DefaultConfigHandler) PushResourcePack(res ResourcePack) {
d.resourcesPack = append(d.resourcesPack, res)
}
func (d *DefaultConfigHandler) PopResourcePack(id pk.UUID) {
for i, v := range d.resourcesPack {
if id == v.ID {
d.resourcesPack = append(d.resourcesPack[:i], d.resourcesPack[i+1:]...)
break
}
}
}
func (d *DefaultConfigHandler) PopAllResourcePack() {
d.resourcesPack = d.resourcesPack[:0]
}
func (d *DefaultConfigHandler) SelectDataPacks(packs []DataPack) []DataPack {
return []DataPack{}
}
type idleTagsDecoder struct{}
func (idleTagsDecoder) ReadFrom(r io.Reader) (int64, error) {
var count pk.VarInt
var tag pk.Identifier
var length pk.VarInt
n, err := count.ReadFrom(r)
if err != nil {
return n, err
}
for i := 0; i < int(count); i++ {
var n1, n2, n3 int64
n1, err = tag.ReadFrom(r)
if err != nil {
return n + n1, err
}
n2, err = length.ReadFrom(r)
if err != nil {
return n + n1 + n2, err
}
n += n1 + n2
var id pk.VarInt
for i := 0; i < int(length); i++ {
n3, err = id.ReadFrom(r)
if err != nil {
return n + n3, err
}
n += n3
}
}
return n, nil
}

51
bot/event.go Normal file
View File

@ -0,0 +1,51 @@
package bot
import (
"sort"
"strconv"
"github.com/Tnze/go-mc/data/packetid"
pk "github.com/Tnze/go-mc/net/packet"
)
type Events struct {
generic []PacketHandler // for every packet
handlers [][]PacketHandler // for specific packet id only
}
func (e *Events) AddListener(listeners ...PacketHandler) {
for _, l := range listeners {
// panic if l.ID is invalid
if l.ID < 0 || int(l.ID) >= len(e.handlers) {
panic("Invalid packet ID (" + strconv.Itoa(int(l.ID)) + ")")
}
if s := e.handlers[l.ID]; s == nil {
e.handlers[l.ID] = []PacketHandler{l}
} else {
e.handlers[l.ID] = append(s, l)
sortPacketHandlers(e.handlers[l.ID])
}
}
}
// AddGeneric adds listeners like AddListener, but the packet ID is ignored.
// Generic listener is always called before specific packet listener.
func (e *Events) AddGeneric(listeners ...PacketHandler) {
e.generic = append(e.generic, listeners...)
sortPacketHandlers(e.generic)
}
type (
PacketHandlerFunc func(p pk.Packet) error
PacketHandler struct {
ID packetid.ClientboundPacketID
Priority int
F func(p pk.Packet) error
}
)
func sortPacketHandlers(slice []PacketHandler) {
sort.SliceStable(slice, func(i, j int) bool {
return slice[i].Priority > slice[j].Priority
})
}

79
bot/example_test.go Normal file
View File

@ -0,0 +1,79 @@
package bot
import (
"encoding/hex"
"log"
"github.com/Tnze/go-mc/offline"
"github.com/Tnze/go-mc/yggdrasil"
)
func ExamplePingAndList() {
resp, delay, err := PingAndList("localhost:25565")
if err != nil {
log.Fatalf("ping and list server fail: %v", err)
}
log.Println("Status:", string(resp))
log.Println("Delay:", delay)
}
func ExampleClient_JoinServer_offline() {
c := NewClient()
c.Auth.Name = "Tnze" // set its name before login.
id := offline.NameToUUID(c.Auth.Name) // optional, get uuid of offline mode game
c.Auth.UUID = hex.EncodeToString(id[:])
// Login
err := c.JoinServer("127.0.0.1")
if err != nil {
log.Fatal(err)
}
log.Println("Login success")
// Register event handlers
// c.Events.AddListener(...)
// JoinGame
err = c.HandleGame()
if err != nil {
log.Fatal(err)
}
}
func ExampleClient_JoinServer_online() {
c := NewClient()
// Login Mojang account to get AccessToken
// To use Microsoft Account, see issue #106
// https://github.com/Tnze/go-mc/issues/106
auth, err := yggdrasil.Authenticate("Your E-mail", "Your Password")
if err != nil {
panic(err)
}
// As long as you set these three fields correctly,
// the client can connect to the online-mode server
c.Auth.UUID, c.Auth.Name = auth.SelectedProfile()
c.Auth.AsTk = auth.AccessToken()
// Connect server
err = c.JoinServer("127.0.0.1")
if err != nil {
log.Fatal(err)
}
log.Println("Login success")
// Register event handlers
// c.Events.GameStart = onGameStartFunc
// c.Events.ChatMsg = onChatMsgFunc
// c.Events.Disconnect = onDisconnectFunc
// ...
// Join the game
err = c.HandleGame()
if err != nil {
log.Fatal(err)
}
}

93
bot/ingame.go Normal file
View File

@ -0,0 +1,93 @@
package bot
import (
"errors"
"fmt"
"github.com/Tnze/go-mc/data/packetid"
pk "github.com/Tnze/go-mc/net/packet"
)
// HandleGame receive server packet and response them correctly.
// Note that HandleGame will block if you don't receive from Events.
func (c *Client) HandleGame() error {
for {
var p pk.Packet
// Read packets
if err := c.Conn.ReadPacket(&p); err != nil {
return err
}
if p.ID == int32(packetid.BundleDelimiter) {
err := c.handleBundlePackets()
if err != nil {
return err
}
} else {
// handle packets
err := c.handlePacket(p)
if err != nil {
return err
}
// return the packet buffer
c.Conn.pool.Put(p.Data)
}
}
}
type PacketHandlerError struct {
ID packetid.ClientboundPacketID
Err error
}
func (d PacketHandlerError) Error() string {
return fmt.Sprintf("handle packet %v error: %v", d.ID, d.Err)
}
func (d PacketHandlerError) Unwrap() error {
return d.Err
}
func (c *Client) handleBundlePackets() (err error) {
var packets []pk.Packet
for i := 0; i < 4096; i++ {
var p pk.Packet
// Read packets
if err := c.Conn.ReadPacket(&p); err != nil {
return err
}
if p.ID == int32(packetid.BundleDelimiter) {
// bundle finished
goto handlePackets
}
packets = append(packets, p)
}
return errors.New("packet number of a bundle out of limit")
handlePackets:
for i := range packets {
if err := c.handlePacket(packets[i]); err != nil {
return err
}
}
return nil
}
func (c *Client) handlePacket(p pk.Packet) (err error) {
packetID := packetid.ClientboundPacketID(p.ID)
for _, handler := range c.Events.generic {
if err = handler.F(p); err != nil {
return PacketHandlerError{ID: packetID, Err: err}
}
}
for _, handler := range c.Events.handlers[packetID] {
err = handler.F(p)
if err != nil {
return PacketHandlerError{ID: packetID, Err: err}
}
}
return
}

328
bot/login.go Normal file
View File

@ -0,0 +1,328 @@
package bot
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"crypto/x509"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/google/uuid"
"github.com/Tnze/go-mc/chat"
"github.com/Tnze/go-mc/data/packetid"
"github.com/Tnze/go-mc/net"
"github.com/Tnze/go-mc/net/CFB8"
pk "github.com/Tnze/go-mc/net/packet"
)
type LoginErr struct {
Stage string
Err error
}
func (l LoginErr) Error() string {
return "bot: login error: [" + l.Stage + "] " + l.Err.Error()
}
func (l LoginErr) Unwrap() error {
return l.Err
}
func (c *Client) joinLogin(conn *net.Conn) error {
var err error
if c.Auth.UUID != "" {
c.UUID, err = uuid.Parse(c.Auth.UUID)
if err != nil {
return LoginErr{"login start", err}
}
}
err = conn.WritePacket(pk.Marshal(
packetid.ServerboundLoginHello,
pk.String(c.Auth.Name),
pk.UUID(c.UUID),
))
if err != nil {
return LoginErr{"login start", err}
}
receiving := "encrypt start"
for {
// Receive Packet
var p pk.Packet
if err = conn.ReadPacket(&p); err != nil {
return LoginErr{receiving, err}
}
// Handle Packet
switch packetid.ClientboundPacketID(p.ID) {
case packetid.ClientboundLoginLoginDisconnect: // LoginDisconnect
var reason chat.JsonMessage
err = p.Scan(&reason)
if err != nil {
return LoginErr{"disconnect", err}
}
return LoginErr{"disconnect", DisconnectErr(reason)}
case packetid.ClientboundLoginHello: // Encryption Request
if err := handleEncryptionRequest(conn, c, p); err != nil {
return LoginErr{"encryption", err}
}
receiving = "set compression"
case packetid.ClientboundLoginGameProfile: // Login Success
err := p.Scan(
(*pk.UUID)(&c.UUID),
(*pk.String)(&c.Name),
)
if err != nil {
return LoginErr{"login success", err}
}
err = conn.WritePacket(pk.Marshal(packetid.ServerboundLoginLoginAcknowledged))
if err != nil {
return LoginErr{"login success", err}
}
return nil
case packetid.ClientboundLoginLoginCompression: // Set Compression
var threshold pk.VarInt
if err := p.Scan(&threshold); err != nil {
return LoginErr{"compression", err}
}
conn.SetThreshold(int(threshold))
receiving = "login success"
case packetid.ClientboundLoginCustomQuery: // Login Plugin Request
var (
msgid pk.VarInt
channel pk.Identifier
data pk.PluginMessageData
)
if err := p.Scan(&msgid, &channel, &data); err != nil {
return LoginErr{"Login Plugin", err}
}
var PluginMessageData pk.Option[pk.PluginMessageData, *pk.PluginMessageData]
if handler, ok := c.LoginPlugin[string(channel)]; ok {
PluginMessageData.Has = true
PluginMessageData.Val, err = handler(data)
if err != nil {
return LoginErr{"Login Plugin", err}
}
}
if err := conn.WritePacket(pk.Marshal(
packetid.ServerboundLoginCustomQueryAnswer,
msgid, PluginMessageData,
)); err != nil {
return LoginErr{"login Plugin", err}
}
case packetid.ClientboundLoginCookieRequest:
var key pk.Identifier
err := p.Scan(&key)
if err != nil {
return LoginErr{"cookie request", err}
}
cookieContent := c.Cookies[string(key)]
err = conn.WritePacket(pk.Marshal(
packetid.ServerboundLoginCookieResponse,
key, pk.OptionEncoder[pk.ByteArray]{
Has: cookieContent != nil,
Val: pk.ByteArray(cookieContent),
},
))
if err != nil {
return LoginErr{"cookie response", err}
}
}
}
}
// Auth includes an account
type Auth struct {
Name string
UUID string
AsTk string
}
func handleEncryptionRequest(conn *net.Conn, c *Client, p pk.Packet) error {
// 创建AES对称加密密钥
key, encoStream, decoStream := newSymmetricEncryption()
// Read EncryptionRequest
var er encryptionRequest
if err := p.Scan(&er); err != nil {
return err
}
err := loginAuth(c.Auth, key, er) // 向Mojang验证
if err != nil {
return fmt.Errorf("login fail: %v", err)
}
// 响应加密请求
// Write Encryption Key Response
p, err = genEncryptionKeyResponse(key, er.PublicKey, er.VerifyToken)
if err != nil {
return fmt.Errorf("gen encryption key response fail: %v", err)
}
err = conn.WritePacket(p)
if err != nil {
return err
}
// 设置连接加密
conn.SetCipher(encoStream, decoStream)
return nil
}
type encryptionRequest struct {
ServerID string
PublicKey []byte
VerifyToken []byte
}
func (e *encryptionRequest) ReadFrom(r io.Reader) (int64, error) {
return pk.Tuple{
(*pk.String)(&e.ServerID),
(*pk.ByteArray)(&e.PublicKey),
(*pk.ByteArray)(&e.VerifyToken),
}.ReadFrom(r)
}
// authDigest computes a special SHA-1 digest required for Minecraft web
// authentication on Premium servers (online-mode=true).
// Source: http://wiki.vg/Protocol_Encryption#Server
func authDigest(serverID string, sharedSecret, publicKey []byte) string {
h := sha1.New()
h.Write([]byte(serverID))
h.Write(sharedSecret)
h.Write(publicKey)
hash := h.Sum(nil)
// Check for negative hashes
negative := (hash[0] & 0x80) == 0x80
if negative {
hash = twosComplement(hash)
}
// Trim away zeroes
res := strings.TrimLeft(hex.EncodeToString(hash), "0")
if negative {
res = "-" + res
}
return res
}
// little endian
func twosComplement(p []byte) []byte {
carry := true
for i := len(p) - 1; i >= 0; i-- {
p[i] = ^p[i]
if carry {
carry = p[i] == 0xff
p[i]++
}
}
return p
}
type profile struct {
ID string `json:"id"`
Name string `json:"name"`
}
type request struct {
AccessToken string `json:"accessToken"`
SelectedProfile profile `json:"selectedProfile"`
ServerID string `json:"serverId"`
}
func loginAuth(auth Auth, shareSecret []byte, er encryptionRequest) error {
digest := authDigest(er.ServerID, shareSecret, er.PublicKey)
requestPacket, err := json.Marshal(
request{
AccessToken: auth.AsTk,
SelectedProfile: profile{
ID: auth.UUID,
Name: auth.Name,
},
ServerID: digest,
},
)
if err != nil {
return fmt.Errorf("create request packet to yggdrasil faile: %v", err)
}
PostRequest, err := http.NewRequest(http.MethodPost, "https://sessionserver.mojang.com/session/minecraft/join",
bytes.NewReader(requestPacket))
if err != nil {
return fmt.Errorf("make request error: %v", err)
}
PostRequest.Header.Set("User-agent", "go-mc")
PostRequest.Header.Set("Connection", "keep-alive")
PostRequest.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(PostRequest)
if err != nil {
return fmt.Errorf("post fail: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("auth fail: %s", string(body))
}
return nil
}
// AES/CFB8 with random key
func newSymmetricEncryption() (key []byte, encoStream, decoStream cipher.Stream) {
key = make([]byte, 16)
if _, err := rand.Read(key); err != nil {
panic(err)
}
b, err := aes.NewCipher(key)
if err != nil {
panic(err)
}
decoStream = CFB8.NewCFB8Decrypt(b, key)
encoStream = CFB8.NewCFB8Encrypt(b, key)
return
}
func genEncryptionKeyResponse(shareSecret, publicKey, verifyToken []byte) (erp pk.Packet, err error) {
iPK, err := x509.ParsePKIXPublicKey(publicKey) // Decode Public Key
if err != nil {
err = fmt.Errorf("decode public key fail: %v", err)
return
}
rsaKey := iPK.(*rsa.PublicKey)
cryptPK, err := rsa.EncryptPKCS1v15(rand.Reader, rsaKey, shareSecret)
if err != nil {
err = fmt.Errorf("encryption share secret fail: %v", err)
return
}
verifyT, err := rsa.EncryptPKCS1v15(rand.Reader, rsaKey, verifyToken)
if err != nil {
err = fmt.Errorf("encryption verfy tokenfail: %v", err)
return erp, err
}
return pk.Marshal(
packetid.ServerboundLoginKey,
pk.ByteArray(cryptPK),
pk.ByteArray(verifyT),
), nil
}

128
bot/mcbot.go Normal file
View File

@ -0,0 +1,128 @@
// Package bot implements a simple Minecraft client that can join a server
// or just ping it for getting information.
//
// Runnable example could be found at examples/ .
package bot
import (
"context"
"errors"
"net"
"strconv"
"github.com/Tnze/go-mc/chat"
mcnet "github.com/Tnze/go-mc/net"
pk "github.com/Tnze/go-mc/net/packet"
"github.com/Tnze/go-mc/net/queue"
"github.com/Tnze/go-mc/yggdrasil/user"
)
// ProtocolVersion is the protocol version number of minecraft net protocol
const (
ProtocolVersion = 767
DefaultPort = mcnet.DefaultPort
)
type JoinOptions struct {
MCDialer mcnet.MCDialer
Context context.Context
// Indicate not to fetch and sending player's PubKey
NoPublicKey bool
// Specify the player PubKey to use.
// If nil, it will be obtained from Mojang when joining
KeyPair *user.KeyPairResp
QueueRead queue.Queue[pk.Packet]
QueueWrite queue.Queue[pk.Packet]
}
// JoinServer connect a Minecraft server for playing the game.
// Using roughly the same way to parse address as minecraft.
func (c *Client) JoinServer(addr string) (err error) {
return c.JoinServerWithOptions(addr, JoinOptions{})
}
// JoinServerWithDialer is similar to JoinServer but using a net.Dialer.
func (c *Client) JoinServerWithDialer(dialer *net.Dialer, addr string) (err error) {
return c.JoinServerWithOptions(addr, JoinOptions{
MCDialer: (*mcnet.Dialer)(dialer),
})
}
func (c *Client) JoinServerWithOptions(addr string, options JoinOptions) (err error) {
if options.MCDialer == nil {
options.MCDialer = &mcnet.DefaultDialer
}
if options.Context == nil {
options.Context = context.Background()
}
if options.QueueRead == nil {
options.QueueRead = queue.NewLinkedQueue[pk.Packet]()
}
if options.QueueWrite == nil {
options.QueueWrite = queue.NewLinkedQueue[pk.Packet]()
}
return c.join(addr, options)
}
func (c *Client) join(addr string, options JoinOptions) error {
const Handshake = 0x00
// Split Host and Port. The DialMCContext will do this once,
// but we need the result for sending handshake packet here.
host, portStr, err := net.SplitHostPort(addr)
var port uint64
if err != nil {
var addrErr *net.AddrError
const missingPort = "missing port in address"
if errors.As(err, &addrErr) && addrErr.Err == missingPort {
host = addr
port = 25565
} else {
return LoginErr{"split address", err}
}
} else {
port, err = strconv.ParseUint(portStr, 0, 16)
if err != nil {
return LoginErr{"parse port", err}
}
}
// Dial connection
conn, err := options.MCDialer.DialMCContext(options.Context, addr)
if err != nil {
return LoginErr{"connect server", err}
}
// Handshake
err = conn.WritePacket(pk.Marshal(
Handshake,
pk.VarInt(ProtocolVersion), // Protocol version
pk.String(host), // Host
pk.UnsignedShort(port), // Port
pk.VarInt(2),
))
if err != nil {
return LoginErr{"handshake", err}
}
// Login Start
if err := c.joinLogin(conn); err != nil {
return err
}
// Configuration
if err := c.joinConfiguration(conn); err != nil {
return err
}
c.Conn = warpConn(conn, options.QueueRead, options.QueueWrite)
return nil
}
type DisconnectErr chat.Message
func (d DisconnectErr) Error() string {
return "disconnect because: " + chat.Message(d).String()
}

221
bot/msg/chat.go Normal file
View File

@ -0,0 +1,221 @@
package msg
import (
"crypto/rand"
"encoding/binary"
"errors"
"time"
"github.com/google/uuid"
"github.com/Tnze/go-mc/bot"
"github.com/Tnze/go-mc/bot/basic"
"github.com/Tnze/go-mc/bot/playerlist"
"github.com/Tnze/go-mc/chat"
"github.com/Tnze/go-mc/chat/sign"
"github.com/Tnze/go-mc/data/packetid"
pk "github.com/Tnze/go-mc/net/packet"
)
// The Manager is used to receive and send chat messages.
type Manager struct {
c *bot.Client
p *basic.Player
pl *playerlist.PlayerList
events EventsHandler
sign.SignatureCache
}
// New returns a new chat manager.
func New(c *bot.Client, p *basic.Player, pl *playerlist.PlayerList, events EventsHandler) *Manager {
m := &Manager{
c: c,
p: p,
pl: pl,
events: events,
SignatureCache: sign.NewSignatureCache(),
}
if events.SystemChat != nil {
c.Events.AddListener(bot.PacketHandler{
Priority: 64, ID: packetid.ClientboundSystemChat,
F: m.handleSystemChat,
})
}
if events.PlayerChatMessage != nil {
c.Events.AddListener(bot.PacketHandler{
Priority: 64, ID: packetid.ClientboundPlayerChat,
F: m.handlePlayerChat,
})
}
if events.DisguisedChat != nil {
c.Events.AddListener(bot.PacketHandler{
Priority: 64, ID: packetid.ClientboundDisguisedChat,
F: m.handleDisguisedChat,
})
}
return m
}
func (m *Manager) handleSystemChat(p pk.Packet) error {
var msg chat.Message
var overlay pk.Boolean
if err := p.Scan(&msg, &overlay); err != nil {
return err
}
return m.events.SystemChat(msg, bool(overlay))
}
func (m *Manager) handlePlayerChat(packet pk.Packet) error {
var (
sender pk.UUID
index pk.VarInt
signature pk.Option[sign.Signature, *sign.Signature]
body sign.PackedMessageBody
unsignedContent pk.Option[chat.Message, *chat.Message]
filter sign.FilterMask
chatType chat.Type
)
if err := packet.Scan(&sender, &index, &signature, &body, &unsignedContent, &filter, &chatType); err != nil {
return err
}
unpackedMsg, err := body.Unpack(&m.SignatureCache)
if err != nil {
return InvalidChatPacket{err}
}
senderInfo, ok := m.pl.PlayerInfos[uuid.UUID(sender)]
if !ok {
return InvalidChatPacket{ErrUnknownPlayer}
}
ct := m.c.Registries.ChatType.GetByID(chatType.ID)
if ct == nil {
return InvalidChatPacket{ErrUnknwonChatType}
}
var message sign.Message
if senderInfo.ChatSession != nil {
message.Prev = sign.Prev{
Index: int(index),
Sender: uuid.UUID(sender),
Session: senderInfo.ChatSession.SessionID,
}
} else {
message.Prev = sign.Prev{
Index: 0,
Sender: uuid.UUID(sender),
Session: uuid.Nil,
}
}
message.Signature = signature.Pointer()
message.MessageBody = unpackedMsg
message.Unsigned = unsignedContent.Pointer()
message.FilterMask = filter
var validated bool
if senderInfo.ChatSession != nil {
if !senderInfo.ChatSession.VerifyAndUpdate(&message) {
return ErrValidationFailed
}
validated = true
// store signature into signatureCache
m.PopOrInsert(signature.Pointer(), message.LastSeen)
}
var content chat.Message
if unsignedContent.Has {
content = unsignedContent.Val
} else {
content = chat.Text(body.PlainMsg)
}
msg := chatType.Decorate(content, &ct.Chat)
return m.events.PlayerChatMessage(msg, validated)
}
func (m *Manager) handleDisguisedChat(packet pk.Packet) error {
var (
message chat.Message
chatType chat.Type
)
if err := packet.Scan(&message, &chatType); err != nil {
return err
}
ct := m.c.Registries.ChatType.GetByID(chatType.ID)
if ct == nil {
return InvalidChatPacket{ErrUnknwonChatType}
}
msg := chatType.Decorate(message, &ct.Chat)
return m.events.DisguisedChat(msg)
}
// SendMessage send chat message to server.
// Doesn't support sending message with signature currently.
func (m *Manager) SendMessage(msg string) error {
if len(msg) > 256 {
return errors.New("message length greater than 256")
}
var salt int64
if err := binary.Read(rand.Reader, binary.BigEndian, &salt); err != nil {
return err
}
err := m.c.Conn.WritePacket(pk.Marshal(
packetid.ServerboundChat,
pk.String(msg),
pk.Long(time.Now().UnixMilli()),
pk.Long(salt),
pk.Boolean(false), // signature
sign.HistoryUpdate{
Acknowledged: pk.NewFixedBitSet(20),
},
))
return err
}
// SendMessage send chat message to server.
// Doesn't support sending message with signature currently.
func (m *Manager) SendCommand(command string) error {
if len(command) > 256 {
return errors.New("message length greater than 256")
}
var salt int64
if err := binary.Read(rand.Reader, binary.BigEndian, &salt); err != nil {
return err
}
err := m.c.Conn.WritePacket(pk.Marshal(
packetid.ServerboundChatCommand,
pk.String(command),
pk.Long(time.Now().UnixMilli()),
pk.Long(salt),
pk.Ary[pk.VarInt]{Ary: []pk.Tuple{}},
sign.HistoryUpdate{
Acknowledged: pk.NewFixedBitSet(20),
},
))
return err
}
type InvalidChatPacket struct {
err error
}
func (i InvalidChatPacket) Error() string {
if i.err == nil {
return "invalid chat packet"
}
return "invalid chat packet: " + i.err.Error()
}
func (i InvalidChatPacket) Unwrap() error {
return i.err
}
var (
ErrUnknownPlayer = errors.New("unknown player")
ErrUnknwonChatType = errors.New("unknown chat type")
ErrValidationFailed error = bot.DisconnectErr(chat.TranslateMsg("multiplayer.disconnect.chat_validation_failed"))
)

29
bot/msg/events.go Normal file
View File

@ -0,0 +1,29 @@
package msg
import "github.com/Tnze/go-mc/chat"
// EventsHandler is a collection of event handlers.
// Fill the fields with your handler functions and pass this struct to [New] to create the msg manager.
// The handler functions will be called when the corresponding event is triggered.
// Leave the fields as nil if you don't want to handle the event.
type EventsHandler struct {
// SystemChat handles messages sent by gaming system.
//
// In vanilla client:
// If overlay is false, the message will be displayed in the chat box.
// If overlay is true, the message will be displayed on the top of the hot-bar.
SystemChat func(msg chat.Message, overlay bool) error
// PlayerChatMessage handles messages sent by players.
//
// Message signing system is added in 1.19. The message and its context could be signed by the player's private key.
// The manager tries to verify the message signature through the player's public key,
// and return the result as validated boolean.
PlayerChatMessage func(msg chat.Message, validated bool) error
// DisguisedChat handles DisguisedChat message.
//
// DisguisedChat message used to send system chat.
// Now it is used to send messages from "/say" command from server console.
DisguisedChat func(msg chat.Message) error
}

137
bot/pinglist.go Normal file
View File

@ -0,0 +1,137 @@
package bot
import (
"context"
"errors"
"fmt"
"net"
"os"
"strconv"
"time"
"github.com/Tnze/go-mc/data/packetid"
mcnet "github.com/Tnze/go-mc/net"
pk "github.com/Tnze/go-mc/net/packet"
)
// PingAndList check server status and list online player.
// Returns a JSON data with server status, and the delay.
//
// For more information for JSON format, see https://wiki.vg/Server_List_Ping#Response
func PingAndList(addr string) ([]byte, time.Duration, error) {
conn, err := mcnet.DialMC(addr)
if err != nil {
return nil, 0, LoginErr{"dial connection", err}
}
return pingAndList(context.Background(), addr, conn)
}
// PingAndListTimeout is the version of PingAndList with max request time.
func PingAndListTimeout(addr string, timeout time.Duration) ([]byte, time.Duration, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return PingAndListContext(ctx, addr)
}
func PingAndListContext(ctx context.Context, addr string) ([]byte, time.Duration, error) {
conn, err := mcnet.DefaultDialer.DialMCContext(ctx, addr)
if err != nil {
return nil, 0, err
}
return pingAndList(ctx, addr, conn)
}
func pingAndList(ctx context.Context, addr string, conn *mcnet.Conn) (data []byte, delay time.Duration, err error) {
if deadline, hasDeadline := ctx.Deadline(); hasDeadline {
if err := conn.Socket.SetDeadline(deadline); err != nil {
return nil, 0, err
}
defer func() {
// Reset deadline
if err2 := conn.Socket.SetDeadline(time.Time{}); err2 != nil {
if err == nil {
err = err2
}
return
}
// Map error type
if errors.Is(err, os.ErrDeadlineExceeded) {
err = context.DeadlineExceeded
}
}()
}
// Split Host and Port
host, portStr, err := net.SplitHostPort(addr)
var port uint64
if err != nil {
var addrErr *net.AddrError
const missingPort = "missing port in address"
if errors.As(err, &addrErr) && addrErr.Err == missingPort {
host, port, err = addr, DefaultPort, nil
} else {
return nil, 0, LoginErr{"split address", err}
}
} else {
port, err = strconv.ParseUint(portStr, 0, 16)
if err != nil {
return nil, 0, LoginErr{"parse port", err}
}
}
const Handshake = 0x00
// 握手
err = conn.WritePacket(pk.Marshal(
Handshake, // Handshake packet ID
pk.VarInt(ProtocolVersion), // Protocol version
pk.String(host), // Server's address
pk.UnsignedShort(port),
pk.Byte(1),
))
if err != nil {
return nil, 0, fmt.Errorf("bot: send handshake packect fail: %v", err)
}
// LIST
// 请求服务器状态
err = conn.WritePacket(pk.Marshal(
packetid.ServerboundStatusStatusRequest,
))
if err != nil {
return nil, 0, fmt.Errorf("bot: send list packect fail: %v", err)
}
var p pk.Packet
// 服务器返回状态
if err := conn.ReadPacket(&p); err != nil {
return nil, 0, fmt.Errorf("bot: recv list packect fail: %v", err)
}
var s pk.String
err = p.Scan(&s)
if err != nil {
return nil, 0, fmt.Errorf("bot: scan list packect fail: %v", err)
}
// PING
startTime := time.Now()
err = conn.WritePacket(pk.Marshal(
packetid.ServerboundStatusPingRequest,
pk.Long(startTime.Unix()),
))
if err != nil {
return nil, 0, fmt.Errorf("bot: send ping packect fail: %v", err)
}
if err = conn.ReadPacket(&p); err != nil {
return nil, 0, fmt.Errorf("bot: recv pong packect fail: %v", err)
}
var t pk.Long
err = p.Scan(&t)
if err != nil {
return nil, 0, fmt.Errorf("bot: scan pong packect fail: %v", err)
}
if t != pk.Long(startTime.Unix()) {
return nil, 0, fmt.Errorf("bot: pong packect no match: %v", err)
}
return []byte(s), time.Since(startTime), err
}

View File

@ -0,0 +1,167 @@
// Package playerlist contains a PlayerList struct that used to manage player information.
//
// The [PlayerList] contains a list of [PlayerInfo] which is received from server when client join.
// The playerlist contains every players' information of name, display name, uuid, gamemode, latency, public key, etc.
// And can be used to render the "TAB List". Other packages may also require playerlist to work,
// for example, the bot/msg package.
package playerlist
import (
"bytes"
"github.com/google/uuid"
"github.com/Tnze/go-mc/bot"
"github.com/Tnze/go-mc/chat"
"github.com/Tnze/go-mc/chat/sign"
"github.com/Tnze/go-mc/data/packetid"
pk "github.com/Tnze/go-mc/net/packet"
"github.com/Tnze/go-mc/yggdrasil/user"
)
type PlayerList struct {
PlayerInfos map[uuid.UUID]*PlayerInfo
}
func New(c *bot.Client) *PlayerList {
pl := PlayerList{
PlayerInfos: make(map[uuid.UUID]*PlayerInfo),
}
c.Events.AddListener(
bot.PacketHandler{
Priority: 64, ID: packetid.ClientboundPlayerInfoUpdate,
F: pl.handlePlayerInfoUpdatePacket,
},
bot.PacketHandler{
Priority: 64, ID: packetid.ClientboundPlayerInfoRemove,
F: pl.handlePlayerInfoRemovePacket,
},
)
return &pl
}
func (pl *PlayerList) handlePlayerInfoUpdatePacket(p pk.Packet) error {
r := bytes.NewReader(p.Data)
action := pk.NewFixedBitSet(6)
if _, err := action.ReadFrom(r); err != nil {
return err
}
var length pk.VarInt
if _, err := length.ReadFrom(r); err != nil {
return err
}
for i := 0; i < int(length); i++ {
var id pk.UUID
if _, err := id.ReadFrom(r); err != nil {
return err
}
player, ok := pl.PlayerInfos[uuid.UUID(id)]
if !ok { // create new player info if not exist
player = new(PlayerInfo)
pl.PlayerInfos[uuid.UUID(id)] = player
}
// add player
if action.Get(0) {
var name pk.String
var properties []user.Property
if _, err := (pk.Tuple{&name, pk.Array(&properties)}).ReadFrom(r); err != nil {
return err
}
player.GameProfile = GameProfile{
ID: uuid.UUID(id),
Name: string(name),
Properties: properties,
}
}
// initialize chat
if action.Get(1) {
var chatSession pk.Option[sign.Session, *sign.Session]
if _, err := chatSession.ReadFrom(r); err != nil {
return err
}
if chatSession.Has {
player.ChatSession = chatSession.Pointer()
player.ChatSession.InitValidate()
} else {
player.ChatSession = nil
}
}
// update gamemode
if action.Get(2) {
var gamemode pk.VarInt
if _, err := gamemode.ReadFrom(r); err != nil {
return err
}
player.Gamemode = int32(gamemode)
}
// update listed
if action.Get(3) {
var listed pk.Boolean
if _, err := listed.ReadFrom(r); err != nil {
return err
}
player.Listed = bool(listed)
}
// update latency
if action.Get(4) {
var latency pk.VarInt
if _, err := latency.ReadFrom(r); err != nil {
return err
}
player.Latency = int32(latency)
}
// display name
if action.Get(5) {
var displayName pk.Option[chat.Message, *chat.Message]
if _, err := displayName.ReadFrom(r); err != nil {
return err
}
if displayName.Has {
player.DisplayName = &displayName.Val
} else {
player.DisplayName = nil
}
}
}
return nil
}
func (pl *PlayerList) handlePlayerInfoRemovePacket(p pk.Packet) error {
r := bytes.NewReader(p.Data)
var (
length pk.VarInt
id pk.UUID
)
if _, err := length.ReadFrom(r); err != nil {
return err
}
for i := 0; i < int(length); i++ {
if _, err := id.ReadFrom(r); err != nil {
return err
}
delete(pl.PlayerInfos, uuid.UUID(id))
}
return nil
}
type PlayerInfo struct {
GameProfile
ChatSession *sign.Session
Gamemode int32
Latency int32
Listed bool
DisplayName *chat.Message
}
type GameProfile struct {
ID uuid.UUID
Name string
Properties []user.Property
}

39
bot/screen/chest.go Normal file
View File

@ -0,0 +1,39 @@
package screen
import (
"errors"
"github.com/Tnze/go-mc/chat"
"github.com/Tnze/go-mc/data/inventory"
)
type Chest struct {
Type inventory.InventoryID
Title chat.Message
Slots []Slot
Rows int
}
func (c *Chest) onSetSlot(i int, slot Slot) error {
if i < 0 || i >= len(c.Slots) {
return errors.New("slot index out of bounds")
}
c.Slots[i] = slot
return nil
}
func (c *Chest) onClose() error {
return nil
}
func (c *Chest) Container() []Slot {
return c.Slots[0 : c.Rows*9]
}
func (c *Chest) Main() []Slot {
return c.Slots[c.Rows*9 : c.Rows*9+27]
}
func (c *Chest) Hotbar() []Slot {
return c.Slots[c.Rows*9+27 : (c.Rows+4)*9]
}

9
bot/screen/events.go Normal file
View File

@ -0,0 +1,9 @@
package screen
import "github.com/Tnze/go-mc/chat"
type EventsListener struct {
Open func(id int, container_type int32, title chat.Message) error
SetSlot func(id, index int) error
Close func(id int) error
}

29
bot/screen/inventory.go Normal file
View File

@ -0,0 +1,29 @@
package screen
import "errors"
type Inventory struct {
Slots [46]Slot
}
func (inv *Inventory) onClose() error {
return nil
}
func (inv *Inventory) onSetSlot(i int, s Slot) error {
if i < 0 || i >= len(inv.Slots) {
return errors.New("slot index out of bounds")
}
inv.Slots[i] = s
return nil
}
func (inv *Inventory) CraftingOutput() *Slot { return &inv.Slots[0] }
func (inv *Inventory) CraftingInput() []Slot { return inv.Slots[1 : 1+4] }
// Armor returns to the armor section of the Inventory.
// The length is 4, which are head, chest, legs and feet.
func (inv *Inventory) Armor() []Slot { return inv.Slots[5 : 5+4] }
func (inv *Inventory) Main() []Slot { return inv.Slots[9 : 9+3*9] }
func (inv *Inventory) Hotbar() []Slot { return inv.Slots[36 : 36+9] }
func (inv *Inventory) Offhand() *Slot { return &inv.Slots[45] }

240
bot/screen/screen.go Normal file
View File

@ -0,0 +1,240 @@
package screen
import (
"errors"
"io"
"github.com/Tnze/go-mc/bot"
"github.com/Tnze/go-mc/chat"
"github.com/Tnze/go-mc/data/packetid"
"github.com/Tnze/go-mc/nbt"
pk "github.com/Tnze/go-mc/net/packet"
)
type Manager struct {
c *bot.Client
Screens map[int]Container
Inventory Inventory
Cursor Slot
events EventsListener
// The last received State ID from server
stateID int32
}
func NewManager(c *bot.Client, e EventsListener) *Manager {
m := &Manager{
c: c,
Screens: make(map[int]Container),
events: e,
}
m.Screens[0] = &m.Inventory
c.Events.AddListener(
bot.PacketHandler{Priority: 0, ID: packetid.ClientboundOpenScreen, F: m.onOpenScreen},
bot.PacketHandler{Priority: 0, ID: packetid.ClientboundContainerSetContent, F: m.onSetContentPacket},
bot.PacketHandler{Priority: 0, ID: packetid.ClientboundContainerClose, F: m.onCloseScreen},
bot.PacketHandler{Priority: 0, ID: packetid.ClientboundContainerSetSlot, F: m.onSetSlot},
)
return m
}
type ChangedSlots map[int]*Slot
func (m *Manager) ContainerClick(id int, slot int16, button byte, mode int32, slots ChangedSlots, carried *Slot) error {
return m.c.Conn.WritePacket(pk.Marshal(
packetid.ServerboundContainerClick,
pk.UnsignedByte(id),
pk.VarInt(m.stateID),
pk.Short(slot),
pk.Byte(button),
pk.VarInt(mode),
slots,
carried,
))
}
func (c ChangedSlots) WriteTo(w io.Writer) (n int64, err error) {
n, err = pk.VarInt(len(c)).WriteTo(w)
if err != nil {
return
}
for i, v := range c {
n1, err := pk.Short(i).WriteTo(w)
if err != nil {
return n + n1, err
}
n2, err := v.WriteTo(w)
if err != nil {
return n + n1 + n2, err
}
n += n1 + n2
}
return
}
func (m *Manager) onOpenScreen(p pk.Packet) error {
var (
ContainerID pk.VarInt
Type pk.VarInt
Title chat.Message
)
if err := p.Scan(&ContainerID, &Type, &Title); err != nil {
return Error{err}
}
if _, ok := m.Screens[int(ContainerID)]; !ok {
TypeInt32 := int32(Type)
if TypeInt32 < 6 {
Rows := TypeInt32 + 1
chest := Chest{
Type: TypeInt32,
Slots: make([]Slot, 9*Rows),
Rows: int(Rows),
Title: Title,
}
m.Screens[int(ContainerID)] = &chest
}
} else {
return errors.New("container id already exists in screens")
}
if m.events.Open != nil {
if err := m.events.Open(int(ContainerID), int32(Type), Title); err != nil {
return Error{err}
}
}
return nil
}
func (m *Manager) onSetContentPacket(p pk.Packet) error {
var (
ContainerID pk.UnsignedByte
StateID pk.VarInt
SlotData []Slot
CarriedItem Slot
)
if err := p.Scan(
&ContainerID,
&StateID,
pk.Array(&SlotData),
&CarriedItem,
); err != nil {
return Error{err}
}
m.stateID = int32(StateID)
// copy the slot data to container
container, ok := m.Screens[int(ContainerID)]
if !ok {
return Error{errors.New("setting content of non-exist container")}
}
for i, v := range SlotData {
err := container.onSetSlot(i, v)
if err != nil {
return Error{err}
}
if m.events.SetSlot != nil {
if err := m.events.SetSlot(int(ContainerID), i); err != nil {
return Error{err}
}
}
}
return nil
}
func (m *Manager) onCloseScreen(p pk.Packet) error {
var ContainerID pk.UnsignedByte
if err := p.Scan(&ContainerID); err != nil {
return Error{err}
}
if c, ok := m.Screens[int(ContainerID)]; ok {
delete(m.Screens, int(ContainerID))
if err := c.onClose(); err != nil {
return Error{err}
}
if m.events.Close != nil {
if err := m.events.Close(int(ContainerID)); err != nil {
return Error{err}
}
}
}
return nil
}
func (m *Manager) onSetSlot(p pk.Packet) (err error) {
var (
ContainerID pk.Byte
StateID pk.VarInt
SlotID pk.Short
SlotData Slot
)
if err := p.Scan(&ContainerID, &StateID, &SlotID, &SlotData); err != nil {
return Error{err}
}
m.stateID = int32(StateID)
if ContainerID == -1 && SlotID == -1 {
m.Cursor = SlotData
} else if ContainerID == -2 {
err = m.Inventory.onSetSlot(int(SlotID), SlotData)
} else if c, ok := m.Screens[int(ContainerID)]; ok {
err = c.onSetSlot(int(SlotID), SlotData)
}
if m.events.SetSlot != nil {
if err := m.events.SetSlot(int(ContainerID), int(SlotID)); err != nil {
return Error{err}
}
}
if err != nil {
return Error{err}
}
return nil
}
type Slot struct {
ID pk.VarInt
Count pk.VarInt
NBT nbt.RawMessage
}
func (s *Slot) WriteTo(w io.Writer) (n int64, err error) {
var present pk.Boolean = s != nil
return pk.Tuple{
present, pk.Opt{
Has: present,
Field: pk.Tuple{
&s.ID, &s.Count, pk.NBT(&s.NBT),
},
},
}.WriteTo(w)
}
func (s *Slot) ReadFrom(r io.Reader) (n int64, err error) {
var componentsAdd, componentsRemove pk.VarInt
return pk.Tuple{
&s.Count, pk.Opt{
Has: func() bool { return s.Count > 0 },
Field: pk.Tuple{
&s.ID,
&componentsAdd,
&componentsRemove,
// TODO: Components Ignored
},
},
}.ReadFrom(r)
}
type Container interface {
onSetSlot(i int, s Slot) error
onClose() error
}
type Error struct {
Err error
}
func (e Error) Error() string {
return "bot/screen: " + e.Err.Error()
}
func (e Error) Unwrap() error {
return e.Err
}

72
bot/world/chunks.go Normal file
View File

@ -0,0 +1,72 @@
package world
import (
"fmt"
"github.com/Tnze/go-mc/bot"
"github.com/Tnze/go-mc/bot/basic"
"github.com/Tnze/go-mc/data/packetid"
"github.com/Tnze/go-mc/level"
pk "github.com/Tnze/go-mc/net/packet"
)
type World struct {
c *bot.Client
p *basic.Player
events EventsListener
Columns map[level.ChunkPos]*level.Chunk
}
func NewWorld(c *bot.Client, p *basic.Player, events EventsListener) (w *World) {
w = &World{
c: c, p: p,
events: events,
Columns: make(map[level.ChunkPos]*level.Chunk),
}
c.Events.AddListener(
bot.PacketHandler{Priority: 64, ID: packetid.ClientboundLogin, F: w.onPlayerSpawn},
bot.PacketHandler{Priority: 64, ID: packetid.ClientboundRespawn, F: w.onPlayerSpawn},
bot.PacketHandler{Priority: 0, ID: packetid.ClientboundLevelChunkWithLight, F: w.handleLevelChunkWithLightPacket},
bot.PacketHandler{Priority: 0, ID: packetid.ClientboundForgetLevelChunk, F: w.handleForgetLevelChunkPacket},
)
return
}
func (w *World) onPlayerSpawn(pk.Packet) error {
// unload all chunks
w.Columns = make(map[level.ChunkPos]*level.Chunk)
return nil
}
func (w *World) handleLevelChunkWithLightPacket(packet pk.Packet) error {
var pos level.ChunkPos
currentDimType := w.c.Registries.DimensionType.GetByID(w.p.DimensionType)
if currentDimType == nil {
return fmt.Errorf("dimension type %d not found", w.p.DimensionType)
}
chunk := level.EmptyChunk(int(currentDimType.Height) / 16)
if err := packet.Scan(&pos, chunk); err != nil {
return err
}
w.Columns[pos] = chunk
if w.events.LoadChunk != nil {
if err := w.events.LoadChunk(pos); err != nil {
return err
}
}
return nil
}
func (w *World) handleForgetLevelChunkPacket(packet pk.Packet) error {
var pos level.ChunkPos
if err := packet.Scan(&pos); err != nil {
return err
}
var err error
if w.events.UnloadChunk != nil {
err = w.events.UnloadChunk(pos)
}
delete(w.Columns, pos)
return err
}

8
bot/world/events.go Normal file
View File

@ -0,0 +1,8 @@
package world
import "github.com/Tnze/go-mc/level"
type EventsListener struct {
LoadChunk func(pos level.ChunkPos) error
UnloadChunk func(pos level.ChunkPos) error
}