diff --git a/cmd/blockaccessor/main.go b/cmd/blockaccessor/main.go new file mode 100644 index 0000000000..36767af290 --- /dev/null +++ b/cmd/blockaccessor/main.go @@ -0,0 +1,331 @@ +package main + +import ( + "bytes" + "flag" + "go/ast" + "go/format" + "log" + "os" + "path/filepath" + "sort" + "text/template" + + "golang.org/x/tools/go/packages" +) + +// accessorSpec describes a generated getter/setter method pair. A block qualifies for a spec when it +// implements EncodeBlock and has a field named Field of the type Pkg.FieldType. +type accessorSpec struct { + // Iface is the name of the interface generated for the accessor pair, such as 'HasFacing'. + Iface string + // IfaceDoc is the doc comment written above the generated interface. + IfaceDoc string + // Method is the name of the generated setter method, such as 'WithFacing'. + Method string + // MethodDoc is the doc comment written above each generated setter method. + MethodDoc string + // Getter is the name of the generated getter method, such as 'FacingDirection'. It must differ from + // Field: a method cannot share its name with a struct field. + Getter string + // GetterDoc is the doc comment written above each generated getter method. + GetterDoc string + // Param is the name of the setter's parameter. + Param string + // ParamType is the qualified type of the setter's parameter, such as 'cube.Direction'. + ParamType string + // Field is the name of the struct field set by the setter. + Field string + // Pkg and FieldType are the package identifier and type name the field must have to qualify. + Pkg, FieldType string +} + +var specs = []accessorSpec{ + { + Iface: "HasFacing", + IfaceDoc: `HasFacing represents a block with a horizontal facing direction. Blocks that face any of the six +// block faces (such as torches, levers and hoppers) or that use an attachment do not implement it.`, + Method: "WithFacing", + MethodDoc: `WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door.`, + Getter: "FacingDirection", + GetterDoc: `FacingDirection returns the horizontal direction the block faces.`, + Param: "facing", ParamType: "cube.Direction", + Field: "Facing", Pkg: "cube", FieldType: "Direction", + }, + { + Iface: "HasAxis", + IfaceDoc: `HasAxis represents a block oriented along one of the three axes, such as logs and pillars.`, + Method: "WithAxis", + MethodDoc: `WithAxis returns a copy of the block with its axis set to axis.`, + Getter: "PillarAxis", + GetterDoc: `PillarAxis returns the axis the block is oriented along.`, + Param: "axis", ParamType: "cube.Axis", + Field: "Axis", Pkg: "cube", FieldType: "Axis", + }, + { + Iface: "HasColour", + IfaceDoc: `HasColour represents a block that comes in the sixteen dye colours, such as wool and concrete. On +// some blocks, such as beds and banners, the colour is stored as block entity data rather than in the +// encoded block state.`, + Method: "WithColour", + MethodDoc: `WithColour returns a copy of the block with its colour set to colour.`, + Getter: "DyeColour", + GetterDoc: `DyeColour returns the dye colour of the block.`, + Param: "colour", ParamType: "item.Colour", + Field: "Colour", Pkg: "item", FieldType: "Colour", + }, +} + +var fileTemplate = template.Must(template.New("accessor").Parse(`// Code generated by cmd/blockaccessor; DO NOT EDIT. + +package {{.PkgName}} + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) +{{range .Specs}} +// {{.IfaceDoc}} +type {{.Iface}} interface { + world.Block + {{.Getter}}() {{.ParamType}} + {{.Method}}({{.Param}} {{.ParamType}}) world.Block +} +{{end}}{{range .Methods}} +// {{.Spec.GetterDoc}} +func (b {{.Struct}}) {{.Spec.Getter}}() {{.Spec.ParamType}} { + return b.{{.Spec.Field}} +} + +// {{.Spec.MethodDoc}} +func (b {{.Struct}}) {{.Spec.Method}}({{.Spec.Param}} {{.Spec.ParamType}}) world.Block { + b.{{.Spec.Field}} = {{.Spec.Param}} + return b +} +{{end}}`)) + +func main() { + out := flag.String("o", "", "output file for accessor interfaces and methods") + flag.Parse() + + if len(flag.Args()) != 1 { + log.Fatalln("Must pass one package to produce accessor methods for.") + } + cfg := &packages.Config{ + Mode: packages.NeedName | packages.NeedSyntax | packages.NeedFiles, + } + pkgs, err := packages.Load(cfg, flag.Args()[0]) + if err != nil { + log.Fatalln(err) + } + if len(pkgs) != 1 { + log.Fatalln("Expected to load exactly one package.") + } + procPackage(pkgs[0], *out) +} + +func procPackage(pkg *packages.Package, out string) { + b := &accessorBuilder{ + pkg: pkg, + out: filepath.Base(out), + fields: make(map[string][]*ast.Field), + aliases: make(map[string]string), + handled: map[string]struct{}{}, + blocks: map[string]struct{}{}, + methods: map[string]struct{}{}, + } + b.readStructFields() + b.readFuncs() + buf := &bytes.Buffer{} + if err := fileTemplate.Execute(buf, struct { + PkgName string + Specs []accessorSpec + Methods []methodData + }{PkgName: pkg.Name, Specs: specs, Methods: b.resolveMethods()}); err != nil { + log.Fatalln(err) + } + src, err := format.Source(buf.Bytes()) + if err != nil { + log.Fatalln(err) + } + if err := os.WriteFile(out, src, 0644); err != nil { + log.Fatalln(err) + } +} + +// methodData holds the data needed to generate the accessor methods of one spec for one block struct. +type methodData struct { + Struct string + Spec accessorSpec +} + +type accessorBuilder struct { + pkg *packages.Package + // out is the base name of the generated file, excluded when reading the package so that previously + // generated methods are not treated as hand-written ones. + out string + fields map[string][]*ast.Field + aliases map[string]string + handled map[string]struct{} + // blocks holds the names of all structs that implement EncodeBlock with a value receiver. + blocks map[string]struct{} + // methods holds 'Struct.Method' keys for every hand-written method in the package, used to avoid + // generating an accessor that would collide with an existing method. + methods map[string]struct{} +} + +// resolveMethods returns the sorted list of accessor methods to generate, matching each spec against the +// fields of every block struct. +func (b *accessorBuilder) resolveMethods() []methodData { + var methods []methodData + for _, spec := range specs { + var names []string + for name := range b.blocks { + if !b.hasField(name, spec) { + continue + } + b.checkCollision(name, spec.Method) + b.checkCollision(name, spec.Getter) + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + methods = append(methods, methodData{Struct: name, Spec: spec}) + } + } + return methods +} + +// checkCollision exits with an error if the struct passed has a hand-written method with the name +// passed: these methods are generated exclusively, so that their behaviour cannot diverge per block. +func (b *accessorBuilder) checkCollision(structName, method string) { + if _, ok := b.methods[structName+"."+method]; ok { + log.Fatalln(structName + "." + method + " is generated by cmd/blockaccessor and must not be written by hand.") + } +} + +// hasField reports whether the struct passed has a field with the name and type required by spec. +func (b *accessorBuilder) hasField(structName string, spec accessorSpec) bool { + for _, field := range b.fields[structName] { + for _, name := range field.Names { + if name.Name != spec.Field { + continue + } + if s, ok := field.Type.(*ast.SelectorExpr); ok { + if x, ok := s.X.(*ast.Ident); ok && x.Name == spec.Pkg && s.Sel.Name == spec.FieldType { + return true + } + } + } + } + return false +} + +// syntax returns the syntax trees of the package, excluding the previously generated output file. +func (b *accessorBuilder) syntax() []*ast.File { + files := make([]*ast.File, 0, len(b.pkg.Syntax)) + for _, f := range b.pkg.Syntax { + if filepath.Base(b.pkg.Fset.Position(f.Pos()).Filename) == b.out { + continue + } + files = append(files, f) + } + return files +} + +func (b *accessorBuilder) readFuncs() { + for _, f := range b.syntax() { + ast.Inspect(f, b.readFuncDecls) + } +} + +func (b *accessorBuilder) readFuncDecls(node ast.Node) bool { + fun, ok := node.(*ast.FuncDecl) + if !ok || fun.Recv == nil { + return true + } + // Only value receivers qualify: the generated accessors copy the receiver. + ident, ok := fun.Recv.List[0].Type.(*ast.Ident) + if !ok { + return true + } + b.methods[ident.Name+"."+fun.Name.Name] = struct{}{} + if fun.Name.Name == "EncodeBlock" { + b.blocks[ident.Name] = struct{}{} + } + return true +} + +func (b *accessorBuilder) readStructFields() { + for _, f := range b.syntax() { + ast.Inspect(f, b.readStructs) + } + b.resolveEmbedded() + b.resolveAliases() +} + +func (b *accessorBuilder) resolveAliases() { + for name, alias := range b.aliases { + b.fields[name] = b.findFields(alias) + } +} + +func (b *accessorBuilder) findFields(structName string) []*ast.Field { + for { + if fields, ok := b.fields[structName]; ok { + // Alias found in the fields map, so it referred to a struct directly. + return fields + } + if nested, ok := b.aliases[structName]; ok { + // The alias itself was an alias, so continue with the next. + structName = nested + continue + } + // Neither an alias nor a struct: Break as this isn't going to go anywhere. + return nil + } +} + +func (b *accessorBuilder) resolveEmbedded() { + for name, fields := range b.fields { + if _, ok := b.handled[name]; ok { + continue + } + newFields := make([]*ast.Field, 0, len(fields)) + for _, f := range fields { + if len(f.Names) == 0 { + if ident, ok := f.Type.(*ast.Ident); ok { + for _, af := range b.findFields(ident.Name) { + if len(af.Names) == 0 { + // The struct embedded has an embedded struct of its own: Resolve that one + // first by restarting the process. + b.resolveEmbedded() + return + } + } + newFields = append(newFields, b.findFields(ident.Name)...) + } + } else { + newFields = append(newFields, f) + } + } + b.handled[name] = struct{}{} + b.fields[name] = newFields + } +} + +func (b *accessorBuilder) readStructs(node ast.Node) bool { + s, ok := node.(*ast.TypeSpec) + if !ok { + return true + } + switch t := s.Type.(type) { + case *ast.StructType: + b.fields[s.Name.Name] = t.Fields.List + case *ast.Ident: + b.aliases[s.Name.Name] = t.Name + } + return true +} diff --git a/cmd/blockhash/main.go b/cmd/blockhash/main.go index 4f8910fc95..94a40cbf5a 100644 --- a/cmd/blockhash/main.go +++ b/cmd/blockhash/main.go @@ -244,6 +244,8 @@ func (b *hashBuilder) ftype(structName, s string, expr ast.Expr, directives map[ return "uint64(" + s + ".FaceUint8())", 3 } return "uint64(" + s + ".Uint8())", 5 + case "OptionalColour": + return "uint64(" + s + ".Uint8())", 5 case "GrindstoneAttachment": return "uint64(" + s + ".Uint8())", 2 case "WoodType", "LeavesType", "FlowerType", "DoubleFlowerType", "Colour", "MushroomType", "SeagrassType": @@ -256,6 +258,8 @@ func (b *hashBuilder) ftype(structName, s string, expr ast.Expr, directives map[ return "uint64(" + s + ".Uint8())", 2 case "OreType", "FireType", "DoubleTallGrassType": return "uint64(" + s + ".Uint8())", 1 + case "BambooLeafSize": + return "uint64(" + s + ".Uint8())", 2 case "Direction", "Axis": return "uint64(" + s + ")", 2 case "Face": diff --git a/go.mod b/go.mod index da45a453f3..7c54a00ad3 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/go-gl/mathgl v1.2.0 github.com/google/uuid v1.6.0 github.com/pelletier/go-toml v1.9.5 - github.com/sandertv/gophertunnel v1.57.0 + github.com/sandertv/gophertunnel v1.59.0 github.com/segmentio/fasthash v1.0.3 github.com/thomaso-mirodin/intmath v0.0.0-20160323211736-5dc6d854e46e github.com/zaataylor/cartesian v0.0.0-20221028053253-3b3244d82727 @@ -20,15 +20,41 @@ require ( ) require ( + github.com/coder/websocket v1.8.14 // indirect + github.com/coreos/go-oidc/v3 v3.17.0 // indirect + github.com/df-mc/go-nethernet v1.0.20 // indirect + github.com/df-mc/go-playfab/v2 v2.0.2 // indirect + github.com/df-mc/go-xsapi/v2 v2.0.3 // indirect github.com/df-mc/jsonc v1.0.5 // indirect - github.com/go-jose/go-jose/v4 v4.1.3 // indirect - github.com/golang/snappy v1.0.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/golang/snappy v0.0.4 // indirect github.com/klauspost/compress v1.18.4 // indirect - github.com/sandertv/go-raknet v1.15.1-0.20260112202637-beca0b10c217 // indirect - golang.org/x/mod v0.24.0 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/oauth2 v0.29.0 // indirect - golang.org/x/sync v0.13.0 // indirect + github.com/nxadm/tail v1.4.11 // indirect + github.com/pion/datachannel v1.6.2 // indirect + github.com/pion/dtls/v3 v3.1.4 // indirect + github.com/pion/ice/v4 v4.2.7 // indirect + github.com/pion/interceptor v0.1.45 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/mdns/v2 v2.1.0 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.16 // indirect + github.com/pion/rtp v1.10.2 // indirect + github.com/pion/sctp v1.10.2 // indirect + github.com/pion/sdp/v3 v3.0.19 // indirect + github.com/pion/srtp/v3 v3.0.12 // indirect + github.com/pion/stun/v3 v3.1.6 // indirect + github.com/pion/transport/v4 v4.0.2 // indirect + github.com/pion/turn/v5 v5.0.10 // indirect + github.com/pion/webrtc/v4 v4.2.16-0.20260627075746-7a223a6f4d4f // indirect + github.com/sandertv/go-raknet v1.15.2-0.20260705184311-0d1fd09e2cf6 // indirect + github.com/wlynxg/anet v0.0.5 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/time v0.10.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 73a9b99773..b86e309292 100644 --- a/go.sum +++ b/go.sum @@ -2,10 +2,18 @@ github.com/brentp/intintmap v0.0.0-20251106190759-56907b1f8479 h1:UZbbt19ACBOFO+ github.com/brentp/intintmap v0.0.0-20251106190759-56907b1f8479/go.mod h1:TOk10ahXejq9wkEaym3KPRNeuR/h5Jx+s8QRWIa2oTM= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cooldogedev/gophertunnel v0.0.0-20251216175446-7b35fac7a626 h1:eCA3OnjCPklA+Db+ApkBGiCLsGjOVgfX2U+S+NeHxCg= -github.com/cooldogedev/gophertunnel v0.0.0-20251216175446-7b35fac7a626/go.mod h1:IhLg93aMPY/rKgB7lKxTzOZ6i11OgkL0WinVs+gMvlI= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/df-mc/go-nethernet v1.0.20 h1:H0MO0GkVlAypu9IDQhzg5Pph3mCD7r40+ExiImsXT2M= +github.com/df-mc/go-nethernet v1.0.20/go.mod h1:11MfQo5F7PDKfHg1H0zM5mMkrCv29jRE0lK5B4egW9w= +github.com/df-mc/go-playfab/v2 v2.0.2 h1:JqOsrpe0nt6Ni4V2JrdmOAhbBgTm7LBw1ppGXlmknhs= +github.com/df-mc/go-playfab/v2 v2.0.2/go.mod h1:CS3FV3tJlohvGQiX0IJiaYKOVNfy0p778B84oEVyxlw= +github.com/df-mc/go-xsapi/v2 v2.0.3 h1:fQLw4k15OinFhagWjbSj0BwQXeDUmY8a1J8yquEod3w= +github.com/df-mc/go-xsapi/v2 v2.0.3/go.mod h1:Gi/zQG2DFMJOMt4DIjuBuINTnU4YnZINe+RyFyza8oo= github.com/df-mc/goleveldb v1.1.9 h1:ihdosZyy5jkQKrxucTQmN90jq/2lUwQnJZjIYIC/9YU= github.com/df-mc/goleveldb v1.1.9/go.mod h1:+NHCup03Sci5q84APIA21z3iPZCuk6m6ABtg4nANCSk= github.com/df-mc/jsonc v1.0.5 h1:O7oh07kbS5AYY+l2Fji6l4h0iHcdjKbxCtK5VlZlLMU= @@ -13,73 +21,114 @@ github.com/df-mc/jsonc v1.0.5/go.mod h1:+Q++JuCE9IKiP8v7sWImdf/RjQX0nfXyfX6PdfTT github.com/df-mc/worldupgrader v1.0.21 h1:Qr4/QB8ek7En0vkTuRXYq4FrZM0HHSOXsJOL7Ko4Cjg= github.com/df-mc/worldupgrader v1.0.21/go.mod h1:tsSOLTRm9mpG7VHvYpAjjZrkRHWmSbKZAm9bOLNnlDk= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/go-gl/mathgl v1.2.0 h1:v2eOj/y1B2afDxF6URV1qCYmo1KW08lAMtTbOn3KXCY= github.com/go-gl/mathgl v1.2.0/go.mod h1:pf9+b5J3LFP7iZ4XXaVzZrCle0Q/vNpB/vDe5+3ulRE= -github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= -github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= -github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= +github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.17.0 h1:9Luw4uT5HTjHTN8+aNcSThgH1vdXnmdJ8xIfZ4wyTRE= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc= +github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E= +github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY= +github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc= +github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao= +github.com/pion/ice/v4 v4.2.7/go.mod h1:9SNPaq0c7El/ki8leJzyCkK10zsskprR3zTNbO3monY= +github.com/pion/interceptor v0.1.45 h1:6PUo/5829bIfRFIPPJQzuDn8EjxRTSB/CSD7QVCOaqo= +github.com/pion/interceptor v0.1.45/go.mod h1:gNDYM/uFKcLe/B3gS2/7+aw6z+RDiMy2qKTnF1LO31w= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= +github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= +github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= +github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo= +github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= +github.com/pion/sctp v1.10.2 h1:6aezYsMrHAwpjJ6kUdyCiWPqZwgToT00ponT7seJ6a4= +github.com/pion/sctp v1.10.2/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0= +github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ= +github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU= +github.com/pion/srtp/v3 v3.0.12 h1:U7V17bckl7sI4mb3sepiojByDuBY0wNCqQE+6IlQBbc= +github.com/pion/srtp/v3 v3.0.12/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns= +github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8= +github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs= +github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= +github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= +github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= +github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= +github.com/pion/turn/v5 v5.0.10 h1:mOMZjudflXpte5OsCnXztpUKwNXcpXIAzMBnq9TXOSQ= +github.com/pion/turn/v5 v5.0.10/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E= +github.com/pion/webrtc/v4 v4.2.16-0.20260627075746-7a223a6f4d4f h1:m39L6AcxVDq1Gwm9/c6QGCiksJ9xoYE4TQyebl9pMu4= +github.com/pion/webrtc/v4 v4.2.16-0.20260627075746-7a223a6f4d4f/go.mod h1:g/C+nTxS7qM2dBr1hRK56OTTD9zFzQDwZi3fMfZxFNM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/sandertv/go-raknet v1.15.1-0.20260112202637-beca0b10c217 h1:UZQq2253Q+7co/C9Et62RYPBggzz+L+2yqGlvQhSNM8= -github.com/sandertv/go-raknet v1.15.1-0.20260112202637-beca0b10c217/go.mod h1:/yysjwfCXm2+2OY8mBazLzcxJ3irnylKCyG3FLgUPVU= -github.com/sandertv/gophertunnel v1.57.0 h1:UkgVg1xLCsOSm79rP09WmodGSHgA8M7+l4quL01cIL8= -github.com/sandertv/gophertunnel v1.57.0/go.mod h1:W4VnrX9AIPIVXNDMEIKMIRj1T80EdOgdqXpGbQpyAbE= +github.com/sandertv/go-raknet v1.15.2-0.20260705184311-0d1fd09e2cf6 h1:Oj7QsiwWTOLmCpOjjmW0Qwde4BE8F5+Kjz817qzICyk= +github.com/sandertv/go-raknet v1.15.2-0.20260705184311-0d1fd09e2cf6/go.mod h1:/yysjwfCXm2+2OY8mBazLzcxJ3irnylKCyG3FLgUPVU= +github.com/sandertv/gophertunnel v1.59.0 h1:hIjmLycavwSjrqRlKFDMiV8LrP64tgzjgEUqWOOxvuQ= +github.com/sandertv/gophertunnel v1.59.0/go.mod h1:JMKsf8DmVmn+qEVJ1IA9m7zEo9qHJ3ZN6IEvySEk9bs= github.com/segmentio/fasthash v1.0.3 h1:EI9+KE1EwvMLBWwjpRDc+fEM+prwxDYbslddQGtrmhM= github.com/segmentio/fasthash v1.0.3/go.mod h1:waKX8l2N8yckOgmSsXJi7x1ZfdKZ4x7KRMzBtS3oedY= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/thomaso-mirodin/intmath v0.0.0-20160323211736-5dc6d854e46e h1:cR8/SYRgyQCt5cNCMniB/ZScMkhI9nk8U5C7SbISXjo= -github.com/thomaso-mirodin/intmath v0.0.0-20160323211736-5dc6d854e46e/go.mod h1:Tu4lItkATkonrYuvtVjG0/rhy15qrNGNTjPdaphtZ/8= -github.com/zaataylor/cartesian v0.0.0-20221028053253-3b3244d82727 h1:eP/MDnwQtFR2hwUv84a5kD5bKgjx3wir69Nhy33Pl4I= -github.com/zaataylor/cartesian v0.0.0-20221028053253-3b3244d82727/go.mod h1:gEXybXQbaNi8PEDSaJ/a9AEG8tiqtnEaPtKFugCAjwU= -golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= -golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 h1:9kj3STMvgqy3YA4VQXBrN7925ICMxD5wzMRcgA30588= +golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= -golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= -golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= -golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/block/accessor.go b/server/block/accessor.go new file mode 100644 index 0000000000..10065e51ff --- /dev/null +++ b/server/block/accessor.go @@ -0,0 +1,632 @@ +// Code generated by cmd/blockaccessor; DO NOT EDIT. + +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// HasFacing represents a block with a horizontal facing direction. Blocks that face any of the six +// block faces (such as torches, levers and hoppers) or that use an attachment do not implement it. +type HasFacing interface { + world.Block + FacingDirection() cube.Direction + WithFacing(facing cube.Direction) world.Block +} + +// HasAxis represents a block oriented along one of the three axes, such as logs and pillars. +type HasAxis interface { + world.Block + PillarAxis() cube.Axis + WithAxis(axis cube.Axis) world.Block +} + +// HasColour represents a block that comes in the sixteen dye colours, such as wool and concrete. On +// some blocks, such as beds and banners, the colour is stored as block entity data rather than in the +// encoded block state. +type HasColour interface { + world.Block + DyeColour() item.Colour + WithColour(colour item.Colour) world.Block +} + +// FacingDirection returns the horizontal direction the block faces. +func (b Anvil) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b Anvil) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b Bed) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b Bed) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b BlastFurnace) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b BlastFurnace) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b Campfire) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b Campfire) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b Chest) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b Chest) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b CocoaBean) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b CocoaBean) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b CopperDoor) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b CopperDoor) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b CopperGolemStatue) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b CopperGolemStatue) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b CopperTrapdoor) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b CopperTrapdoor) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b DecoratedPot) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b DecoratedPot) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b EndPortalFrame) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b EndPortalFrame) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b EnderChest) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b EnderChest) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b Furnace) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b Furnace) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b GlazedTerracotta) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b GlazedTerracotta) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b Grindstone) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b Grindstone) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b Ladder) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b Ladder) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b Lectern) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b Lectern) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b LitPumpkin) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b LitPumpkin) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b Loom) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b Loom) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b PinkPetals) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b PinkPetals) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b Pumpkin) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b Pumpkin) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b Smoker) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b Smoker) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b Stairs) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b Stairs) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b Stonecutter) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b Stonecutter) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b WoodDoor) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b WoodDoor) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b WoodFenceGate) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b WoodFenceGate) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// FacingDirection returns the horizontal direction the block faces. +func (b WoodTrapdoor) FacingDirection() cube.Direction { + return b.Facing +} + +// WithFacing returns a copy of the block with its facing set to facing. It does not update any +// other blocks that the block may be part of, such as the second half of a bed or door. +func (b WoodTrapdoor) WithFacing(facing cube.Direction) world.Block { + b.Facing = facing + return b +} + +// PillarAxis returns the axis the block is oriented along. +func (b BambooBlock) PillarAxis() cube.Axis { + return b.Axis +} + +// WithAxis returns a copy of the block with its axis set to axis. +func (b BambooBlock) WithAxis(axis cube.Axis) world.Block { + b.Axis = axis + return b +} + +// PillarAxis returns the axis the block is oriented along. +func (b Basalt) PillarAxis() cube.Axis { + return b.Axis +} + +// WithAxis returns a copy of the block with its axis set to axis. +func (b Basalt) WithAxis(axis cube.Axis) world.Block { + b.Axis = axis + return b +} + +// PillarAxis returns the axis the block is oriented along. +func (b Bone) PillarAxis() cube.Axis { + return b.Axis +} + +// WithAxis returns a copy of the block with its axis set to axis. +func (b Bone) WithAxis(axis cube.Axis) world.Block { + b.Axis = axis + return b +} + +// PillarAxis returns the axis the block is oriented along. +func (b CopperChain) PillarAxis() cube.Axis { + return b.Axis +} + +// WithAxis returns a copy of the block with its axis set to axis. +func (b CopperChain) WithAxis(axis cube.Axis) world.Block { + b.Axis = axis + return b +} + +// PillarAxis returns the axis the block is oriented along. +func (b Deepslate) PillarAxis() cube.Axis { + return b.Axis +} + +// WithAxis returns a copy of the block with its axis set to axis. +func (b Deepslate) WithAxis(axis cube.Axis) world.Block { + b.Axis = axis + return b +} + +// PillarAxis returns the axis the block is oriented along. +func (b Froglight) PillarAxis() cube.Axis { + return b.Axis +} + +// WithAxis returns a copy of the block with its axis set to axis. +func (b Froglight) WithAxis(axis cube.Axis) world.Block { + b.Axis = axis + return b +} + +// PillarAxis returns the axis the block is oriented along. +func (b HayBale) PillarAxis() cube.Axis { + return b.Axis +} + +// WithAxis returns a copy of the block with its axis set to axis. +func (b HayBale) WithAxis(axis cube.Axis) world.Block { + b.Axis = axis + return b +} + +// PillarAxis returns the axis the block is oriented along. +func (b InfestedDeepslate) PillarAxis() cube.Axis { + return b.Axis +} + +// WithAxis returns a copy of the block with its axis set to axis. +func (b InfestedDeepslate) WithAxis(axis cube.Axis) world.Block { + b.Axis = axis + return b +} + +// PillarAxis returns the axis the block is oriented along. +func (b IronChain) PillarAxis() cube.Axis { + return b.Axis +} + +// WithAxis returns a copy of the block with its axis set to axis. +func (b IronChain) WithAxis(axis cube.Axis) world.Block { + b.Axis = axis + return b +} + +// PillarAxis returns the axis the block is oriented along. +func (b Log) PillarAxis() cube.Axis { + return b.Axis +} + +// WithAxis returns a copy of the block with its axis set to axis. +func (b Log) WithAxis(axis cube.Axis) world.Block { + b.Axis = axis + return b +} + +// PillarAxis returns the axis the block is oriented along. +func (b MuddyMangroveRoots) PillarAxis() cube.Axis { + return b.Axis +} + +// WithAxis returns a copy of the block with its axis set to axis. +func (b MuddyMangroveRoots) WithAxis(axis cube.Axis) world.Block { + b.Axis = axis + return b +} + +// PillarAxis returns the axis the block is oriented along. +func (b Portal) PillarAxis() cube.Axis { + return b.Axis +} + +// WithAxis returns a copy of the block with its axis set to axis. +func (b Portal) WithAxis(axis cube.Axis) world.Block { + b.Axis = axis + return b +} + +// PillarAxis returns the axis the block is oriented along. +func (b PurpurPillar) PillarAxis() cube.Axis { + return b.Axis +} + +// WithAxis returns a copy of the block with its axis set to axis. +func (b PurpurPillar) WithAxis(axis cube.Axis) world.Block { + b.Axis = axis + return b +} + +// PillarAxis returns the axis the block is oriented along. +func (b QuartzPillar) PillarAxis() cube.Axis { + return b.Axis +} + +// WithAxis returns a copy of the block with its axis set to axis. +func (b QuartzPillar) WithAxis(axis cube.Axis) world.Block { + b.Axis = axis + return b +} + +// PillarAxis returns the axis the block is oriented along. +func (b Wood) PillarAxis() cube.Axis { + return b.Axis +} + +// WithAxis returns a copy of the block with its axis set to axis. +func (b Wood) WithAxis(axis cube.Axis) world.Block { + b.Axis = axis + return b +} + +// DyeColour returns the dye colour of the block. +func (b Banner) DyeColour() item.Colour { + return b.Colour +} + +// WithColour returns a copy of the block with its colour set to colour. +func (b Banner) WithColour(colour item.Colour) world.Block { + b.Colour = colour + return b +} + +// DyeColour returns the dye colour of the block. +func (b Bed) DyeColour() item.Colour { + return b.Colour +} + +// WithColour returns a copy of the block with its colour set to colour. +func (b Bed) WithColour(colour item.Colour) world.Block { + b.Colour = colour + return b +} + +// DyeColour returns the dye colour of the block. +func (b Carpet) DyeColour() item.Colour { + return b.Colour +} + +// WithColour returns a copy of the block with its colour set to colour. +func (b Carpet) WithColour(colour item.Colour) world.Block { + b.Colour = colour + return b +} + +// DyeColour returns the dye colour of the block. +func (b Concrete) DyeColour() item.Colour { + return b.Colour +} + +// WithColour returns a copy of the block with its colour set to colour. +func (b Concrete) WithColour(colour item.Colour) world.Block { + b.Colour = colour + return b +} + +// DyeColour returns the dye colour of the block. +func (b ConcretePowder) DyeColour() item.Colour { + return b.Colour +} + +// WithColour returns a copy of the block with its colour set to colour. +func (b ConcretePowder) WithColour(colour item.Colour) world.Block { + b.Colour = colour + return b +} + +// DyeColour returns the dye colour of the block. +func (b GlazedTerracotta) DyeColour() item.Colour { + return b.Colour +} + +// WithColour returns a copy of the block with its colour set to colour. +func (b GlazedTerracotta) WithColour(colour item.Colour) world.Block { + b.Colour = colour + return b +} + +// DyeColour returns the dye colour of the block. +func (b StainedGlass) DyeColour() item.Colour { + return b.Colour +} + +// WithColour returns a copy of the block with its colour set to colour. +func (b StainedGlass) WithColour(colour item.Colour) world.Block { + b.Colour = colour + return b +} + +// DyeColour returns the dye colour of the block. +func (b StainedGlassPane) DyeColour() item.Colour { + return b.Colour +} + +// WithColour returns a copy of the block with its colour set to colour. +func (b StainedGlassPane) WithColour(colour item.Colour) world.Block { + b.Colour = colour + return b +} + +// DyeColour returns the dye colour of the block. +func (b StainedTerracotta) DyeColour() item.Colour { + return b.Colour +} + +// WithColour returns a copy of the block with its colour set to colour. +func (b StainedTerracotta) WithColour(colour item.Colour) world.Block { + b.Colour = colour + return b +} + +// DyeColour returns the dye colour of the block. +func (b Wool) DyeColour() item.Colour { + return b.Colour +} + +// WithColour returns a copy of the block with its colour set to colour. +func (b Wool) WithColour(colour item.Colour) world.Block { + b.Colour = colour + return b +} diff --git a/server/block/air.go b/server/block/air.go index 895946b6d5..e1840a2942 100644 --- a/server/block/air.go +++ b/server/block/air.go @@ -1,5 +1,7 @@ package block +import "github.com/df-mc/dragonfly/server/world" + // Air is the block present in otherwise empty space. type Air struct { empty @@ -12,6 +14,11 @@ func (Air) HasLiquidDrops() bool { return false } +// PortalInterior returns true if air may occupy the inside of a portal frame before activation for the target dimension. +func (Air) PortalInterior(target world.Dimension) bool { + return target == world.Nether +} + // EncodeItem ... func (Air) EncodeItem() (name string, meta int16) { return "minecraft:air", 0 diff --git a/server/block/ancient_debris.go b/server/block/ancient_debris.go index 129c239334..0bd305d346 100644 --- a/server/block/ancient_debris.go +++ b/server/block/ancient_debris.go @@ -13,7 +13,7 @@ type AncientDebris struct { func (a AncientDebris) BreakInfo() BreakInfo { return newBreakInfo(30, func(t item.Tool) bool { return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierDiamond.HarvestLevel - }, pickaxeEffective, oneOf(a)).withBlastResistance(6000) + }, pickaxeEffective, oneOf(a)).withBlastResistance(1200) } // SmeltInfo ... diff --git a/server/block/anvil.go b/server/block/anvil.go index a4e5f043a9..3650ee9d9d 100644 --- a/server/block/anvil.go +++ b/server/block/anvil.go @@ -27,7 +27,7 @@ func (a Anvil) Model() world.BlockModel { // BreakInfo ... func (a Anvil) BreakInfo() BreakInfo { - return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(a)).withBlastResistance(6000) + return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(a)).withBlastResistance(1200) } // Activate ... diff --git a/server/block/bamboo.go b/server/block/bamboo.go new file mode 100644 index 0000000000..c3090e88fb --- /dev/null +++ b/server/block/bamboo.go @@ -0,0 +1,202 @@ +package block + +import ( + "math" + "math/rand/v2" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/block/model" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// Bamboo is a versatile, fast-growing plant found primarily in jungles. +type Bamboo struct { + transparent + bass + + Ready bool + Thick bool + LeafSize BambooLeafSize +} + +var ( + _ item.BoneMealAffected = Bamboo{} + _ Flammable = Bamboo{} +) + +// FuelInfo ... +func (b Bamboo) FuelInfo() item.FuelInfo { + return newFuelInfo(time.Millisecond * 2500) +} + +// EncodeItem ... +func (b Bamboo) EncodeItem() (name string, meta int16) { + return "minecraft:bamboo", 0 +} + +// BoneMeal ... +func (b Bamboo) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { + top := b.top(pos, tx) + if tx.Block(top).(Bamboo).grow(top, rand.IntN(2)+1, b.maxHeight(top), tx) { + return item.BoneMealResultSmall + } + return item.BoneMealResultNone +} + +// FlammabilityInfo ... +func (b Bamboo) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(60, 60, true) +} + +// BreakInfo ... +func (b Bamboo) BreakInfo() BreakInfo { + return newBreakInfo(1, alwaysHarvestable, axeEffective, oneOf(b)) +} + +// EncodeBlock ... +func (b Bamboo) EncodeBlock() (string, map[string]any) { + thickness := "thin" + if b.Thick { + thickness = "thick" + } + return "minecraft:bamboo", map[string]any{ + "age_bit": boolByte(b.Ready), + "bamboo_leaf_size": b.LeafSize.String(), + "bamboo_stalk_thickness": thickness, + } +} + +// Model ... +func (b Bamboo) Model() world.BlockModel { + return model.Bamboo{Thick: b.Thick} +} + +// RandomTick ... +func (b Bamboo) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + if tx.Light(pos) >= 9 && r.IntN(3) == 0 { + b.grow(pos, 1, b.maxHeight(pos), tx) + } +} + +// NeighbourUpdateTick ... +func (b Bamboo) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + down := tx.Block(pos.Side(cube.FaceDown)) + switch down.(type) { + case BambooSapling, Bamboo: + return + } + if supportsVegetation(b, down) { + return + } + breakBlock(b, pos, tx) +} + +// UseOnBlock ... +func (b Bamboo) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + if face == cube.FaceUp { + switch x := tx.Block(pos).(type) { + case Bamboo: + if x.top(pos, tx) != pos { + return false + } + return b.grow(pos, 1, math.MaxInt, tx) + case BambooSapling: + return x.grow(pos, tx) + default: + } + } + + pos, _, used := firstReplaceable(tx, pos, face, b) + if !used { + return false + } + s := BambooSapling{} + if !supportsVegetation(s, tx.Block(pos.Sub(cube.Pos{0, 1}))) { + return false + } + place(tx, pos, s, user, ctx) + return placed(ctx) +} + +// maxHeight returns the maximum height, between 12 and 16, of the bamboo +// stalk at the position passed. +func (b Bamboo) maxHeight(pos cube.Pos) int { + seed := 3129871*uint32(pos.X()) ^ 116129781*uint32(pos.Z()) + seed *= 42317861*seed + 11 + return 12 + int(seed>>24)%5 +} + +// top ... +func (b Bamboo) top(pos cube.Pos, tx *world.Tx) (top cube.Pos) { + top = pos + for { + up := top.Side(cube.FaceUp) + if _, ok := tx.Block(up).(Bamboo); !ok { + return top + } + top = up + } +} + +// grow ... +func (b Bamboo) grow(pos cube.Pos, amount int, maxHeight int, tx *world.Tx) bool { + if !replaceableWith(tx, pos.Side(cube.FaceUp), b) { + return false + } + + height := 1 + for { + if _, ok := tx.Block(pos.Sub(cube.Pos{0, height})).(Bamboo); !ok { + break + } + height++ + if height >= maxHeight { + return false + } + } + + for i, block := range b.growthLayout(height+amount, amount) { + tx.SetBlock(pos.Sub(cube.Pos{0, i - amount}), block, nil) + } + + return true +} + +// growthLayout returns the new top blocks of a stalk grown by amount blocks +// to newHeight, ordered top to bottom. +func (b Bamboo) growthLayout(newHeight, amount int) []world.Block { + stemBlock := Bamboo{Thick: b.Thick || newHeight >= 4} + smallLeavesBlock := Bamboo{Thick: stemBlock.Thick, LeafSize: BambooSizeSmallLeaves()} + bigLeavesBlock := Bamboo{Thick: stemBlock.Thick, LeafSize: BambooSizeLargeLeaves()} + + switch { + case newHeight == 2: + return []world.Block{smallLeavesBlock} + case newHeight == 3: + return []world.Block{smallLeavesBlock, smallLeavesBlock} + case newHeight == 4: + return []world.Block{bigLeavesBlock, smallLeavesBlock, stemBlock, stemBlock} + case newHeight > 4: + newBlocks := []world.Block{bigLeavesBlock, bigLeavesBlock, smallLeavesBlock} + for i, mx := 0, min(amount, newHeight-len(newBlocks)); i < mx; i++ { + newBlocks = append(newBlocks, stemBlock) + } + return newBlocks + } + return nil +} + +// allBamboos ... +func allBamboos() (bamboos []world.Block) { + for _, thick := range []bool{false, true} { + for _, ready := range []bool{false, true} { + for _, leafSize := range BambooLeafSizes() { + bamboos = append(bamboos, Bamboo{Thick: thick, Ready: ready, LeafSize: leafSize}) + } + } + } + return +} diff --git a/server/block/bamboo_block.go b/server/block/bamboo_block.go new file mode 100644 index 0000000000..c03a1a03ff --- /dev/null +++ b/server/block/bamboo_block.go @@ -0,0 +1,78 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" + "time" +) + +// BambooBlock is a rotatable flammable block made from bamboo. +type BambooBlock struct { + solid + bass + + // Axis is the axis which the bamboo block faces. + Axis cube.Axis + // Stripped specifies if the bamboo block is stripped. + Stripped bool +} + +// FlammabilityInfo ... +func (BambooBlock) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(5, 5, true) +} + +// BreakInfo ... +func (b BambooBlock) BreakInfo() BreakInfo { + return newBreakInfo(2.0, alwaysHarvestable, axeEffective, oneOf(b)) +} + +// FuelInfo ... +func (BambooBlock) FuelInfo() item.FuelInfo { + return newFuelInfo(time.Second * 15) +} + +// UseOnBlock ... +func (b BambooBlock) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, face, used = firstReplaceable(tx, pos, face, b) + if !used { + return + } + b.Axis = face.Axis() + + place(tx, pos, b, user, ctx) + return placed(ctx) +} + +// Strip ... +func (b BambooBlock) Strip() (world.Block, world.Sound, bool) { + return BambooBlock{Axis: b.Axis, Stripped: true}, nil, !b.Stripped +} + +// EncodeItem ... +func (b BambooBlock) EncodeItem() (name string, meta int16) { + if b.Stripped { + return "minecraft:stripped_bamboo_block", 0 + } + return "minecraft:bamboo_block", 0 +} + +// EncodeBlock ... +func (b BambooBlock) EncodeBlock() (name string, properties map[string]any) { + meta := map[string]any{"pillar_axis": b.Axis.String()} + if b.Stripped { + return "minecraft:stripped_bamboo_block", meta + } + return "minecraft:bamboo_block", meta +} + +// allBambooBlocks ... +func allBambooBlocks() (blocks []world.Block) { + for _, axis := range cube.Axes() { + blocks = append(blocks, BambooBlock{Axis: axis}) + blocks = append(blocks, BambooBlock{Axis: axis, Stripped: true}) + } + return +} diff --git a/server/block/bamboo_leaf_size.go b/server/block/bamboo_leaf_size.go new file mode 100644 index 0000000000..1796e4961c --- /dev/null +++ b/server/block/bamboo_leaf_size.go @@ -0,0 +1,59 @@ +package block + +// BambooLeafSize represents the size of bamboo leaves. +type BambooLeafSize struct { + bamboo +} + +type bamboo uint8 + +// BambooSizeNoLeaves ... +func BambooSizeNoLeaves() BambooLeafSize { + return BambooLeafSize{0} +} + +// BambooSizeSmallLeaves ... +func BambooSizeSmallLeaves() BambooLeafSize { + return BambooLeafSize{1} +} + +// BambooSizeLargeLeaves ... +func BambooSizeLargeLeaves() BambooLeafSize { + return BambooLeafSize{2} +} + +// Uint8 ... +func (b bamboo) Uint8() uint8 { + return uint8(b) +} + +// String ... +func (b bamboo) String() string { + switch b { + case 0: + return "no_leaves" + case 1: + return "small_leaves" + case 2: + return "large_leaves" + } + panic("unknown bamboo leaf size") +} + +// Name ... +func (b bamboo) Name() string { + switch b { + case 0: + return "No Leaves" + case 1: + return "Small Leaves" + case 2: + return "Large Leaves" + } + panic("unknown bamboo leaf size") +} + +// BambooLeafSizes returns all possible bamboo leaf sizes. +func BambooLeafSizes() []BambooLeafSize { + return []BambooLeafSize{BambooSizeNoLeaves(), BambooSizeSmallLeaves(), BambooSizeLargeLeaves()} +} diff --git a/server/block/bamboo_mosaic.go b/server/block/bamboo_mosaic.go new file mode 100644 index 0000000000..7cc9caa5a4 --- /dev/null +++ b/server/block/bamboo_mosaic.go @@ -0,0 +1,42 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" + "time" +) + +// BambooMosaic is a decorative bamboo plank variant. +type BambooMosaic struct { + solid + bass +} + +// FlammabilityInfo ... +func (BambooMosaic) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(5, 20, true) +} + +// BreakInfo ... +func (b BambooMosaic) BreakInfo() BreakInfo { + return newBreakInfo(2, alwaysHarvestable, axeEffective, oneOf(b)).withBlastResistance(3) +} + +// RepairsWoodTools ... +func (BambooMosaic) RepairsWoodTools() bool { + return true +} + +// FuelInfo ... +func (BambooMosaic) FuelInfo() item.FuelInfo { + return newFuelInfo(time.Second * 15) +} + +// EncodeItem ... +func (BambooMosaic) EncodeItem() (name string, meta int16) { + return "minecraft:bamboo_mosaic", 0 +} + +// EncodeBlock ... +func (BambooMosaic) EncodeBlock() (string, map[string]any) { + return "minecraft:bamboo_mosaic", nil +} diff --git a/server/block/bamboo_sapling.go b/server/block/bamboo_sapling.go new file mode 100644 index 0000000000..5687206f34 --- /dev/null +++ b/server/block/bamboo_sapling.go @@ -0,0 +1,85 @@ +package block + +import ( + "math/rand/v2" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" +) + +// BambooSapling ... +type BambooSapling struct { + empty + transparent + bass + + Ready bool +} + +var ( + _ item.BoneMealAffected = BambooSapling{} + _ Flammable = BambooSapling{} +) + +// BoneMeal ... +func (b BambooSapling) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { + if b.grow(pos, tx) { + return item.BoneMealResultSmall + } + return item.BoneMealResultNone +} + +// FlammabilityInfo ... +func (b BambooSapling) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(60, 60, true) +} + +// NeighbourUpdateTick ... +func (b BambooSapling) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + down := tx.Block(pos.Side(cube.FaceDown)) + if supportsVegetation(b, down) { + return + } + breakBlock(b, pos, tx) +} + +// RandomTick ... +func (b BambooSapling) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + if tx.Light(pos) >= 9 && r.IntN(3) == 0 { + b.grow(pos, tx) + } +} + +// BreakInfo ... +func (b BambooSapling) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, axeEffective, oneOf(Bamboo{})).withBlastResistance(1) +} + +// HasLiquidDrops ... +func (b BambooSapling) HasLiquidDrops() bool { + return true +} + +// EncodeBlock ... +func (b BambooSapling) EncodeBlock() (string, map[string]any) { + return "minecraft:bamboo_sapling", map[string]any{"age_bit": boolByte(b.Ready)} +} + +// grow ... +func (b BambooSapling) grow(pos cube.Pos, tx *world.Tx) bool { + if !replaceableWith(tx, pos.Side(cube.FaceUp), b) { + return false + } + + tx.SetBlock(pos, Bamboo{}, nil) + tx.SetBlock(pos.Side(cube.FaceUp), Bamboo{LeafSize: BambooSizeSmallLeaves()}, nil) + return true +} + +// allBambooSaplings ... +func allBambooSaplings() (saplings []world.Block) { + saplings = append(saplings, BambooSapling{Ready: false}) + saplings = append(saplings, BambooSapling{Ready: true}) + return +} diff --git a/server/block/banner_pattern_layer.go b/server/block/banner_pattern_layer.go index 3fef11f136..c47d509d51 100644 --- a/server/block/banner_pattern_layer.go +++ b/server/block/banner_pattern_layer.go @@ -1,6 +1,8 @@ package block import ( + "fmt" + "github.com/df-mc/dragonfly/server/internal/nbtconv" "github.com/df-mc/dragonfly/server/item" ) @@ -23,7 +25,12 @@ func (b BannerPatternLayer) EncodeNBT() map[string]any { // DecodeNBT decodes the given NBT map into a BannerPatternLayer and returns it. func (b BannerPatternLayer) DecodeNBT(data map[string]any) any { - b.Type = BannerPatternByID(nbtconv.String(data, "Pattern")) + id := nbtconv.String(data, "Pattern") + pattern, exists := BannerPatternByID(id) + if !exists { + panic(fmt.Errorf("unknown banner pattern id %q", id)) + } + b.Type = pattern b.Colour = invertColourID(int16(nbtconv.Int32(data, "Color"))) return b } diff --git a/server/block/banner_pattern_type_register.go b/server/block/banner_pattern_type_register.go index f6a6d90140..b906b81edc 100644 --- a/server/block/banner_pattern_type_register.go +++ b/server/block/banner_pattern_type_register.go @@ -57,13 +57,11 @@ func registerBannerPattern(id string, pattern BannerPatternType) { bannerPatternIDs[pattern] = id } -// BannerPatternByID returns a banner pattern by the ID it was registered with. -func BannerPatternByID(id string) BannerPatternType { +// BannerPatternByID returns a banner pattern by the ID it was registered with. Second return value describes whether +// a banner pattern with the ID was found. +func BannerPatternByID(id string) (BannerPatternType, bool) { b, ok := bannerPatternsMap[id] - if !ok { - panic("should never happen") - } - return b + return b, ok } // bannerPatternID returns the ID a banner pattern was registered with. diff --git a/server/block/basalt.go b/server/block/basalt.go index eb66d5080d..0abdfcea23 100644 --- a/server/block/basalt.go +++ b/server/block/basalt.go @@ -32,7 +32,7 @@ func (b Basalt) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world // BreakInfo ... func (b Basalt) BreakInfo() BreakInfo { - return newBreakInfo(1.25, pickaxeHarvestable, pickaxeEffective, oneOf(b)).withBlastResistance(21) + return newBreakInfo(1.25, pickaxeHarvestable, pickaxeEffective, oneOf(b)).withBlastResistance(4.2) } // EncodeItem ... diff --git a/server/block/bed.go b/server/block/bed.go index b02f0299a2..916e18aa5a 100644 --- a/server/block/bed.go +++ b/server/block/bed.go @@ -106,9 +106,12 @@ func (b Bed) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *i if w.Dimension() != world.Overworld { tx.SetBlock(pos, nil, nil) ExplosionConfig{ - Size: 5, SpawnFire: true, - }.Explode(tx, pos.Vec3Centre()) + }.Explode(tx, world.BlockExplosionSource{ + Block: b, + Pos: pos, + ExplosionSize: 5, + }) return true } diff --git a/server/block/bedrock.go b/server/block/bedrock.go index a152bcf66c..dde5dd8ad7 100644 --- a/server/block/bedrock.go +++ b/server/block/bedrock.go @@ -20,3 +20,8 @@ func (b Bedrock) EncodeBlock() (name string, properties map[string]any) { //noinspection SpellCheckingInspection return "minecraft:bedrock", map[string]any{"infiniburn_bit": b.InfiniteBurning} } + +// SupportsEndCrystal always returns true. +func (Bedrock) SupportsEndCrystal() bool { + return true +} diff --git a/server/block/blackstone.go b/server/block/blackstone.go index f8426047c8..d3b7d3b4d1 100644 --- a/server/block/blackstone.go +++ b/server/block/blackstone.go @@ -38,7 +38,7 @@ func (b Blackstone) BreakInfo() BreakInfo { hardness = 2 } - return newBreakInfo(hardness, pickaxeHarvestable, pickaxeEffective, drops).withBlastResistance(30) + return newBreakInfo(hardness, pickaxeHarvestable, pickaxeEffective, drops).withBlastResistance(6) } // EncodeItem ... diff --git a/server/block/blast_furnace.go b/server/block/blast_furnace.go index e63cfedc93..5eee346eba 100644 --- a/server/block/blast_furnace.go +++ b/server/block/blast_furnace.go @@ -81,7 +81,10 @@ func (b BlastFurnace) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx // BreakInfo ... func (b BlastFurnace) BreakInfo() BreakInfo { - xp := b.Experience() + xp := 0 + if b.smelter != nil { + xp = b.Experience() + } return newBreakInfo(3.5, alwaysHarvestable, pickaxeEffective, oneOf(BlastFurnace{})).withXPDropRange(xp, xp).withBreakHandler(func(pos cube.Pos, tx *world.Tx, u item.User) { for _, i := range b.Inventory(tx, pos).Clear() { dropItem(tx, i, pos.Vec3()) diff --git a/server/block/block.go b/server/block/block.go index 044f65a81b..9707d7e5bd 100644 --- a/server/block/block.go +++ b/server/block/block.go @@ -54,6 +54,14 @@ type LightDiffuser interface { LightDiffusionLevel() uint8 } +// NonSuffocating represents a block that, despite being fully solid, never suffocates an entity standing +// inside it. This is distinct from LightDiffuser: a block may block all light while still being safe to +// stand inside, such as tinted glass. +type NonSuffocating interface { + // PreventsSuffocation returns true if the block never causes suffocation damage to entities inside it. + PreventsSuffocation() bool +} + // RedstoneWireStepDowner represents a block with custom behaviour for redstone wire providing power when travelling // down it. type RedstoneWireStepDowner interface { diff --git a/server/block/block_behaviour_test.go b/server/block/block_behaviour_test.go new file mode 100644 index 0000000000..e4be048d76 --- /dev/null +++ b/server/block/block_behaviour_test.go @@ -0,0 +1,37 @@ +package block_test + +import ( + "context" + "testing" + + "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" +) + +// TestTorchBreaksWithoutSupport verifies that a torch is broken by a neighbour +// update on the tick after its supporting block is removed, using a +// synchronous World to make the tick deterministic. +func TestTorchBreaksWithoutSupport(t *testing.T) { + w := world.Config{Synchronous: true, Entities: entity.DefaultRegistry}.New() + defer w.Close() + + support, torch := cube.Pos{0, 0, 0}, cube.Pos{0, 1, 0} + w.Do(func(tx *world.Tx) { + tx.SetBlock(support, block.Stone{}, nil) + tx.SetBlock(torch, block.Torch{Facing: cube.FaceDown}, nil) + tx.SetBlock(support, block.Air{}, nil) + }) + w.AdvanceTick() + + b, err := world.Call(context.Background(), w, func(tx *world.Tx) (world.Block, error) { + return tx.Block(torch), nil + }) + if err != nil { + t.Fatalf("read torch block: %v", err) + } + if b != (block.Air{}) { + t.Errorf("expected torch to break after removing its support, got %v", b) + } +} diff --git a/server/block/break_info.go b/server/block/break_info.go index cc6004a1ae..a6745d1ba8 100644 --- a/server/block/break_info.go +++ b/server/block/break_info.go @@ -22,59 +22,100 @@ type Breakable interface { BreakInfo() BreakInfo } -// BreakDuration returns the base duration that breaking the block passed takes when being broken using the -// item passed. -func BreakDuration(b world.Block, i item.Stack) time.Duration { +// BreakContext carries the environmental and status-effect state that influences how quickly a block is +// broken. The zero value represents a player standing on the ground, out of water, without any relevant +// status effects or enchantments. +type BreakContext struct { + // HasteLevel is the level of the Haste effect (0 if absent). Level 1 corresponds to Haste I. + HasteLevel int + // ConduitPowerLevel is the level of the Conduit Power effect (0 if absent). It grants a mining speed + // boost equivalent to Haste; the two do not stack (the higher of the two is used). + ConduitPowerLevel int + // MiningFatigueLevel is the level of the Mining Fatigue effect (0 if absent). + MiningFatigueLevel int + // Underwater is true if the player's head is submerged in water, which slows mining by 5x unless + // negated by AquaAffinity. + Underwater bool + // AquaAffinity is true if the player wears a helmet enchanted with Aqua Affinity, negating the + // underwater mining penalty. + AquaAffinity bool + // Flying is true if the player is flying, which suppresses the airborne mining speed penalty. + Flying bool + // Airborne is true if the player is not on the ground, which slows mining by 5x. + Airborne bool +} + +// BreakDuration returns the duration that breaking the block passed takes when being broken using the item +// passed, accounting for the status effects and environment described by ctx. +// See https://minecraft.wiki/w/Breaking#Calculation. +func BreakDuration(b world.Block, i item.Stack, ctx BreakContext) time.Duration { breakable, ok := b.(Breakable) if !ok { return math.MaxInt64 } + info := breakable.BreakInfo() + if info.Hardness <= 0 { + return 0 + } t, ok := i.Item().(item.Tool) if !ok { t = item.ToolNone{} } - info := breakable.BreakInfo() - breakTime := info.Hardness * 5 - if info.Harvestable(t) { - breakTime = info.Hardness * 1.5 - } + canHarvest := info.Harvestable(t) + speed := 1.0 if info.Effective(t) { - eff := t.BaseMiningEfficiency(b) - if e, ok := i.Enchantment(enchantment.Efficiency); ok { - eff += enchantment.Efficiency.Addend(e.Level()) + speed = t.BaseMiningEfficiency(b) + if !canHarvest { + // A tool of the correct type but wrong tier (e.g. a wooden pickaxe on diamond ore) grants no + // speed bonus in Bedrock Edition. + speed = 1 + } else if e, ok := i.Enchantment(enchantment.Efficiency); ok { + speed += enchantment.Efficiency.Addend(e.Level()) } - breakTime /= eff } - // TODO: Account for haste etc here. - timeInTicksAccurate := math.Round(breakTime/0.05) * 0.05 - return (time.Duration(math.Round(timeInTicksAccurate*20)) * time.Second) / 20 -} - -// BreaksInstantly checks if the block passed can be broken instantly using the item stack passed to break -// it. -func BreaksInstantly(b world.Block, i item.Stack) bool { - breakable, ok := b.(Breakable) - if !ok { - return false + // Haste and Conduit Power do not stack; the higher of the two is used. They boost both the mining speed + // and the final destroy progress per tick. + positive := max(ctx.HasteLevel, ctx.ConduitPowerLevel) + if positive > 0 { + speed *= 0.2*float64(positive) + 1 } - hardness := breakable.BreakInfo().Hardness - if hardness == 0 { - return true + if ctx.MiningFatigueLevel > 0 { + speed *= math.Pow(0.3, float64(ctx.MiningFatigueLevel)) } - t, ok := i.Item().(item.Tool) - if !ok || !breakable.BreakInfo().Effective(t) { - return false + if ctx.Underwater && !ctx.AquaAffinity { + speed /= 5 + } + if ctx.Airborne && !ctx.Flying { + speed /= 5 } - // TODO: Account for haste etc here. - efficiencyVal := 0.0 - if e, ok := i.Enchantment(enchantment.Efficiency); ok { - efficiencyVal += enchantment.Efficiency.Addend(e.Level()) + damage := speed / info.Hardness + if canHarvest { + damage /= 30 + } else { + damage /= 100 + } + if positive > 0 { + damage *= math.Pow(1.2, float64(positive)) } - hasteVal := 0.0 - return (t.BaseMiningEfficiency(b)+efficiencyVal)*hasteVal >= hardness*30 + if ctx.MiningFatigueLevel > 0 { + damage *= math.Pow(0.7, float64(ctx.MiningFatigueLevel)) + } + if damage >= 1 { + // The block breaks within a single tick. + return 0 + } + return time.Duration(math.Ceil(1/damage)) * time.Second / 20 +} + +// BreaksInstantly checks if the block passed breaks instantly, that is, it has zero hardness and so is +// mined in a single tick with any item and without consuming tool durability. This is distinct from a +// block that only breaks within one tick because of a high-speed tool or status effects. +func BreaksInstantly(b world.Block) bool { + breakable, ok := b.(Breakable) + return ok && breakable.BreakInfo().Hardness <= 0 } // BreakInfo is a struct returned by every block. It holds information on block breaking related data, such as @@ -100,11 +141,11 @@ type BreakInfo struct { } // newBreakInfo creates a BreakInfo struct with the properties passed. The XPDrops field is 0 by default. The blast -// resistance is set to the block's hardness*5 by default. +// resistance is set to the block's hardness by default. func newBreakInfo(hardness float64, harvestable func(item.Tool) bool, effective func(item.Tool) bool, drops func(item.Tool, []item.Enchantment) []item.Stack) BreakInfo { return BreakInfo{ Hardness: hardness, - BlastResistance: hardness * 5, + BlastResistance: hardness, Harvestable: harvestable, Effective: effective, Drops: drops, diff --git a/server/block/break_info_test.go b/server/block/break_info_test.go new file mode 100644 index 0000000000..86f51643ba --- /dev/null +++ b/server/block/break_info_test.go @@ -0,0 +1,134 @@ +package block_test + +import ( + "testing" + + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/enchantment" + "github.com/df-mc/dragonfly/server/world" +) + +// TestBreakDuration verifies the Bedrock Edition breaking calculation against the reference values +// documented at https://minecraft.wiki/w/Breaking#Calculation. +func TestBreakDuration(t *testing.T) { + diamondPick := item.NewStack(item.Pickaxe{Tier: item.ToolTierDiamond}, 1) + efficiencyDiamondPick := diamondPick.WithEnchantments(item.NewEnchantment(enchantment.Efficiency, 3)) + woodPick := item.NewStack(item.Pickaxe{Tier: item.ToolTierWood}, 1) + + tests := []struct { + name string + block world.Block + stack item.Stack + ctx block.BreakContext + wantTicks int64 + }{ + { + name: "efficiency adds to best harvestable tool speed", + block: block.Stone{}, + stack: efficiencyDiamondPick, + wantTicks: 3, + }, + { + name: "haste applies speed and damage multipliers", + block: block.Stone{}, + stack: diamondPick, + ctx: block.BreakContext{HasteLevel: 1}, + wantTicks: 4, + }, + { + name: "grounded best tool", + block: block.Stone{}, + stack: diamondPick, + wantTicks: 6, + }, + { + name: "aqua affinity removes water penalty", + block: block.Stone{}, + stack: diamondPick, + ctx: block.BreakContext{Underwater: true, AquaAffinity: true}, + wantTicks: 6, + }, + { + name: "mining fatigue applies speed and damage multipliers", + block: block.Stone{}, + stack: diamondPick, + ctx: block.BreakContext{MiningFatigueLevel: 1}, + wantTicks: 27, + }, + { + name: "airborne penalty before rounding", + block: block.Stone{}, + stack: diamondPick, + ctx: block.BreakContext{Airborne: true}, + wantTicks: 29, + }, + { + name: "efficiency instant-mines a soft block on land", + block: block.Netherrack{}, + stack: efficiencyDiamondPick, + wantTicks: 0, + }, + { + name: "airborne prevents instant-mining the same soft block", + block: block.Netherrack{}, + stack: efficiencyDiamondPick, + ctx: block.BreakContext{Airborne: true}, + wantTicks: 4, + }, + { + name: "water without aqua affinity slows mining", + block: block.Stone{}, + stack: diamondPick, + ctx: block.BreakContext{Underwater: true}, + wantTicks: 29, + }, + { + name: "wrong tier tool cannot harvest", + block: block.DiamondOre{Type: block.StoneOre()}, + stack: woodPick, + wantTicks: 300, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := block.BreakDuration(tt.block, tt.stack, tt.ctx).Milliseconds() / 50; got != tt.wantTicks { + t.Errorf("got %d ticks, want %d", got, tt.wantTicks) + } + }) + } +} + +// TestBreaksInstantly verifies that BreaksInstantly reports an instant break only for zero-hardness blocks, +// not for positive-hardness blocks that merely break within one tick due to a fast tool. +func TestBreaksInstantly(t *testing.T) { + tests := []struct { + name string + block world.Block + want bool + }{ + { + name: "zero-hardness block breaks instantly", + block: block.ShortGrass{}, + want: true, + }, + { + name: "positive-hardness block does not break instantly", + block: block.Netherrack{}, + want: false, + }, + { + name: "stone does not break instantly", + block: block.Stone{}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := block.BreaksInstantly(tt.block); got != tt.want { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} diff --git a/server/block/bricks.go b/server/block/bricks.go index 3266513d74..536f03eb38 100644 --- a/server/block/bricks.go +++ b/server/block/bricks.go @@ -8,7 +8,7 @@ type Bricks struct { // BreakInfo ... func (b Bricks) BreakInfo() BreakInfo { - return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(b)).withBlastResistance(30) + return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(b)).withBlastResistance(6) } // EncodeItem ... diff --git a/server/block/cake.go b/server/block/cake.go index 2122c94b17..3f3ab130a0 100644 --- a/server/block/cake.go +++ b/server/block/cake.go @@ -4,6 +4,7 @@ import ( "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/block/model" "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/enchantment" "github.com/df-mc/dragonfly/server/world" "github.com/df-mc/dragonfly/server/world/sound" "github.com/go-gl/mathgl/mgl64" @@ -16,6 +17,20 @@ type Cake struct { // Bites is the amount of bites taken out of the cake. Bites int + // Candle is true if the cake has a candle on top. + Candle bool + // CandleColour is the colour of the candle. + CandleColour item.OptionalColour + // CandleLit is whether the candle is lit. + CandleLit bool +} + +// LightEmissionLevel ... +func (c Cake) LightEmissionLevel() uint8 { + if c.Candle && c.CandleLit { + return 3 + } + return 0 } // SideClosed ... @@ -42,16 +57,59 @@ func (c Cake) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.T func (c Cake) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { if _, air := tx.Block(pos.Side(cube.FaceDown)).(Air); air { breakBlock(c, pos, tx) + return + } + liquid, _ := tx.Liquid(pos) + if _, ok := liquid.(Water); ok && c.Candle && c.CandleLit { + c.CandleLit = false + tx.SetBlock(pos, c, nil) + tx.PlaySound(pos.Vec3Centre(), sound.FireExtinguish{}) } } // Activate ... -func (c Cake) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { +func (c Cake) Activate(pos cube.Pos, face cube.Face, tx *world.Tx, u item.User, ctx *item.UseContext) bool { + held, _ := u.HeldItems() + if c.Bites == 0 && !c.Candle { + if candle, ok := held.Item().(Candle); ok { + c.Candle = true + c.CandleColour = candle.Colour + tx.SetBlock(pos, c, nil) + tx.PlaySound(pos.Vec3Centre(), sound.ItemUseOn{Block: c}) + ctx.SubtractFromCount(1) + return true + } + } + + if _, ok := held.Enchantment(enchantment.FireAspect); ok { + c.Ignite(pos, tx, nil) + ctx.DamageItem(1) + return true + } + + if _, ok := held.Item().(item.FlintAndSteel); ok { + return false + } + + if c.Candle && c.CandleLit && face == cube.FaceUp && held.Empty() { + c.CandleLit = false + tx.SetBlock(pos, c, nil) + return true + } + if i, ok := u.(interface { Saturate(food int, saturation float64) }); ok { + if c.Candle { + dropItem(tx, item.NewStack(Candle{Colour: c.CandleColour}, 1), pos.Vec3Centre()) + + c.Candle, c.CandleLit = false, false + c.CandleColour = item.OptionalColour(0) + } + i.Saturate(2, 0.4) tx.PlaySound(u.Position().Add(mgl64.Vec3{0, 1.5}), sound.Burp{}) + c.Bites++ if c.Bites > 6 { tx.SetBlock(pos, nil, nil) @@ -63,18 +121,51 @@ func (c Cake) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ * return false } +// Ignite ... +func (c Cake) Ignite(pos cube.Pos, tx *world.Tx, _ world.Entity) bool { + if !c.Candle || c.CandleLit { + return false + } + if _, ok := tx.Liquid(pos); ok { + return false + } + + c.CandleLit = true + tx.SetBlock(pos, c, nil) + tx.PlaySound(pos.Vec3(), sound.Ignite{}) + return true +} + +// EntityInside ... +func (c Cake) EntityInside(pos cube.Pos, tx *world.Tx, e world.Entity) { + if flammable, ok := e.(flammableEntity); ok { + if flammable.OnFireDuration() > 0 { + c.Ignite(pos, tx, e) + } + } +} + // BreakInfo ... func (c Cake) BreakInfo() BreakInfo { + if c.Candle { + return newBreakInfo(0.5, alwaysHarvestable, nothingEffective, oneOf(Candle{Colour: c.CandleColour})) + } return newBreakInfo(0.5, neverHarvestable, nothingEffective, simpleDrops()) } // EncodeItem ... func (c Cake) EncodeItem() (name string, meta int16) { + if c.Candle { + return "minecraft:" + c.CandleColour.Prepend("candle_cake"), 0 + } return "minecraft:cake", 0 } // EncodeBlock ... func (c Cake) EncodeBlock() (name string, properties map[string]any) { + if c.Candle { + return "minecraft:" + c.CandleColour.Prepend("candle_cake"), map[string]any{"lit": c.CandleLit} + } return "minecraft:cake", map[string]any{"bite_counter": int32(c.Bites)} } @@ -88,5 +179,9 @@ func allCake() (cake []world.Block) { for bites := 0; bites < 7; bites++ { cake = append(cake, Cake{Bites: bites}) } + for _, c := range item.OptionalColours() { + cake = append(cake, Cake{CandleColour: c, Candle: true}) + cake = append(cake, Cake{CandleColour: c, Candle: true, CandleLit: true}) + } return } diff --git a/server/block/campfire.go b/server/block/campfire.go index 88a143925b..3c6e7c3f7f 100644 --- a/server/block/campfire.go +++ b/server/block/campfire.go @@ -243,8 +243,8 @@ func (c Campfire) EncodeNBT() map[string]any { for i, v := range c.Items { id := strconv.Itoa(i + 1) if !v.Item.Empty() { - m["Item"+id] = nbtconv.WriteItem(v.Item, true) - m["ItemTime"+id] = uint8(v.Time.Milliseconds() / 50) + m["Item"+id] = item.WriteNBT(v.Item, true) + m["ItemTime"+id] = int32(v.Time.Milliseconds() / 50) } } return m @@ -255,8 +255,8 @@ func (c Campfire) DecodeNBT(data map[string]any) any { for i := 0; i < 4; i++ { id := strconv.Itoa(i + 1) c.Items[i] = CampfireItem{ - Item: nbtconv.MapItem(data, "Item"+id), - Time: time.Duration(nbtconv.Int16(data, "ItemTime"+id)) * time.Millisecond * 50, + Item: item.MapNBT(data, "Item"+id), + Time: time.Duration(nbtconv.Int32(data, "ItemTime"+id)) * time.Millisecond * 50, } } return c diff --git a/server/block/cinnabar.go b/server/block/cinnabar.go new file mode 100644 index 0000000000..cd2b8224dc --- /dev/null +++ b/server/block/cinnabar.go @@ -0,0 +1,31 @@ +package block + +// Cinnabar is a decorative rock that generates throughout sulfur caves and as part of sulfur springs. +type Cinnabar struct { + solid + bassDrum + + // Chiseled specifies if the cinnabar is chiseled. + Chiseled bool +} + +// BreakInfo ... +func (c Cinnabar) BreakInfo() BreakInfo { + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(c)).withBlastResistance(6) +} + +// EncodeItem ... +func (c Cinnabar) EncodeItem() (name string, meta int16) { + if c.Chiseled { + return "minecraft:chiseled_cinnabar", 0 + } + return "minecraft:cinnabar", 0 +} + +// EncodeBlock ... +func (c Cinnabar) EncodeBlock() (string, map[string]any) { + if c.Chiseled { + return "minecraft:chiseled_cinnabar", nil + } + return "minecraft:cinnabar", nil +} diff --git a/server/block/cinnabar_bricks.go b/server/block/cinnabar_bricks.go new file mode 100644 index 0000000000..06cd7ff554 --- /dev/null +++ b/server/block/cinnabar_bricks.go @@ -0,0 +1,22 @@ +package block + +// CinnabarBricks is a decorative variant of Cinnabar. +type CinnabarBricks struct { + solid + bassDrum +} + +// BreakInfo ... +func (c CinnabarBricks) BreakInfo() BreakInfo { + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(c)).withBlastResistance(6) +} + +// EncodeItem ... +func (CinnabarBricks) EncodeItem() (name string, meta int16) { + return "minecraft:cinnabar_bricks", 0 +} + +// EncodeBlock ... +func (CinnabarBricks) EncodeBlock() (string, map[string]any) { + return "minecraft:cinnabar_bricks", nil +} diff --git a/server/block/coal.go b/server/block/coal.go index 870e0a178a..aa8c432c89 100644 --- a/server/block/coal.go +++ b/server/block/coal.go @@ -13,7 +13,7 @@ type Coal struct { // BreakInfo ... func (c Coal) BreakInfo() BreakInfo { - return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(c)).withBlastResistance(30) + return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(c)).withBlastResistance(6) } // FlammabilityInfo ... diff --git a/server/block/coal_ore.go b/server/block/coal_ore.go index 13d3d6273f..7e53b39d11 100644 --- a/server/block/coal_ore.go +++ b/server/block/coal_ore.go @@ -13,7 +13,7 @@ type CoalOre struct { // BreakInfo ... func (c CoalOre) BreakInfo() BreakInfo { - return newBreakInfo(c.Type.Hardness(), pickaxeHarvestable, pickaxeEffective, oreDrops(item.Coal{}, c)).withXPDropRange(0, 2).withBlastResistance(15) + return newBreakInfo(c.Type.Hardness(), pickaxeHarvestable, pickaxeEffective, oreDrops(item.Coal{}, c)).withXPDropRange(0, 2).withBlastResistance(3) } // SmeltInfo ... diff --git a/server/block/cobblestone.go b/server/block/cobblestone.go index 3abff59b5c..bfe848dce7 100644 --- a/server/block/cobblestone.go +++ b/server/block/cobblestone.go @@ -14,7 +14,7 @@ type Cobblestone struct { // BreakInfo ... func (c Cobblestone) BreakInfo() BreakInfo { - return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(c)).withBlastResistance(30) + return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(c)).withBlastResistance(6) } // SmeltInfo ... diff --git a/server/block/cobweb.go b/server/block/cobweb.go index b18ac0df86..94f8df4a69 100644 --- a/server/block/cobweb.go +++ b/server/block/cobweb.go @@ -47,7 +47,7 @@ func (c Cobweb) BreakInfo() BreakInfo { } return nil }, - ).withBlastResistance(4) + ) } // HasLiquidDrops ... diff --git a/server/block/cocoa_bean.go b/server/block/cocoa_bean.go index bf19b07166..b2ad28e65d 100644 --- a/server/block/cocoa_bean.go +++ b/server/block/cocoa_bean.go @@ -93,7 +93,7 @@ func (c CocoaBean) BreakInfo() BreakInfo { return []item.Stack{item.NewStack(c, rand.IntN(2)+2)} } return []item.Stack{item.NewStack(c, 1)} - }).withBlastResistance(15) + }).withBlastResistance(3) } // CompostChance ... diff --git a/server/block/composter.go b/server/block/composter.go index e6c020061c..7b3072e839 100644 --- a/server/block/composter.go +++ b/server/block/composter.go @@ -82,7 +82,7 @@ func (c Composter) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { // BreakInfo ... func (c Composter) BreakInfo() BreakInfo { - return newBreakInfo(0.6, alwaysHarvestable, axeEffective, oneOf(c)).withBreakHandler(func(pos cube.Pos, tx *world.Tx, u item.User) { + return newBreakInfo(0.6, alwaysHarvestable, axeEffective, oneOf(Composter{})).withBreakHandler(func(pos cube.Pos, tx *world.Tx, u item.User) { if c.Level == 8 { dropItem(tx, item.NewStack(item.BoneMeal{}, 1), pos.Side(cube.FaceUp).Vec3Middle()) } @@ -97,6 +97,8 @@ func (c Composter) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User tx.SetBlock(pos, c, nil) dropItem(tx, item.NewStack(item.BoneMeal{}, 1), pos.Side(cube.FaceUp).Vec3Middle()) tx.PlaySound(pos.Vec3(), sound.ComposterEmpty{}) + // The bone meal was collected, so the item held must not also be used on the composter. + return true } return false } diff --git a/server/block/copper.go b/server/block/copper.go index fd814b2ae4..eecd8461ae 100644 --- a/server/block/copper.go +++ b/server/block/copper.go @@ -38,7 +38,7 @@ func (c Copper) Strip() (world.Block, world.Sound, bool) { func (c Copper) BreakInfo() BreakInfo { return newBreakInfo(3, func(t item.Tool) bool { return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierStone.HarvestLevel - }, pickaxeEffective, oneOf(c)).withBlastResistance(30) + }, pickaxeEffective, oneOf(c)).withBlastResistance(6) } // Wax waxes the copper block to stop it from oxidising further. diff --git a/server/block/copper_bars.go b/server/block/copper_bars.go index 87539cf42e..c66fea39e3 100644 --- a/server/block/copper_bars.go +++ b/server/block/copper_bars.go @@ -23,7 +23,7 @@ type CopperBars struct { // BreakInfo ... func (c CopperBars) BreakInfo() BreakInfo { - return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(c)).withBlastResistance(30) + return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(c)).withBlastResistance(6) } // SideClosed ... diff --git a/server/block/copper_chain.go b/server/block/copper_chain.go index 5f553ce457..c97b8d4261 100644 --- a/server/block/copper_chain.go +++ b/server/block/copper_chain.go @@ -43,7 +43,7 @@ func (c CopperChain) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx * // BreakInfo ... func (c CopperChain) BreakInfo() BreakInfo { - return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(c)).withBlastResistance(30) + return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(c)).withBlastResistance(6) } // Wax waxes the copper chain to stop it from oxidising further. diff --git a/server/block/copper_door.go b/server/block/copper_door.go index c73f3f3126..c46b05a897 100644 --- a/server/block/copper_door.go +++ b/server/block/copper_door.go @@ -158,7 +158,7 @@ func (d CopperDoor) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { func (d CopperDoor) BreakInfo() BreakInfo { return newBreakInfo(3, func(t item.Tool) bool { return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierStone.HarvestLevel - }, pickaxeEffective, oneOf(d)) + }, pickaxeEffective, oneOf(d)).withBlastResistance(6) } // SideClosed ... diff --git a/server/block/copper_golem_statue.go b/server/block/copper_golem_statue.go index 7198a9b34c..a6bf742e26 100644 --- a/server/block/copper_golem_statue.go +++ b/server/block/copper_golem_statue.go @@ -30,7 +30,7 @@ type CopperGolemStatue struct { // BreakInfo ... func (c CopperGolemStatue) BreakInfo() BreakInfo { - return newBreakInfo(3, alwaysHarvestable, pickaxeEffective, oneOf(c)).withBlastResistance(30) + return newBreakInfo(3, alwaysHarvestable, pickaxeEffective, oneOf(c)).withBlastResistance(6) } // Activate ... diff --git a/server/block/copper_grate.go b/server/block/copper_grate.go index cfdaea578a..5a05a44d41 100644 --- a/server/block/copper_grate.go +++ b/server/block/copper_grate.go @@ -27,7 +27,7 @@ type CopperGrate struct { func (c CopperGrate) BreakInfo() BreakInfo { return newBreakInfo(3, func(t item.Tool) bool { return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierStone.HarvestLevel - }, pickaxeEffective, oneOf(c)).withBlastResistance(30) + }, pickaxeEffective, oneOf(c)).withBlastResistance(6) } // Wax waxes the copper grate to stop it from oxidising further. diff --git a/server/block/copper_ore.go b/server/block/copper_ore.go index e851ad19d3..c2449791e4 100644 --- a/server/block/copper_ore.go +++ b/server/block/copper_ore.go @@ -17,7 +17,7 @@ type CopperOre struct { func (c CopperOre) BreakInfo() BreakInfo { return newBreakInfo(c.Type.Hardness(), func(t item.Tool) bool { return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierStone.HarvestLevel - }, pickaxeEffective, multiOreDrops(item.RawCopper{}, c, 2, 5)).withBlastResistance(15) + }, pickaxeEffective, multiOreDrops(item.RawCopper{}, c, 2, 5)).withBlastResistance(3) } // SmeltInfo ... diff --git a/server/block/copper_trapdoor.go b/server/block/copper_trapdoor.go index 919e1a0916..9a2316f601 100644 --- a/server/block/copper_trapdoor.go +++ b/server/block/copper_trapdoor.go @@ -101,7 +101,7 @@ func (t CopperTrapdoor) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { func (t CopperTrapdoor) BreakInfo() BreakInfo { return newBreakInfo(3, func(t item.Tool) bool { return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierStone.HarvestLevel - }, pickaxeEffective, oneOf(t)) + }, pickaxeEffective, oneOf(t)).withBlastResistance(6) } // SideClosed ... diff --git a/server/block/coral_block.go b/server/block/coral_block.go index 5de221afc6..aa21052af0 100644 --- a/server/block/coral_block.go +++ b/server/block/coral_block.go @@ -44,7 +44,7 @@ func (c CoralBlock) ScheduledTick(pos cube.Pos, tx *world.Tx, _ *rand.Rand) { // BreakInfo ... func (c CoralBlock) BreakInfo() BreakInfo { - return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, silkTouchOneOf(CoralBlock{Type: c.Type, Dead: true}, c)).withBlastResistance(30) + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, silkTouchOneOf(CoralBlock{Type: c.Type, Dead: true}, c)).withBlastResistance(6) } // EncodeBlock ... diff --git a/server/block/cube/axis.go b/server/block/cube/axis.go index dd94d46ded..fa39ca40d8 100644 --- a/server/block/cube/axis.go +++ b/server/block/cube/axis.go @@ -44,6 +44,20 @@ func (a Axis) RotateRight() Axis { return a.RotateLeft() } +// Faces returns the negative and positive Face along the Axis. For X it +// returns FaceWest, FaceEast; for Y, FaceDown, FaceUp; for Z, FaceNorth, +// FaceSouth. +func (a Axis) Faces() (negative, positive Face) { + switch a { + case X: + return FaceWest, FaceEast + case Y: + return FaceDown, FaceUp + default: + return FaceNorth, FaceSouth + } +} + // Vec3 returns a unit Vec3 of either (1, 0, 0), (0, 1, 0) or (0, 0, 1), // depending on the Axis. func (a Axis) Vec3() mgl64.Vec3 { diff --git a/server/block/cube/bbox.go b/server/block/cube/bbox.go index b0a8131afd..76ca9e287b 100644 --- a/server/block/cube/bbox.go +++ b/server/block/cube/bbox.go @@ -1,19 +1,48 @@ package cube import ( + "github.com/go-gl/mathgl/mgl32" "github.com/go-gl/mathgl/mgl64" ) +// number represents a floating-point number supported by a bounding box. +type number interface { + ~float32 | ~float64 +} + +// vec3 represents a three-dimensional vector of floating-point numbers. +type vec3[T number] interface { + ~[3]T +} + +// boundingBox represents an axis-aligned bounding box backed by a vector type. +type boundingBox[T number, V vec3[T]] struct { + min, max V +} + // BBox represents an Axis Aligned Bounding Box in a 3D space. It is defined as // two Vec3s, of which one is the minimum and one is the maximum. -type BBox struct { - min, max mgl64.Vec3 -} +type BBox = boundingBox[float64, mgl64.Vec3] + +// BBox32 represents a float32 Axis Aligned Bounding Box in a 3D space. It is +// defined as two Vec3s, of which one is the minimum and one is the maximum. +type BBox32 = boundingBox[float32, mgl32.Vec3] // Box creates a new axis aligned bounding box with the minimum and maximum // coordinates provided. The returned box has minimum and maximum coordinates // swapped if necessary so that it is well-formed. func Box(x0, y0, z0, x1, y1, z1 float64) BBox { + return newBox[float64, mgl64.Vec3](x0, y0, z0, x1, y1, z1) +} + +// Box32 creates a new float32 axis aligned bounding box with the minimum and +// maximum coordinates provided. The returned box has minimum and maximum +// coordinates swapped if necessary so that it is well-formed. +func Box32(x0, y0, z0, x1, y1, z1 float32) BBox32 { + return newBox[float32, mgl32.Vec3](x0, y0, z0, x1, y1, z1) +} + +func newBox[T number, V vec3[T]](x0, y0, z0, x1, y1, z1 T) boundingBox[T, V] { if x0 > x1 { x0, x1 = x1, x0 } @@ -23,72 +52,67 @@ func Box(x0, y0, z0, x1, y1, z1 float64) BBox { if z0 > z1 { z0, z1 = z1, z0 } - return BBox{min: mgl64.Vec3{x0, y0, z0}, max: mgl64.Vec3{x1, y1, z1}} + return boundingBox[T, V]{min: V{x0, y0, z0}, max: V{x1, y1, z1}} } // Grow grows the bounding box in all directions by x and returns the new // bounding box. -func (box BBox) Grow(x float64) BBox { - add := mgl64.Vec3{x, x, x} - return BBox{min: box.min.Sub(add), max: box.max.Add(add)} +func (box boundingBox[T, V]) Grow(x T) boundingBox[T, V] { + return box.GrowVec3(V{x, x, x}) } // GrowVec3 grows the BBox on all axes as represented by the Vec3 passed. The // vec values are subtracted from the minimum values of the BBox and added to // the maximum values of the BBox. -func (box BBox) GrowVec3(vec mgl64.Vec3) BBox { - return BBox{min: box.min.Sub(vec), max: box.max.Add(vec)} +func (box boundingBox[T, V]) GrowVec3(vec V) boundingBox[T, V] { + for i := range 3 { + box.min[i] -= vec[i] + box.max[i] += vec[i] + } + return box } // Min returns the minimum coordinate of the bounding box. -func (box BBox) Min() mgl64.Vec3 { +func (box boundingBox[T, V]) Min() V { return box.min } // Max returns the maximum coordinate of the bounding box. -func (box BBox) Max() mgl64.Vec3 { +func (box boundingBox[T, V]) Max() V { return box.max } // Width returns the width of the BBox. -func (box BBox) Width() float64 { +func (box boundingBox[T, V]) Width() T { return box.max[0] - box.min[0] } // Length returns the length of the BBox. -func (box BBox) Length() float64 { +func (box boundingBox[T, V]) Length() T { return box.max[2] - box.min[2] } // Height returns the height of the BBox. -func (box BBox) Height() float64 { +func (box boundingBox[T, V]) Height() T { return box.max[1] - box.min[1] } // Extend expands the BBox on all axes as represented by the Vec3 passed. // Negative coordinates result in an expansion towards the negative axis, and // vice versa for positive coordinates. -func (box BBox) Extend(vec mgl64.Vec3) BBox { - if vec[0] < 0 { - box.min[0] += vec[0] - } else if vec[0] > 0 { - box.max[0] += vec[0] - } - if vec[1] < 0 { - box.min[1] += vec[1] - } else if vec[1] > 0 { - box.max[1] += vec[1] - } - if vec[2] < 0 { - box.min[2] += vec[2] - } else if vec[2] > 0 { - box.max[2] += vec[2] +func (box boundingBox[T, V]) Extend(vec V) boundingBox[T, V] { + for i := range 3 { + if vec[i] < 0 { + box.min[i] += vec[i] + } else if vec[i] > 0 { + box.max[i] += vec[i] + } } return box } // ExtendTowards extends the bounding box by x in a given direction. -func (box BBox) ExtendTowards(f Face, x float64) BBox { +func (box boundingBox[T, V]) ExtendTowards(f Face, x T) boundingBox[T, V] { switch f { case FaceDown: box.min[1] -= x @@ -107,7 +131,7 @@ func (box BBox) ExtendTowards(f Face, x float64) BBox { } // Stretch stretches the bounding box by x in a given axis. -func (box BBox) Stretch(a Axis, x float64) BBox { +func (box boundingBox[T, V]) Stretch(a Axis, x T) boundingBox[T, V] { switch a { case Y: box.min[1] -= x @@ -124,37 +148,44 @@ func (box BBox) Stretch(a Axis, x float64) BBox { // Translate moves the entire BBox with the Vec3 given. The (minimum and // maximum) x, y and z coordinates are moved by those in the Vec3 passed. -func (box BBox) Translate(vec mgl64.Vec3) BBox { - return BBox{min: box.min.Add(vec), max: box.max.Add(vec)} +func (box boundingBox[T, V]) Translate(vec V) boundingBox[T, V] { + for i := range 3 { + box.min[i] += vec[i] + box.max[i] += vec[i] + } + return box } // TranslateTowards moves the entire BBox by x in the direction of a Face f. -func (box BBox) TranslateTowards(f Face, x float64) BBox { +func (box boundingBox[T, V]) TranslateTowards(f Face, x T) boundingBox[T, V] { + var vec V switch f { case FaceDown: - return box.Translate(mgl64.Vec3{0, -x, 0}) + vec[1] = -x case FaceUp: - return box.Translate(mgl64.Vec3{0, x, 0}) + vec[1] = x case FaceNorth: - return box.Translate(mgl64.Vec3{0, 0, -x}) + vec[2] = -x case FaceSouth: - return box.Translate(mgl64.Vec3{0, 0, x}) + vec[2] = x case FaceWest: - return box.Translate(mgl64.Vec3{-x, 0, 0}) + vec[0] = -x case FaceEast: - return box.Translate(mgl64.Vec3{x, 0, 0}) + vec[0] = x + default: + return box } - return box + return box.Translate(vec) } // IntersectsWith checks if the BBox intersects with another BBox. -func (box BBox) IntersectsWith(other BBox) bool { +func (box boundingBox[T, V]) IntersectsWith(other boundingBox[T, V]) bool { return box.intersectsWith(other, 1e-5) } // intersectsWith checks if the BBox intersects with another BBox using a // specific epsilon. -func (box BBox) intersectsWith(other BBox, epsilon float64) bool { +func (box boundingBox[T, V]) intersectsWith(other boundingBox[T, V], epsilon T) bool { if other.max[0]-box.min[0] > epsilon && box.max[0]-other.min[0] > epsilon { if other.max[1]-box.min[1] > epsilon && box.max[1]-other.min[1] > epsilon { return other.max[2]-box.min[2] > epsilon && box.max[2]-other.min[2] > epsilon @@ -165,6 +196,15 @@ func (box BBox) intersectsWith(other BBox, epsilon float64) bool { // AnyIntersections checks if any of boxes intersect with search. func AnyIntersections(boxes []BBox, search BBox) bool { + return anyIntersections(boxes, search) +} + +// AnyIntersections32 checks if any of the float32 boxes intersect with search. +func AnyIntersections32(boxes []BBox32, search BBox32) bool { + return anyIntersections(boxes, search) +} + +func anyIntersections[T number, V vec3[T]](boxes []boundingBox[T, V], search boundingBox[T, V]) bool { for _, box := range boxes { if box.intersectsWith(search, 0) { return true @@ -174,7 +214,7 @@ func AnyIntersections(boxes []BBox, search BBox) bool { } // Vec3Within checks if a BBox has vec within it. -func (box BBox) Vec3Within(vec mgl64.Vec3) bool { +func (box boundingBox[T, V]) Vec3Within(vec V) bool { if vec[0] <= box.min[0] || vec[0] >= box.max[0] { return false } @@ -185,7 +225,7 @@ func (box BBox) Vec3Within(vec mgl64.Vec3) bool { } // Vec3WithinYZ checks if a BBox has vec within its Y and Z bounds. -func (box BBox) Vec3WithinYZ(vec mgl64.Vec3) bool { +func (box boundingBox[T, V]) Vec3WithinYZ(vec V) bool { if vec[2] < box.min[2] || vec[2] > box.max[2] { return false } @@ -193,7 +233,7 @@ func (box BBox) Vec3WithinYZ(vec mgl64.Vec3) bool { } // Vec3WithinXZ checks if a BBox has vec within its X and Z bounds. -func (box BBox) Vec3WithinXZ(vec mgl64.Vec3) bool { +func (box boundingBox[T, V]) Vec3WithinXZ(vec V) bool { if vec[0] < box.min[0] || vec[0] > box.max[0] { return false } @@ -201,7 +241,7 @@ func (box BBox) Vec3WithinXZ(vec mgl64.Vec3) bool { } // Vec3WithinXY checks if a BBox has vec within its X and Y bounds. -func (box BBox) Vec3WithinXY(vec mgl64.Vec3) bool { +func (box boundingBox[T, V]) Vec3WithinXY(vec V) bool { if vec[0] < box.min[0] || vec[0] > box.max[0] { return false } @@ -211,7 +251,7 @@ func (box BBox) Vec3WithinXY(vec mgl64.Vec3) bool { // XOffset calculates the offset on the X axis between two bounding boxes, // returning a delta always smaller than or equal to deltaX if deltaX is bigger // than 0, or always bigger than or equal to deltaX if it is smaller than 0. -func (box BBox) XOffset(nearby BBox, deltaX float64) float64 { +func (box boundingBox[T, V]) XOffset(nearby boundingBox[T, V], deltaX T) T { if box.max[1] <= nearby.min[1] || box.min[1] >= nearby.max[1] || box.max[2] <= nearby.min[2] || box.min[2] >= nearby.max[2] { // Not in the same Y/Z plane. return deltaX @@ -227,7 +267,7 @@ func (box BBox) XOffset(nearby BBox, deltaX float64) float64 { // YOffset calculates the offset on the Y axis between two bounding boxes, // returning a delta always smaller than or equal to deltaY if deltaY is bigger // than 0, or always bigger than or equal to deltaY if it is smaller than 0. -func (box BBox) YOffset(nearby BBox, deltaY float64) float64 { +func (box boundingBox[T, V]) YOffset(nearby boundingBox[T, V], deltaY T) T { if box.max[0] <= nearby.min[0] || box.min[0] >= nearby.max[0] || box.max[2] <= nearby.min[2] || box.min[2] >= nearby.max[2] { // Not the same X/Z plane. return deltaY @@ -244,7 +284,7 @@ func (box BBox) YOffset(nearby BBox, deltaY float64) float64 { // ZOffset calculates the offset on the Z axis between two bounding boxes, // returning a delta always smaller than or equal to deltaZ if deltaZ is bigger // than 0, or always bigger than or equal to deltaZ if it is smaller than 0. -func (box BBox) ZOffset(nearby BBox, deltaZ float64) float64 { +func (box boundingBox[T, V]) ZOffset(nearby boundingBox[T, V], deltaZ T) T { if box.max[0] <= nearby.min[0] || box.min[0] >= nearby.max[0] || box.max[1] <= nearby.min[1] || box.min[1] >= nearby.max[1] { // Not the same X/Y plane. return deltaZ @@ -259,9 +299,9 @@ func (box BBox) ZOffset(nearby BBox, deltaZ float64) float64 { } // Corners returns the positions of all corners of a BBox. -func (box BBox) Corners() []mgl64.Vec3 { +func (box boundingBox[T, V]) Corners() []V { bbmin, bbmax := box.min, box.max - return []mgl64.Vec3{ + return []V{ box.min, box.max, {bbmin[0], bbmin[1], bbmax[2]}, @@ -274,11 +314,15 @@ func (box BBox) Corners() []mgl64.Vec3 { } // Mul performs a scalar multiplication of the min and max points of a BBox. -func (box BBox) Mul(val float64) BBox { - return BBox{min: box.min.Mul(val), max: box.max.Mul(val)} +func (box boundingBox[T, V]) Mul(val T) boundingBox[T, V] { + for i := range 3 { + box.min[i] *= val + box.max[i] *= val + } + return box } // Volume calculates the volume of a BBox. -func (box BBox) Volume() float64 { +func (box boundingBox[T, V]) Volume() T { return box.Height() * box.Length() * box.Width() } diff --git a/server/block/cube/pos.go b/server/block/cube/pos.go index 2c87d8659a..99bb72000d 100644 --- a/server/block/cube/pos.go +++ b/server/block/cube/pos.go @@ -96,21 +96,29 @@ func (p Pos) Side(face Face) Pos { // Face returns the face that the other Pos was on compared to the current Pos. // The other Pos is assumed to be a direct neighbour of the current Pos. func (p Pos) Face(other Pos) Face { - switch other { - case p.Add(Pos{0, 1}): - return FaceUp - case p.Add(Pos{0, -1}): - return FaceDown - case p.Add(Pos{0, 0, -1}): - return FaceNorth - case p.Add(Pos{0, 0, 1}): - return FaceSouth - case p.Add(Pos{-1, 0, 0}): - return FaceWest - case p.Add(Pos{1, 0, 0}): - return FaceEast + face, _ := p.NeighbourFace(other) + return face +} + +// NeighbourFace returns the face that the other Pos was on compared to the +// current Pos, if the other Pos is a direct neighbour of the current Pos. +// Example: Pos{0, 0, 0}.NeighbourFace(Pos{0, 1, 0}) returns FaceUp, true. +func (p Pos) NeighbourFace(other Pos) (Face, bool) { + switch other.Sub(p) { + case Pos{0, 1, 0}: + return FaceUp, true + case Pos{0, -1, 0}: + return FaceDown, true + case Pos{0, 0, -1}: + return FaceNorth, true + case Pos{0, 0, 1}: + return FaceSouth, true + case Pos{-1, 0, 0}: + return FaceWest, true + case Pos{1, 0, 0}: + return FaceEast, true } - return FaceUp + return FaceUp, false } // Neighbours calls the function passed for each of the block position's diff --git a/server/block/customblock/material.go b/server/block/customblock/material.go index 8b018947ae..a6164e6524 100644 --- a/server/block/customblock/material.go +++ b/server/block/customblock/material.go @@ -8,19 +8,23 @@ type Material struct { renderMethod Method // faceDimming is if the material should be dimmed by the direction it's facing. faceDimming bool - // ambientOcclusion is if the material should have ambient occlusion applied when lighting. - ambientOcclusion bool + // ambientOcclusion controls how much ambient occlusion is applied when lighting. + ambientOcclusion float32 } // NewMaterial returns a new Material with the provided information. It enables face dimming by default and ambient // occlusion based on the render method given. func NewMaterial(texture string, method Method) Material { - return Material{ + m := Material{ texture: texture, renderMethod: method, faceDimming: true, - ambientOcclusion: method.AmbientOcclusion(), + ambientOcclusion: 1, } + if !method.AmbientOcclusion() { + m.ambientOcclusion = 0 + } + return m } // WithFaceDimming returns a copy of the Material with face dimming enabled. @@ -37,13 +41,13 @@ func (m Material) WithoutFaceDimming() Material { // WithAmbientOcclusion returns a copy of the Material with ambient occlusion enabled. func (m Material) WithAmbientOcclusion() Material { - m.ambientOcclusion = true + m.ambientOcclusion = 1 return m } // WithoutAmbientOcclusion returns a copy of the Material with ambient occlusion disabled. func (m Material) WithoutAmbientOcclusion() Material { - m.ambientOcclusion = false + m.ambientOcclusion = 0 return m } diff --git a/server/block/decorated_pot.go b/server/block/decorated_pot.go index adaac17230..68dbee37be 100644 --- a/server/block/decorated_pot.go +++ b/server/block/decorated_pot.go @@ -72,7 +72,7 @@ func (p DecoratedPot) ExtractItem(h Hopper, pos cube.Pos, tx *world.Tx) bool { // InsertItem ... func (p DecoratedPot) InsertItem(h Hopper, pos cube.Pos, tx *world.Tx) bool { for sourceSlot, sourceStack := range h.inventory.Slots() { - if !sourceStack.Empty() && sourceStack.Comparable(p.Item) { + if !sourceStack.Empty() && sourceStack.Comparable(p.Item) && p.Item.Count() < p.Item.MaxCount() { if p.Item.Empty() { p.Item = sourceStack.Grow(-sourceStack.Count() + 1) } else { @@ -172,17 +172,20 @@ func (p DecoratedPot) EncodeNBT() map[string]any { "sherds": sherds, } if !p.Item.Empty() { - m["item"] = nbtconv.WriteItem(p.Item, true) + m["item"] = item.WriteNBT(p.Item, true) } return m } // DecodeNBT ... func (p DecoratedPot) DecodeNBT(data map[string]any) any { - p.Item = nbtconv.MapItem(data, "item") + p.Item = item.MapNBT(data, "item") p.Decorations = [4]PotDecoration{} if sherds := nbtconv.Slice(data, "sherds"); sherds != nil { for i, name := range sherds { + if i >= len(p.Decorations) { + break + } it, ok := world.ItemByName(name.(string), 0) if !ok { panic(fmt.Errorf("unknown item %s", name)) diff --git a/server/block/deepslate.go b/server/block/deepslate.go index 70ef4ac66f..36a5450fe4 100644 --- a/server/block/deepslate.go +++ b/server/block/deepslate.go @@ -21,9 +21,9 @@ type Deepslate struct { // BreakInfo ... func (d Deepslate) BreakInfo() BreakInfo { if d.Type == NormalDeepslate() { - return newBreakInfo(3, pickaxeHarvestable, pickaxeEffective, silkTouchOneOf(Deepslate{Type: CobbledDeepslate()}, d)).withBlastResistance(30) + return newBreakInfo(3, pickaxeHarvestable, pickaxeEffective, silkTouchOneOf(Deepslate{Type: CobbledDeepslate()}, d)).withBlastResistance(6) } - return newBreakInfo(3.5, pickaxeHarvestable, pickaxeEffective, oneOf(d)).withBlastResistance(30) + return newBreakInfo(3.5, pickaxeHarvestable, pickaxeEffective, oneOf(d)).withBlastResistance(6) } // SmeltInfo ... diff --git a/server/block/deepslate_bricks.go b/server/block/deepslate_bricks.go index 08342a2173..9676811743 100644 --- a/server/block/deepslate_bricks.go +++ b/server/block/deepslate_bricks.go @@ -13,7 +13,7 @@ type DeepslateBricks struct { // BreakInfo ... func (d DeepslateBricks) BreakInfo() BreakInfo { - return newBreakInfo(3.5, pickaxeHarvestable, pickaxeEffective, oneOf(d)).withBlastResistance(30) + return newBreakInfo(3.5, pickaxeHarvestable, pickaxeEffective, oneOf(d)).withBlastResistance(6) } // SmeltInfo ... diff --git a/server/block/deepslate_tiles.go b/server/block/deepslate_tiles.go index ed271d974f..1896320ecc 100644 --- a/server/block/deepslate_tiles.go +++ b/server/block/deepslate_tiles.go @@ -13,7 +13,7 @@ type DeepslateTiles struct { // BreakInfo ... func (d DeepslateTiles) BreakInfo() BreakInfo { - return newBreakInfo(3.5, pickaxeHarvestable, pickaxeEffective, oneOf(d)).withBlastResistance(30) + return newBreakInfo(3.5, pickaxeHarvestable, pickaxeEffective, oneOf(d)).withBlastResistance(6) } // SmeltInfo ... diff --git a/server/block/diamond.go b/server/block/diamond.go index cd89c09f9f..287a1bac87 100644 --- a/server/block/diamond.go +++ b/server/block/diamond.go @@ -13,7 +13,7 @@ type Diamond struct { func (d Diamond) BreakInfo() BreakInfo { return newBreakInfo(5, func(t item.Tool) bool { return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierIron.HarvestLevel - }, pickaxeEffective, oneOf(d)).withBlastResistance(30) + }, pickaxeEffective, oneOf(d)).withBlastResistance(6) } // PowersBeacon ... diff --git a/server/block/diamond_ore.go b/server/block/diamond_ore.go index f48a46e0e6..770ce353fe 100644 --- a/server/block/diamond_ore.go +++ b/server/block/diamond_ore.go @@ -17,7 +17,7 @@ type DiamondOre struct { func (d DiamondOre) BreakInfo() BreakInfo { return newBreakInfo(d.Type.Hardness(), func(t item.Tool) bool { return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierIron.HarvestLevel - }, pickaxeEffective, oreDrops(item.Diamond{}, d)).withXPDropRange(3, 7).withBlastResistance(15) + }, pickaxeEffective, oreDrops(item.Diamond{}, d)).withXPDropRange(3, 7).withBlastResistance(3) } // SmeltInfo ... diff --git a/server/block/dirt.go b/server/block/dirt.go index c2c916c501..ed84ddfd2e 100644 --- a/server/block/dirt.go +++ b/server/block/dirt.go @@ -20,7 +20,7 @@ func (d Dirt) SoilFor(block world.Block) bool { switch block.(type) { case ShortGrass, Fern, DoubleTallGrass, DeadBush: return !d.Coarse - case Flower, DoubleFlower, NetherSprouts, PinkPetals, SugarCane, Azalea, Sapling: + case Flower, DoubleFlower, NetherSprouts, PinkPetals, SugarCane, Azalea, Sapling, BambooSapling, Bamboo: return true } return false diff --git a/server/block/dragon_egg.go b/server/block/dragon_egg.go index 71af284869..f8bf298696 100644 --- a/server/block/dragon_egg.go +++ b/server/block/dragon_egg.go @@ -62,7 +62,7 @@ func (d DragonEgg) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, _ item.User // BreakInfo ... func (d DragonEgg) BreakInfo() BreakInfo { - return newBreakInfo(3, pickaxeHarvestable, pickaxeEffective, oneOf(d)).withBlastResistance(45) + return newBreakInfo(3, pickaxeHarvestable, pickaxeEffective, oneOf(d)).withBlastResistance(9) } // EncodeItem ... diff --git a/server/block/dried_kelp.go b/server/block/dried_kelp.go index b47c2b1232..e53b2a46c6 100644 --- a/server/block/dried_kelp.go +++ b/server/block/dried_kelp.go @@ -12,7 +12,7 @@ type DriedKelp struct { // BreakInfo ... func (d DriedKelp) BreakInfo() BreakInfo { - return newBreakInfo(0.5, alwaysHarvestable, hoeEffective, oneOf(d)).withBlastResistance(12.5) + return newBreakInfo(0.5, alwaysHarvestable, hoeEffective, oneOf(d)).withBlastResistance(2.5) } // FlammabilityInfo ... diff --git a/server/block/dripstone.go b/server/block/dripstone.go index b3bbcb2e79..bebb305d5d 100644 --- a/server/block/dripstone.go +++ b/server/block/dripstone.go @@ -8,7 +8,7 @@ type Dripstone struct { // BreakInfo ... func (d Dripstone) BreakInfo() BreakInfo { - return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(d)).withBlastResistance(5) + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(d)).withBlastResistance(1) } // EncodeItem ... diff --git a/server/block/emerald.go b/server/block/emerald.go index bebdcb9dbc..520062aa9e 100644 --- a/server/block/emerald.go +++ b/server/block/emerald.go @@ -19,7 +19,7 @@ func (e Emerald) Instrument() sound.Instrument { func (e Emerald) BreakInfo() BreakInfo { return newBreakInfo(5, func(t item.Tool) bool { return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierIron.HarvestLevel - }, pickaxeEffective, oneOf(e)).withBlastResistance(30) + }, pickaxeEffective, oneOf(e)).withBlastResistance(6) } // PowersBeacon ... diff --git a/server/block/emerald_ore.go b/server/block/emerald_ore.go index dea7f38a93..caf1497618 100644 --- a/server/block/emerald_ore.go +++ b/server/block/emerald_ore.go @@ -19,7 +19,7 @@ func (e EmeraldOre) BreakInfo() BreakInfo { return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierIron.HarvestLevel }, pickaxeEffective, oreDrops(item.Emerald{}, e)).withXPDropRange(3, 7) if e.Type == DeepslateOre() { - i = i.withBlastResistance(15) + i = i.withBlastResistance(3) } return i } diff --git a/server/block/enchanting_table.go b/server/block/enchanting_table.go index dbba12fc46..47ca65095e 100644 --- a/server/block/enchanting_table.go +++ b/server/block/enchanting_table.go @@ -22,7 +22,7 @@ func (e EnchantingTable) Model() world.BlockModel { // BreakInfo ... func (e EnchantingTable) BreakInfo() BreakInfo { - return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(e)).withBlastResistance(6000) + return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(e)).withBlastResistance(1200) } // SideClosed ... diff --git a/server/block/end_bricks.go b/server/block/end_bricks.go index c5b8474950..ec70a6d37d 100644 --- a/server/block/end_bricks.go +++ b/server/block/end_bricks.go @@ -8,7 +8,7 @@ type EndBricks struct { // BreakInfo ... func (e EndBricks) BreakInfo() BreakInfo { - return newBreakInfo(3, pickaxeHarvestable, pickaxeEffective, oneOf(e)).withBlastResistance(45) + return newBreakInfo(3, pickaxeHarvestable, pickaxeEffective, oneOf(e)).withBlastResistance(9) } // EncodeItem ... diff --git a/server/block/end_portal.go b/server/block/end_portal.go new file mode 100644 index 0000000000..56fcda2c37 --- /dev/null +++ b/server/block/end_portal.go @@ -0,0 +1,65 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/block/model" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/portal" +) + +// EndPortal is the translucent block that teleports the player to and from the End. It is created by inserting an Eye +// of Ender into all twelve frame blocks of an End portal frame ring. +type EndPortal struct { + transparent +} + +// Model ... +func (EndPortal) Model() world.BlockModel { + return model.Empty{} +} + +// LightEmissionLevel returns 15. +func (EndPortal) LightEmissionLevel() uint8 { + return 15 +} + +// HasLiquidDrops ... +func (EndPortal) HasLiquidDrops() bool { + return false +} + +// Portal returns the End dimension. The same block leads back to the Overworld when entered from the End. +func (EndPortal) Portal() world.Dimension { + return world.End +} + +// EncodeNBT encodes the End portal block actor. +func (EndPortal) EncodeNBT() map[string]any { + return map[string]any{"id": "EndPortal"} +} + +// DecodeNBT decodes the End portal block actor. +func (e EndPortal) DecodeNBT(map[string]any) any { + return e +} + +// NeighbourUpdateTick removes the connected portal blocks if the surrounding frame ring is no longer complete, +// like breaking the frame of a nether portal. +func (EndPortal) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if portal.EndPortalRingIntact(tx, pos) { + return + } + portal.DeactivateEndPortal(tx, pos) +} + +// EntityInside ... +func (EndPortal) EntityInside(_ cube.Pos, tx *world.Tx, e world.Entity) { + if t, ok := e.(portalTraveller); ok { + t.TravelThroughPortal(tx, world.End) + } +} + +// EncodeBlock ... +func (EndPortal) EncodeBlock() (string, map[string]any) { + return "minecraft:end_portal", nil +} diff --git a/server/block/end_portal_frame.go b/server/block/end_portal_frame.go new file mode 100644 index 0000000000..765b8b5b59 --- /dev/null +++ b/server/block/end_portal_frame.go @@ -0,0 +1,89 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/block/model" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// EndPortalFrame is the indestructible block that forms the twelve-block ring of an End portal. +type EndPortalFrame struct { + bassDrum + + // Eye is true if an Eye of Ender has been inserted into the frame. + Eye bool + // Facing is the direction the frame faces. Each frame in a valid ring faces the centre of the 3x3 interior. + Facing cube.Direction +} + +// Model ... +func (f EndPortalFrame) Model() world.BlockModel { + return model.EndPortalFrame{} +} + +// LightEmissionLevel returns 1. +func (EndPortalFrame) LightEmissionLevel() uint8 { + return 1 +} + +// EncodeItem ... +func (EndPortalFrame) EncodeItem() (name string, meta int16) { + return "minecraft:end_portal_frame", 0 +} + +// EncodeBlock ... +func (f EndPortalFrame) EncodeBlock() (string, map[string]any) { + return "minecraft:end_portal_frame", map[string]any{ + "end_portal_eye_bit": f.Eye, + "minecraft:cardinal_direction": f.Facing.String(), + } +} + +// UseOnBlock ... +func (f EndPortalFrame) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { + pos, _, used := firstReplaceable(tx, pos, face, f) + if !used { + return false + } + f.Facing = user.Rotation().Direction().Opposite() + f.Eye = false + place(tx, pos, f, user, ctx) + return placed(ctx) +} + +// EndPortalFrameState returns the frame's eye and facing state. +func (f EndPortalFrame) EndPortalFrameState() (eye bool, facing cube.Direction) { + return f.Eye, f.Facing +} + +// EncodeNBT encodes the End portal block actor stored with the frame. +func (EndPortalFrame) EncodeNBT() map[string]any { + return map[string]any{"id": "EndPortal"} +} + +// DecodeNBT decodes the End portal block actor. Eye and facing are stored in the block state. +func (f EndPortalFrame) DecodeNBT(map[string]any) any { + return f +} + +// InsertEndPortalEye returns a copy of the frame with an eye inserted, or false if it already held one. +func (f EndPortalFrame) InsertEndPortalEye() (world.Block, bool) { + if f.Eye { + return f, false + } + f.Eye = true + return f, true +} + +// allEndPortalFrames returns every state combination of EndPortalFrame for registration. +func allEndPortalFrames() []world.Block { + frames := make([]world.Block, 0, len(cube.Directions())*2) + for _, dir := range cube.Directions() { + for _, eye := range []bool{false, true} { + frames = append(frames, EndPortalFrame{Facing: dir, Eye: eye}) + } + } + return frames +} diff --git a/server/block/end_stone.go b/server/block/end_stone.go index 98224ed1c2..d4293b314e 100644 --- a/server/block/end_stone.go +++ b/server/block/end_stone.go @@ -8,7 +8,7 @@ type EndStone struct { // BreakInfo ... func (e EndStone) BreakInfo() BreakInfo { - return newBreakInfo(3, pickaxeHarvestable, pickaxeEffective, oneOf(e)).withBlastResistance(45) + return newBreakInfo(3, pickaxeHarvestable, pickaxeEffective, oneOf(e)).withBlastResistance(9) } // EncodeItem ... diff --git a/server/block/ender_chest.go b/server/block/ender_chest.go index 054b075ca5..8ded0bd781 100644 --- a/server/block/ender_chest.go +++ b/server/block/ender_chest.go @@ -37,7 +37,7 @@ func NewEnderChest() EnderChest { // BreakInfo ... func (c EnderChest) BreakInfo() BreakInfo { - return newBreakInfo(22.5, pickaxeHarvestable, pickaxeEffective, silkTouchDrop(item.NewStack(Obsidian{}, 8), item.NewStack(NewEnderChest(), 1))).withBlastResistance(3000) + return newBreakInfo(22.5, pickaxeHarvestable, pickaxeEffective, silkTouchDrop(item.NewStack(Obsidian{}, 8), item.NewStack(NewEnderChest(), 1))).withBlastResistance(600) } // LightEmissionLevel ... diff --git a/server/block/explosion.go b/server/block/explosion.go index 660549f583..7b519bc9c5 100644 --- a/server/block/explosion.go +++ b/server/block/explosion.go @@ -7,7 +7,6 @@ import ( "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/block/cube/trace" - "github.com/df-mc/dragonfly/server/event" "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/world" "github.com/df-mc/dragonfly/server/world/particle" @@ -15,11 +14,9 @@ import ( "github.com/go-gl/mathgl/mgl64" ) -// ExplosionConfig is the configuration for an explosion. The world, position, size, sound, particle, and more can all -// be configured through this configuration. +// ExplosionConfig is the configuration for an explosion. The sound, particle, item drop chance and more can all be +// configured through this configuration. The position and size come from the world.ExplosionSource passed to Explode. type ExplosionConfig struct { - // Size is the size of the explosion, it is effectively the radius which entities/blocks will be affected within. - Size float64 // RandSource is the source to use for the explosion "randomness". If set // to nil, RandSource defaults to a `rand.PCG`source seeded with // `time.Now().UnixNano()`. @@ -27,6 +24,9 @@ type ExplosionConfig struct { // SpawnFire will cause the explosion to randomly start fires in 1/3 of all destroyed air blocks that are // above opaque blocks. SpawnFire bool + // SuppressUnderwaterImpact prevents the explosion from affecting entities through liquid layers. Bedrock Edition + // applies this to every explosion. + SuppressUnderwaterImpact bool // ItemDropChance specifies how item drops should be handled. By default, // the item drop chance is 1/Size. If negative, no items will be dropped by // the explosion. If set to 1 or higher, all items are dropped. @@ -42,17 +42,27 @@ type ExplosionConfig struct { // ExplodableEntity represents an entity that can be exploded. type ExplodableEntity interface { - // Explode is called when an explosion occurs. The entity can then react to the explosion using the configuration - // and impact provided. - Explode(explosionPos mgl64.Vec3, impact float64, c ExplosionConfig) + // Explode is called when an explosion occurs. The entity can react using the source and impact provided. + Explode(src world.ExplosionSource, impact float64) } // Explodable represents a block that can be exploded. type Explodable interface { - // Explode is called when an explosion occurs. The block can react to the explosion using the configuration passed. - Explode(explosionPos mgl64.Vec3, pos cube.Pos, tx *world.Tx, c ExplosionConfig) + // Explode is called when an explosion occurs. The block can react using the source passed. + Explode(src world.ExplosionSource, pos cube.Pos, tx *world.Tx) } +type explosionBlockInfo struct { + resistance float64 + flags uint8 +} + +const ( + explosionBlockResists uint8 = 1 << iota + explosionBlockStopsRay + explosionBlockAffected +) + // rays ... var rays = make([]mgl64.Vec3, 0, 1352) @@ -71,7 +81,7 @@ func init() { } // Explode performs the explosion as specified by the configuration. -func (c ExplosionConfig) Explode(tx *world.Tx, explosionPos mgl64.Vec3) { +func (c ExplosionConfig) Explode(tx *world.Tx, src world.ExplosionSource) { if c.Sound == nil { c.Sound = sound.Explosion{} } @@ -82,14 +92,12 @@ func (c ExplosionConfig) Explode(tx *world.Tx, explosionPos mgl64.Vec3) { t := uint64(time.Now().UnixNano()) c.RandSource = rand.NewPCG(t, t) } - if c.Size == 0 { - c.Size = 4 - } + size, explosionPos := src.Size(), src.Position() if c.ItemDropChance == 0 { - c.ItemDropChance = 1.0 / c.Size + c.ItemDropChance = 1.0 / size } - r, d := rand.New(c.RandSource), c.Size*2 + r, d := rand.New(c.RandSource), size*2 box := cube.Box( math.Floor(explosionPos[0]-d-1), math.Floor(explosionPos[1]-d-1), @@ -110,48 +118,78 @@ func (c ExplosionConfig) Explode(tx *world.Tx, explosionPos mgl64.Vec3) { affectedEntities = append(affectedEntities, e) } - affectedBlocks := make([]cube.Pos, 0, 32) + estimatedBlocks := max(32, min(4096, int(size*size*size*16))) + if _, ok := tx.Liquid(cube.PosFromVec3(explosionPos)); ok { + // Liquids such as water stop a regular TNT blast at the source, so avoid reserving space for a full blast. + estimatedBlocks = 32 + } + affectedBlocks := make([]cube.Pos, 0, estimatedBlocks) + blockCache := make(map[cube.Pos]explosionBlockInfo, estimatedBlocks) for _, ray := range rays { pos := explosionPos - for blastForce := c.Size * (0.7 + r.Float64()*0.6); blastForce > 0.0; blastForce -= 0.225 { + for blastForce := size * (0.7 + r.Float64()*0.6); blastForce > 0.0; blastForce -= 0.225 { current := cube.PosFromVec3(pos) - currentBlock := tx.Block(current) - - resistance := 0.0 - if l, ok := tx.Liquid(current); ok { - resistance = l.BlastResistance() - } else if i, ok := currentBlock.(Breakable); ok { - resistance = i.BreakInfo().BlastResistance - } else if _, ok = currentBlock.(Air); !ok { + info, ok := blockCache[current] + if !ok { + currentBlock := tx.Block(current) + if l, ok := tx.Liquid(current); ok { + info.resistance = l.BlastResistance() + info.flags = explosionBlockResists + } else if i, ok := currentBlock.(Breakable); ok { + info.resistance = i.BreakInfo().BlastResistance + info.flags = explosionBlockResists + } else if _, ok = currentBlock.(Air); !ok { + info.flags = explosionBlockStopsRay + } + blockCache[current] = info + } + if info.flags&explosionBlockStopsRay != 0 { // Completely stop the ray if the current block is not air and unbreakable. break } pos = pos.Add(ray) - if blastForce -= (resistance/5 + 0.3) * 0.3; blastForce > 0 { + // Air offers no resistance to the ray, only blocks and liquids reduce its force beyond the step decay. + if info.flags&explosionBlockResists != 0 { + blastForce -= (info.resistance + 0.3) * 0.3 + } + if blastForce > 0 && info.flags&explosionBlockAffected == 0 { + info.flags |= explosionBlockAffected + blockCache[current] = info affectedBlocks = append(affectedBlocks, current) } } } - ctx := event.C(tx) + ctx := tx.Event() spawnFire := c.SpawnFire itemDropChance := c.ItemDropChance - if tx.World().Handler().HandleExplosion(ctx, explosionPos, &affectedEntities, &affectedBlocks, &itemDropChance, &spawnFire); ctx.Cancelled() { + if tx.World().Handler().HandleExplosion(ctx, src, &affectedEntities, &affectedBlocks, &itemDropChance, &spawnFire); ctx.Cancelled() { return } for _, e := range affectedEntities { - if explodable, ok := e.(ExplodableEntity); ok { - impact := (1 - e.Position().Sub(explosionPos).Len()/d) * exposure(tx, explosionPos, e) - explodable.Explode(explosionPos, impact, c) + explodable, ok := e.(ExplodableEntity) + if !ok { + continue + } + impact := (1 - e.Position().Sub(explosionPos).Len()/d) * c.exposure(tx, explosionPos, e) + if c.SuppressUnderwaterImpact && impact <= 0 { + // The blast never reached the entity. Skip the call entirely, as entities with a constant damage term, + // such as players, would otherwise still be hurt through the liquid that blocked it. + continue } + explodable.Explode(src, impact) } + blast := make(map[cube.Pos]struct{}, len(affectedBlocks)) + for _, pos := range affectedBlocks { + blast[pos] = struct{}{} + } for _, pos := range affectedBlocks { bl := tx.Block(pos) if explodable, ok := bl.(Explodable); ok { - explodable.Explode(explosionPos, pos, tx, c) + explodable.Explode(src, pos, tx) } else if breakable, ok := bl.(Breakable); ok { // Clear the block first so break handlers see the post-break world, this is required by things such as redstone updates. tx.SetBlock(pos, nil, nil) @@ -164,6 +202,7 @@ func (c ExplosionConfig) Explode(tx *world.Tx, explosionPos mgl64.Vec3) { dropItem(tx, drop, pos.Vec3Centre()) } } + removeDependents(pos, tx, blast) } } @@ -182,7 +221,7 @@ func (c ExplosionConfig) Explode(tx *world.Tx, explosionPos mgl64.Vec3) { } // exposure returns the exposure of an explosion to an entity, used to calculate the impact of an explosion. -func exposure(tx *world.Tx, origin mgl64.Vec3, e world.Entity) float64 { +func (c ExplosionConfig) exposure(tx *world.Tx, origin mgl64.Vec3, e world.Entity) float64 { pos := e.Position() box := e.H().Type().BBox(e).Translate(pos) @@ -208,6 +247,12 @@ func exposure(tx *world.Tx, origin mgl64.Vec3, e world.Entity) float64 { } var collided bool trace.TraverseBlocks(origin, point, func(pos cube.Pos) (cont bool) { + if c.SuppressUnderwaterImpact { + if _, liquid := tx.Liquid(pos); liquid { + collided = true + return false + } + } _, collided = trace.BlockIntercept(pos, tx, tx.Block(pos), origin, point) return !collided }) @@ -226,3 +271,19 @@ func exposure(tx *world.Tx, origin mgl64.Vec3, e world.Entity) float64 { func lerp(a, b, t float64) float64 { return b + a*(t-b) } + +// removeDependents removes the blocks in the blast of an explosion that depended on the block at the position passed. +// Neighbour updates only run at the end of a tick, so the other half of a block such as a door or a bed would still be +// standing when the explosion reaches it and would drop a second item of its own. +func removeDependents(pos cube.Pos, tx *world.Tx, blast map[cube.Pos]struct{}) { + pos.Neighbours(func(neighbour cube.Pos) { + if _, ok := blast[neighbour]; !ok { + // Blocks outside the blast are removed by the neighbour updates at the end of the tick, which drop no + // items of their own. + return + } + if ticker, ok := tx.Block(neighbour).(world.NeighbourUpdateTicker); ok { + ticker.NeighbourUpdateTick(neighbour, pos, tx) + } + }, tx.Range()) +} diff --git a/server/block/farmland.go b/server/block/farmland.go index 8f0f71a48f..f752ade410 100644 --- a/server/block/farmland.go +++ b/server/block/farmland.go @@ -4,7 +4,6 @@ import ( "math/rand/v2" "github.com/df-mc/dragonfly/server/block/cube" - "github.com/df-mc/dragonfly/server/event" "github.com/df-mc/dragonfly/server/world" ) @@ -74,7 +73,7 @@ func (f Farmland) hydrated(pos cube.Pos, tx *world.Tx) bool { func (f Farmland) EntityLand(pos cube.Pos, tx *world.Tx, e world.Entity, _ *float64) { if living, ok := e.(livingEntity); ok { if fall, ok := living.(fallDistanceEntity); ok && rand.Float64() < fall.FallDistance()-0.5 { - ctx := event.C(tx) + ctx := tx.Event() if tx.World().Handler().HandleCropTrample(ctx, pos); !ctx.Cancelled() { tx.SetBlock(pos, Dirt{}, nil) } diff --git a/server/block/fire.go b/server/block/fire.go index 59a6052bed..e81330dd7f 100644 --- a/server/block/fire.go +++ b/server/block/fire.go @@ -3,13 +3,14 @@ package block //lint:file-ignore ST1022 Exported variables in this package have compiler directives. These variables are not otherwise exposed to users. import ( + "math/rand/v2" + "time" + "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/enchantment" "github.com/df-mc/dragonfly/server/world" - "math/rand/v2" - "time" + "github.com/df-mc/dragonfly/server/world/portal" ) // Fire is a non-solid block that can spread to nearby flammable blocks. @@ -175,16 +176,19 @@ func (f Fire) tick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { // this might end up not happening. func (f Fire) spread(from, to cube.Pos, tx *world.Tx, r *rand.Rand) { if _, air := tx.Block(to).(Air); !air { - ctx := event.C(tx) + ctx := tx.Event() if tx.World().Handler().HandleBlockBurn(ctx, to); ctx.Cancelled() { return } } - ctx := event.C(tx) + ctx := tx.Event() if tx.World().Handler().HandleFireSpread(ctx, from, to); ctx.Cancelled() { return } spread := Fire{Type: f.Type, Age: min(15, f.Age+r.IntN(5)/4)} + if spread.Type == NormalFire() && portal.ActivateNetherPortal(tx, to) { + return + } tx.SetBlock(to, spread, nil) tx.ScheduleBlockUpdate(to, spread, time.Duration(30+r.IntN(10))*time.Second/20) } @@ -239,6 +243,11 @@ func (f Fire) HasLiquidDrops() bool { return false } +// PortalInterior returns true if fire may occupy the inside of a portal frame before activation for the target dimension. +func (f Fire) PortalInterior(target world.Dimension) bool { + return target == world.Nether && f.Type == NormalFire() +} + // LightEmissionLevel ... func (f Fire) LightEmissionLevel() uint8 { return f.Type.LightLevel() @@ -265,6 +274,9 @@ func (f Fire) Start(tx *world.Tx, pos cube.Pos) { if air || shortGrass || fern { below := tx.Block(pos.Side(cube.FaceDown)) if below.Model().FaceSolid(pos, cube.FaceUp, tx) || neighboursFlammable(pos, tx) { + if portal.ActivateNetherPortal(tx, pos) { + return + } f := Fire{} tx.SetBlock(pos, f, nil) tx.ScheduleBlockUpdate(pos, f, time.Duration(30+rand.IntN(10))*time.Second/20) diff --git a/server/block/furnace.go b/server/block/furnace.go index b7ab603a22..e763cce7fb 100644 --- a/server/block/furnace.go +++ b/server/block/furnace.go @@ -80,7 +80,10 @@ func (f Furnace) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *worl // BreakInfo ... func (f Furnace) BreakInfo() BreakInfo { - xp := f.Experience() + xp := 0 + if f.smelter != nil { + xp = f.Experience() + } return newBreakInfo(3.5, alwaysHarvestable, pickaxeEffective, oneOf(Furnace{})).withXPDropRange(xp, xp).withBreakHandler(func(pos cube.Pos, tx *world.Tx, u item.User) { for _, i := range f.Inventory(tx, pos).Clear() { dropItem(tx, i, pos.Vec3()) diff --git a/server/block/glowstone.go b/server/block/glowstone.go index 97539d7a51..e55d5171da 100644 --- a/server/block/glowstone.go +++ b/server/block/glowstone.go @@ -12,37 +12,29 @@ type Glowstone struct { solid } -// Instrument ... func (g Glowstone) Instrument() sound.Instrument { return sound.Pling() } -// CanRedstoneWireStepDown ... +// CanRedstoneWireStepDown keeps dust from stepping down over glowstone despite its solid top face. func (Glowstone) CanRedstoneWireStepDown(cube.Pos, cube.Pos, *world.Tx) bool { return false } -// RelaysRedstonePowerThrough returns false. -func (Glowstone) RelaysRedstonePowerThrough() bool { - return false -} +func (Glowstone) RedstoneNonConductive() {} -// BreakInfo ... func (g Glowstone) BreakInfo() BreakInfo { return newBreakInfo(0.3, alwaysHarvestable, nothingEffective, discreteDrops(item.GlowstoneDust{}, g, 2, 4, 4)) } -// EncodeItem ... func (Glowstone) EncodeItem() (name string, meta int16) { return "minecraft:glowstone", 0 } -// EncodeBlock ... func (Glowstone) EncodeBlock() (string, map[string]any) { return "minecraft:glowstone", nil } -// LightEmissionLevel returns 15. func (Glowstone) LightEmissionLevel() uint8 { return 15 } diff --git a/server/block/gold.go b/server/block/gold.go index 2a08ad2187..9b66cddbdf 100644 --- a/server/block/gold.go +++ b/server/block/gold.go @@ -19,7 +19,7 @@ func (g Gold) Instrument() sound.Instrument { func (g Gold) BreakInfo() BreakInfo { return newBreakInfo(3, func(t item.Tool) bool { return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierIron.HarvestLevel - }, pickaxeEffective, oneOf(g)).withBlastResistance(30) + }, pickaxeEffective, oneOf(g)).withBlastResistance(6) } // PowersBeacon ... diff --git a/server/block/gold_ore.go b/server/block/gold_ore.go index 698ac4fb94..b3db4a52b5 100644 --- a/server/block/gold_ore.go +++ b/server/block/gold_ore.go @@ -17,7 +17,7 @@ type GoldOre struct { func (g GoldOre) BreakInfo() BreakInfo { return newBreakInfo(g.Type.Hardness(), func(t item.Tool) bool { return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierIron.HarvestLevel - }, pickaxeEffective, oreDrops(item.RawGold{}, g)).withBlastResistance(15) + }, pickaxeEffective, oreDrops(item.RawGold{}, g)).withBlastResistance(3) } // SmeltInfo ... diff --git a/server/block/grass.go b/server/block/grass.go index 238af3e1ee..77279d8091 100644 --- a/server/block/grass.go +++ b/server/block/grass.go @@ -39,7 +39,7 @@ func init() { // SoilFor ... func (g Grass) SoilFor(block world.Block) bool { switch block.(type) { - case ShortGrass, Fern, DoubleTallGrass, Flower, DoubleFlower, NetherSprouts, PinkPetals, SugarCane, DeadBush, Azalea, Sapling: + case ShortGrass, Fern, DoubleTallGrass, Flower, DoubleFlower, NetherSprouts, PinkPetals, SugarCane, DeadBush, Azalea, Sapling, BambooSapling, Bamboo: return true } return false diff --git a/server/block/gravel.go b/server/block/gravel.go index 4326d59880..38e103a816 100644 --- a/server/block/gravel.go +++ b/server/block/gravel.go @@ -15,6 +15,15 @@ type Gravel struct { snare } +// SoilFor ... +func (g Gravel) SoilFor(block world.Block) bool { + switch block.(type) { + case BambooSapling, Bamboo: + return true + } + return false +} + // NeighbourUpdateTick ... func (g Gravel) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { g.fall(g, pos, tx) diff --git a/server/block/grindstone.go b/server/block/grindstone.go index e321f2e999..18fd2b4792 100644 --- a/server/block/grindstone.go +++ b/server/block/grindstone.go @@ -21,7 +21,7 @@ type Grindstone struct { // BreakInfo ... func (g Grindstone) BreakInfo() BreakInfo { - return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(g)).withBlastResistance(30) + return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(g)).withBlastResistance(6) } // Activate ... diff --git a/server/block/hash.go b/server/block/hash.go index d6790e02cd..f706ca0967 100644 --- a/server/block/hash.go +++ b/server/block/hash.go @@ -10,6 +10,10 @@ const ( hashAncientDebris hashAndesite hashAnvil + hashBamboo + hashBambooBlock + hashBambooMosaic + hashBambooSapling hashAzalea hashAzaleaLeaves hashBanner @@ -36,6 +40,8 @@ const ( hashCarrot hashChest hashChiseledQuartz + hashCinnabar + hashCinnabarBricks hashClay hashCoal hashCoalOre @@ -78,6 +84,8 @@ const ( hashEmeraldOre hashEnchantingTable hashEndBricks + hashEndPortal + hashEndPortalFrame hashEndRod hashEndStone hashEnderChest @@ -154,7 +162,10 @@ const ( hashPlanks hashPodzol hashPolishedBlackstoneBrick + hashPolishedCinnabar + hashPolishedSulfur hashPolishedTuff + hashPortal hashPotato hashPrismarine hashPumpkin @@ -183,6 +194,7 @@ const ( hashSeagrass hashShortGrass hashShroomlight + hashShulkerBox hashSign hashSkull hashSlab @@ -205,8 +217,11 @@ const ( hashStonecutter hashString hashSugarCane + hashSulfur + hashSulfurBricks hashTNT hashTerracotta + hashTintedGlass hashTorch hashTuff hashTuffBricks @@ -252,6 +267,22 @@ func (a Anvil) Hash() (uint64, uint64) { return hashAnvil, uint64(a.Type.Uint8()) | uint64(a.Facing)<<2 } +func (b Bamboo) Hash() (uint64, uint64) { + return hashBamboo, uint64(boolByte(b.Ready)) | uint64(boolByte(b.Thick))<<1 | uint64(b.LeafSize.Uint8())<<2 +} + +func (b BambooBlock) Hash() (uint64, uint64) { + return hashBambooBlock, uint64(b.Axis) | uint64(boolByte(b.Stripped))<<2 +} + +func (BambooMosaic) Hash() (uint64, uint64) { + return hashBambooMosaic, 0 +} + +func (b BambooSapling) Hash() (uint64, uint64) { + return hashBambooSapling, uint64(boolByte(b.Ready)) +} + func (a Azalea) Hash() (uint64, uint64) { return hashAzalea, uint64(boolByte(a.Flowering)) } @@ -321,7 +352,7 @@ func (c Cactus) Hash() (uint64, uint64) { } func (c Cake) Hash() (uint64, uint64) { - return hashCake, uint64(c.Bites) + return hashCake, uint64(c.Bites) | uint64(boolByte(c.Candle))<<8 | uint64(c.CandleColour.Uint8())<<9 | uint64(boolByte(c.CandleLit))<<14 } func (Calcite) Hash() (uint64, uint64) { @@ -352,6 +383,14 @@ func (ChiseledQuartz) Hash() (uint64, uint64) { return hashChiseledQuartz, 0 } +func (c Cinnabar) Hash() (uint64, uint64) { + return hashCinnabar, uint64(boolByte(c.Chiseled)) +} + +func (CinnabarBricks) Hash() (uint64, uint64) { + return hashCinnabarBricks, 0 +} + func (Clay) Hash() (uint64, uint64) { return hashClay, 0 } @@ -520,6 +559,14 @@ func (EndBricks) Hash() (uint64, uint64) { return hashEndBricks, 0 } +func (EndPortal) Hash() (uint64, uint64) { + return hashEndPortal, 0 +} + +func (f EndPortalFrame) Hash() (uint64, uint64) { + return hashEndPortalFrame, uint64(boolByte(f.Eye)) | uint64(f.Facing)<<1 +} + func (e EndRod) Hash() (uint64, uint64) { return hashEndRod, uint64(e.Facing) } @@ -824,10 +871,22 @@ func (b PolishedBlackstoneBrick) Hash() (uint64, uint64) { return hashPolishedBlackstoneBrick, uint64(boolByte(b.Cracked)) } +func (PolishedCinnabar) Hash() (uint64, uint64) { + return hashPolishedCinnabar, 0 +} + +func (PolishedSulfur) Hash() (uint64, uint64) { + return hashPolishedSulfur, 0 +} + func (PolishedTuff) Hash() (uint64, uint64) { return hashPolishedTuff, 0 } +func (p Portal) Hash() (uint64, uint64) { + return hashPortal, uint64(p.Axis) +} + func (p Potato) Hash() (uint64, uint64) { return hashPotato, uint64(p.Growth) } @@ -940,6 +999,10 @@ func (Shroomlight) Hash() (uint64, uint64) { return hashShroomlight, 0 } +func (s ShulkerBox) Hash() (uint64, uint64) { + return hashShulkerBox, uint64(s.Colour.Uint8()) +} + func (s Sign) Hash() (uint64, uint64) { return hashSign, uint64(s.Wood.Uint8()) | uint64(s.Attach.Uint8())<<4 } @@ -1028,6 +1091,14 @@ func (c SugarCane) Hash() (uint64, uint64) { return hashSugarCane, uint64(c.Age) } +func (s Sulfur) Hash() (uint64, uint64) { + return hashSulfur, uint64(boolByte(s.Chiseled)) +} + +func (SulfurBricks) Hash() (uint64, uint64) { + return hashSulfurBricks, 0 +} + func (TNT) Hash() (uint64, uint64) { return hashTNT, 0 } @@ -1036,6 +1107,10 @@ func (Terracotta) Hash() (uint64, uint64) { return hashTerracotta, 0 } +func (TintedGlass) Hash() (uint64, uint64) { + return hashTintedGlass, 0 +} + func (t Torch) Hash() (uint64, uint64) { return hashTorch, uint64(t.Facing) | uint64(t.Type.Uint8())<<3 } diff --git a/server/block/hopper.go b/server/block/hopper.go index 14a86a637b..d6f455dc06 100644 --- a/server/block/hopper.go +++ b/server/block/hopper.go @@ -28,8 +28,6 @@ type Hopper struct { // colour codes. CustomName string - // LastTick is the last world tick that the hopper was ticked. - LastTick int64 // TransferCooldown is the duration in ticks until the hopper can transfer items again. TransferCooldown int64 // CollectCooldown is the duration in ticks until the hopper can collect items again. @@ -74,7 +72,7 @@ func (Hopper) CanRedstoneWireStepDown(cube.Pos, cube.Pos, *world.Tx) bool { // BreakInfo ... func (h Hopper) BreakInfo() BreakInfo { - return newBreakInfo(3, pickaxeHarvestable, pickaxeEffective, oneOf(Hopper{})).withBlastResistance(24).withBreakHandler(func(pos cube.Pos, tx *world.Tx, u item.User) { + return newBreakInfo(3, pickaxeHarvestable, pickaxeEffective, oneOf(Hopper{})).withBlastResistance(4.8).withBreakHandler(func(pos cube.Pos, tx *world.Tx, u item.User) { for _, i := range h.Inventory(tx, pos).Clear() { dropItem(tx, i, pos.Vec3()) } @@ -134,20 +132,28 @@ func (h Hopper) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world } // Tick ... -func (h Hopper) Tick(currentTick int64, pos cube.Pos, tx *world.Tx) { - h.TransferCooldown-- - h.CollectCooldown-- - h.LastTick = currentTick +func (h Hopper) Tick(_ int64, pos cube.Pos, tx *world.Tx) { + cooldownChanged := h.TransferCooldown > 0 || h.CollectCooldown > 0 + if h.TransferCooldown > 0 { + h.TransferCooldown-- + } + if h.CollectCooldown > 0 { + h.CollectCooldown-- + } if !h.Powered && h.TransferCooldown <= 0 { inserted := h.insertItem(pos, tx) extracted := h.extractItem(pos, tx) if inserted || extracted { h.TransferCooldown = 8 + tx.SetBlock(pos, h, nil) + return } } - tx.SetBlock(pos, h, nil) + if cooldownChanged { + tx.SetBlockEntity(pos, h) + } } // HopperInsertable represents a block that can have its contents inserted into by a hopper. @@ -171,7 +177,7 @@ func (h Hopper) insertItem(pos cube.Pos, tx *world.Tx) bool { continue } - _, err := container.Inventory(tx, pos).AddItem(sourceStack.Grow(-sourceStack.Count() + 1)) + _, err := container.Inventory(tx, destPos).AddItem(sourceStack.Grow(-sourceStack.Count() + 1)) if err != nil { // The destination is full. return false diff --git a/server/block/iron.go b/server/block/iron.go index 87903c9957..f2aa9f05c1 100644 --- a/server/block/iron.go +++ b/server/block/iron.go @@ -19,7 +19,7 @@ func (i Iron) Instrument() sound.Instrument { func (i Iron) BreakInfo() BreakInfo { return newBreakInfo(5, func(t item.Tool) bool { return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierStone.HarvestLevel - }, pickaxeEffective, oneOf(i)).withBlastResistance(30) + }, pickaxeEffective, oneOf(i)).withBlastResistance(6) } // PowersBeacon ... diff --git a/server/block/iron_bars.go b/server/block/iron_bars.go index 24ecdceb32..45e11de9a4 100644 --- a/server/block/iron_bars.go +++ b/server/block/iron_bars.go @@ -14,7 +14,7 @@ type IronBars struct { // BreakInfo ... func (i IronBars) BreakInfo() BreakInfo { - return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(i)).withBlastResistance(30) + return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(i)).withBlastResistance(6) } // SideClosed ... diff --git a/server/block/iron_chain.go b/server/block/iron_chain.go index a5c50a5387..a266b34a58 100644 --- a/server/block/iron_chain.go +++ b/server/block/iron_chain.go @@ -36,7 +36,7 @@ func (c IronChain) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *wo // BreakInfo ... func (c IronChain) BreakInfo() BreakInfo { - return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(c)).withBlastResistance(30) + return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(c)).withBlastResistance(6) } // EncodeItem ... diff --git a/server/block/iron_ore.go b/server/block/iron_ore.go index 6a78ef68d1..cc9609a2f9 100644 --- a/server/block/iron_ore.go +++ b/server/block/iron_ore.go @@ -17,7 +17,7 @@ type IronOre struct { func (i IronOre) BreakInfo() BreakInfo { return newBreakInfo(i.Type.Hardness(), func(t item.Tool) bool { return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierStone.HarvestLevel - }, pickaxeEffective, oreDrops(item.RawIron{}, i)).withBlastResistance(15) + }, pickaxeEffective, oreDrops(item.RawIron{}, i)).withBlastResistance(3) } // SmeltInfo ... diff --git a/server/block/item_frame.go b/server/block/item_frame.go index 2eb96e210e..d5b6311bc7 100644 --- a/server/block/item_frame.go +++ b/server/block/item_frame.go @@ -119,7 +119,7 @@ func (i ItemFrame) EncodeBlock() (name string, properties map[string]any) { func (i ItemFrame) DecodeNBT(data map[string]any) any { i.DropChance = float64(nbtconv.Float32(data, "ItemDropChance")) i.Rotations = int(nbtconv.Uint8(data, "ItemRotation")) - i.Item = nbtconv.MapItem(data, "Item") + i.Item = item.MapNBT(data, "Item") return i } @@ -134,7 +134,7 @@ func (i ItemFrame) EncodeNBT() map[string]any { m["id"] = "GlowItemFrame" } if !i.Item.Empty() { - m["Item"] = nbtconv.WriteItem(i.Item, true) + m["Item"] = item.WriteNBT(i.Item, true) } return m } diff --git a/server/block/jukebox.go b/server/block/jukebox.go index c56b9a8688..bd2898387d 100644 --- a/server/block/jukebox.go +++ b/server/block/jukebox.go @@ -5,7 +5,6 @@ import ( "time" "github.com/df-mc/dragonfly/server/block/cube" - "github.com/df-mc/dragonfly/server/internal/nbtconv" "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/world" "github.com/df-mc/dragonfly/server/world/sound" @@ -56,7 +55,7 @@ func (j Jukebox) FuelInfo() item.FuelInfo { // BreakInfo ... func (j Jukebox) BreakInfo() BreakInfo { - return newBreakInfo(2, alwaysHarvestable, axeEffective, oneOf(Jukebox{})).withBlastResistance(30).withBreakHandler(func(pos cube.Pos, tx *world.Tx, u item.User) { + return newBreakInfo(2, alwaysHarvestable, axeEffective, oneOf(Jukebox{})).withBlastResistance(6).withBreakHandler(func(pos cube.Pos, tx *world.Tx, u item.User) { if _, hasDisc := j.Disc(); hasDisc { dropItem(tx, j.Item, pos.Vec3()) tx.PlaySound(pos.Vec3Centre(), sound.MusicDiscEnd{}) @@ -110,14 +109,14 @@ func (j Jukebox) Disc() (sound.DiscType, bool) { func (j Jukebox) EncodeNBT() map[string]any { m := map[string]any{"id": "Jukebox"} if _, hasDisc := j.Disc(); hasDisc { - m["RecordItem"] = nbtconv.WriteItem(j.Item, true) + m["RecordItem"] = item.WriteNBT(j.Item, true) } return m } // DecodeNBT ... func (j Jukebox) DecodeNBT(data map[string]any) any { - s := nbtconv.MapItem(data, "RecordItem") + s := item.MapNBT(data, "RecordItem") if _, ok := s.Item().(item.MusicDisc); ok { j.Item = s } diff --git a/server/block/lapis_ore.go b/server/block/lapis_ore.go index 56b12ab468..8d7ff6e397 100644 --- a/server/block/lapis_ore.go +++ b/server/block/lapis_ore.go @@ -17,7 +17,7 @@ type LapisOre struct { func (l LapisOre) BreakInfo() BreakInfo { return newBreakInfo(l.Type.Hardness(), func(t item.Tool) bool { return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierStone.HarvestLevel - }, pickaxeEffective, multiOreDrops(item.LapisLazuli{}, l, 4, 9)).withXPDropRange(2, 5).withBlastResistance(15) + }, pickaxeEffective, multiOreDrops(item.LapisLazuli{}, l, 4, 9)).withXPDropRange(2, 5).withBlastResistance(3) } // SmeltInfo ... diff --git a/server/block/lava.go b/server/block/lava.go index 9899d7b93b..67bf804727 100644 --- a/server/block/lava.go +++ b/server/block/lava.go @@ -5,7 +5,6 @@ import ( "time" "github.com/df-mc/dragonfly/server/block/cube" - "github.com/df-mc/dragonfly/server/event" "github.com/df-mc/dragonfly/server/world" "github.com/df-mc/dragonfly/server/world/sound" ) @@ -139,9 +138,9 @@ func (l Lava) LiquidFalling() bool { return l.Falling } -// BlastResistance always returns 500. +// BlastResistance ... func (Lava) BlastResistance() float64 { - return 500 + return 100 } // LiquidType returns 10 as a unique identifier for the lava liquid. @@ -177,7 +176,7 @@ func (l Lava) Harden(pos cube.Pos, tx *world.Tx, flownIntoBy *cube.Pos) bool { } }, tx.Range()) if b != nil { - ctx := event.C(tx) + ctx := tx.Event() if tx.World().Handler().HandleLiquidHarden(ctx, pos, l, water, b); ctx.Cancelled() { return false } @@ -197,7 +196,7 @@ func (l Lava) Harden(pos cube.Pos, tx *world.Tx, flownIntoBy *cube.Pos) bool { } else { b = Cobblestone{} } - ctx := event.C(tx) + ctx := tx.Event() if tx.World().Handler().HandleLiquidHarden(ctx, pos, l, water, b); ctx.Cancelled() { return false } diff --git a/server/block/leaves.go b/server/block/leaves.go index 8ca2b33ab5..b7f17bd396 100644 --- a/server/block/leaves.go +++ b/server/block/leaves.go @@ -4,7 +4,6 @@ import ( "math/rand/v2" "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/world" "github.com/go-gl/mathgl/mgl64" @@ -68,7 +67,7 @@ func (l Leaves) RandomTick(pos cube.Pos, tx *world.Tx, _ *rand.Rand) { tx.SetBlock(pos, l, nil) return } - ctx := event.C(tx) + ctx := tx.Event() if tx.World().Handler().HandleLeavesDecay(ctx, pos); ctx.Cancelled() { // Prevent immediate re-updating. l.ShouldUpdate = false diff --git a/server/block/lectern.go b/server/block/lectern.go index fee6c6fe19..2af563e72f 100644 --- a/server/block/lectern.go +++ b/server/block/lectern.go @@ -88,7 +88,8 @@ func (l Lectern) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, return false } - l.Book, l.Page = held, 0 + // Only one book is taken from the stack held, so only one may be put on the lectern. + l.Book, l.Page = held.Grow(-held.Count()+1), 0 tx.SetBlock(pos, l, nil) tx.PlaySound(pos.Vec3Centre(), sound.LecternBookPlace{}) @@ -135,7 +136,7 @@ func (l Lectern) EncodeNBT() map[string]any { "id": "Lectern", } if r, ok := l.Book.Item().(readableBook); ok { - m["book"] = nbtconv.WriteItem(l.Book, true) + m["book"] = item.WriteNBT(l.Book, true) m["totalPages"] = int32(r.TotalPages()) } return m @@ -144,7 +145,7 @@ func (l Lectern) EncodeNBT() map[string]any { // DecodeNBT ... func (l Lectern) DecodeNBT(m map[string]any) any { l.Page = int(nbtconv.Int32(m, "page")) - l.Book = nbtconv.MapItem(m, "book") + l.Book = item.MapNBT(m, "book") return l } diff --git a/server/block/lever.go b/server/block/lever.go index 10a2cacbea..3dcf03505b 100644 --- a/server/block/lever.go +++ b/server/block/lever.go @@ -23,33 +23,24 @@ type Lever struct { Direction cube.Direction } -// RedstoneSource ... -func (l Lever) RedstoneSource() bool { - return true -} - -// WeakPower ... -func (l Lever) WeakPower(cube.Pos, cube.Face, *world.Tx, bool) int { +func (l Lever) RedstonePower(cube.Pos, *world.Tx, cube.Face) int { if l.Powered { return 15 } return 0 } -// StrongPower ... -func (l Lever) StrongPower(_ cube.Pos, face cube.Face, _ *world.Tx, _ bool) int { - if l.Powered && l.Facing == face { +func (l Lever) RedstoneStrongPower(_ cube.Pos, _ *world.Tx, face cube.Face) int { + if l.Powered && l.Facing.Opposite() == face { return 15 } return 0 } -// SideClosed ... func (l Lever) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { return false } -// NeighbourUpdateTick ... func (l Lever) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { supportPos := pos.Side(l.Facing.Opposite()) if !tx.Block(supportPos).Model().FaceSolid(supportPos, l.Facing, tx) { @@ -57,7 +48,6 @@ func (l Lever) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { } } -// UseOnBlock ... func (l Lever) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { pos, face, used := firstReplaceable(tx, pos, face, l) if !used { @@ -78,7 +68,6 @@ func (l Lever) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world. return placed(ctx) } -// Activate ... func (l Lever) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, _ item.User, _ *item.UseContext) bool { l.Powered = !l.Powered tx.SetBlock(pos, l, nil) @@ -87,23 +76,17 @@ func (l Lever) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, _ item.User, _ } else { tx.PlaySound(pos.Vec3Centre(), sound.PowerOff{}) } - updateDirectionalRedstone(pos, tx, l.Facing.Opposite()) return true } -// BreakInfo ... func (l Lever) BreakInfo() BreakInfo { - return newBreakInfo(0.5, alwaysHarvestable, nothingEffective, oneOf(Lever{})).withBreakHandler(func(pos cube.Pos, tx *world.Tx, _ item.User) { - updateDirectionalRedstone(pos, tx, l.Facing.Opposite()) - }) + return newBreakInfo(0.5, alwaysHarvestable, nothingEffective, oneOf(Lever{})) } -// EncodeItem ... func (l Lever) EncodeItem() (name string, meta int16) { return "minecraft:lever", 0 } -// EncodeBlock ... func (l Lever) EncodeBlock() (string, map[string]any) { direction := l.Facing.String() if l.Facing == cube.FaceDown || l.Facing == cube.FaceUp { @@ -116,7 +99,6 @@ func (l Lever) EncodeBlock() (string, map[string]any) { return "minecraft:lever", map[string]any{"open_bit": l.Powered, "lever_direction": direction} } -// allLevers ... func allLevers() (all []world.Block) { f := func(facing cube.Face, direction cube.Direction) { all = append(all, Lever{Facing: facing, Direction: direction}) diff --git a/server/block/liquid.go b/server/block/liquid.go index df72fa2890..9954ca3693 100644 --- a/server/block/liquid.go +++ b/server/block/liquid.go @@ -5,7 +5,6 @@ import ( "sync" "github.com/df-mc/dragonfly/server/block/cube" - "github.com/df-mc/dragonfly/server/event" "github.com/df-mc/dragonfly/server/world" ) @@ -42,7 +41,7 @@ func tickLiquid(b world.Liquid, pos cube.Pos, tx *world.Tx) { if b.LiquidDepth()-4 > 0 { res = b.WithDepth(b.LiquidDepth()-2*b.SpreadDecay(), false) } - ctx := event.C(tx) + ctx := tx.Event() if tx.World().Handler().HandleLiquidDecay(ctx, pos, b, res); ctx.Cancelled() { return } @@ -138,7 +137,7 @@ func flowInto(b world.Liquid, src, pos cube.Pos, tx *world.Tx, falling bool) boo // (basically considered full depth), so no need to continue. return true } - ctx := event.C(tx) + ctx := tx.Event() if tx.World().Handler().HandleLiquidFlow(ctx, src, pos, b.WithDepth(newDepth, falling), existing); ctx.Cancelled() { return false } @@ -160,7 +159,7 @@ func flowInto(b world.Liquid, src, pos cube.Pos, tx *world.Tx, falling bool) boo // Can't flow into this block. return false } - ctx := event.C(tx) + ctx := tx.Event() if tx.World().Handler().HandleLiquidFlow(ctx, src, pos, b.WithDepth(newDepth, falling), existing); ctx.Cancelled() { return false } diff --git a/server/block/log.go b/server/block/log.go index 9303dd9f9e..de85e18a06 100644 --- a/server/block/log.go +++ b/server/block/log.go @@ -34,7 +34,12 @@ func (l Log) FlammabilityInfo() FlammabilityInfo { // BreakInfo ... func (l Log) BreakInfo() BreakInfo { - return newBreakInfo(2, alwaysHarvestable, axeEffective, oneOf(l)) + breakInfo := newBreakInfo(2, alwaysHarvestable, axeEffective, oneOf(l)) + if l.Wood == MangroveWood() && !l.Stripped { + // Mangrove logs have a much lower blast resistance than all other logs. + return breakInfo.withBlastResistance(0.4) + } + return breakInfo } // SmeltInfo ... @@ -106,6 +111,9 @@ func (l Log) EncodeBlock() (name string, properties map[string]any) { // allLogs returns a list of all possible log states. func allLogs() (logs []world.Block) { for _, w := range WoodTypes() { + if w == BambooWood() { + continue + } for axis := cube.Axis(0); axis < 3; axis++ { logs = append(logs, Log{Axis: axis, Stripped: true, Wood: w}) logs = append(logs, Log{Axis: axis, Stripped: false, Wood: w}) diff --git a/server/block/magma.go b/server/block/magma.go index 351e4fd5e6..9adff8d6e4 100644 --- a/server/block/magma.go +++ b/server/block/magma.go @@ -34,7 +34,7 @@ func (Magma) EntityStepOn(_ cube.Pos, _ *world.Tx, e world.Entity) { // BreakInfo ... func (m Magma) BreakInfo() BreakInfo { - return newBreakInfo(0.5, pickaxeHarvestable, pickaxeEffective, oneOf(m)).withBlastResistance(30) + return newBreakInfo(0.5, pickaxeHarvestable, pickaxeEffective, oneOf(m)) } // EncodeItem ... diff --git a/server/block/model/bamboo.go b/server/block/model/bamboo.go new file mode 100644 index 0000000000..073f50f82f --- /dev/null +++ b/server/block/model/bamboo.go @@ -0,0 +1,27 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Bamboo is a model used by bamboo. +type Bamboo struct { + Thick bool +} + +// BBox ... +func (b Bamboo) BBox(pos cube.Pos, s world.BlockSource) []cube.BBox { + // The stalk's box extends from the block's centre towards positive X and Z. + size := 0.5 + 2.0/16.0 + if b.Thick { + size = 0.5 + 3.0/16.0 + } + offset := randomOffset(pos, -0.25, 0.25, 16) + return []cube.BBox{cube.Box(0.5, 0, 0.5, size, 1, size).Translate(offset)} +} + +// FaceSolid ... +func (b Bamboo) FaceSolid(pos cube.Pos, face cube.Face, s world.BlockSource) bool { + return false +} diff --git a/server/block/model/end_portal_frame.go b/server/block/model/end_portal_frame.go new file mode 100644 index 0000000000..c6e4c160a6 --- /dev/null +++ b/server/block/model/end_portal_frame.go @@ -0,0 +1,19 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// EndPortalFrame is the model of a 13/16 tall, full-width end portal frame. +type EndPortalFrame struct{} + +// BBox ... +func (EndPortalFrame) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return []cube.BBox{cube.Box(0, 0, 0, 1, 0.8125, 1)} +} + +// FaceSolid returns true only for the down face. +func (EndPortalFrame) FaceSolid(_ cube.Pos, face cube.Face, _ world.BlockSource) bool { + return face == cube.FaceDown +} diff --git a/server/block/model/offset.go b/server/block/model/offset.go new file mode 100644 index 0000000000..75f998d150 --- /dev/null +++ b/server/block/model/offset.go @@ -0,0 +1,51 @@ +package model + +import ( + "math" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/internal/mcrandom" + "github.com/go-gl/mathgl/mgl64" +) + +// randomOffset returns the deterministic horizontal offset vanilla applies to +// the models of certain blocks, such as bamboo, at the position passed. +func randomOffset(pos cube.Pos, mn, mx float32, steps int) mgl64.Vec3 { + seed := offsetSeed(int32(pos.X()), int32(pos.Z())) + s0 := mcrandom.MixStafford13(seed) + s1 := mcrandom.MixStafford13(seed + 0x9E3779B97F4A7C15) + prng := mcrandom.NewXoroshiro128PlusPlus(s0, s1) + x := offsetValue(mn, mx, steps, randomFloat32(prng.Next())) + prng.Next() // The Y offset is always zero, but its draw is still consumed. + z := offsetValue(mn, mx, steps, randomFloat32(prng.Next())) + return mgl64.Vec3{float64(x), 0, float64(z)} +} + +// offsetSeed returns the seed vanilla uses for random model offsets. Both coordinate +// products use signed 64-bit arithmetic. The signed 32-bit extraction after the +// nonlinear mix is intentional. +func offsetSeed(x, z int32) uint64 { + v := int64(z)*116129781 ^ int64(x)*0x2fc20f + v = int64(int32(uint64(v*(v*42317861+11)) >> 16)) + return uint64(v) ^ 0x6A09E667F3BCC909 +} + +// randomFloat32 converts a random uint64 to a float in [0, 1). +func randomFloat32(random uint64) float32 { + return float32(random>>40) * (1.0 / 16777216.0) +} + +// offsetValue quantizes a random float to one of steps values in [mn, mx]. +func offsetValue(mn, mx float32, steps int, random float32) float32 { + if mn >= mx { + return mn + } + if steps == 1 { + return (mn + mx) * 0.5 + } + if steps > 1 { + index := float32(math.Floor(float64(float32(steps) * random))) + return mn + index*(mx-mn)/float32(steps-1) + } + return mn + (mx-mn)*random +} diff --git a/server/block/model/portal.go b/server/block/model/portal.go new file mode 100644 index 0000000000..a0007aba43 --- /dev/null +++ b/server/block/model/portal.go @@ -0,0 +1,22 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Portal is a model used by portal blocks. +type Portal struct { + // Axis is the axis that the portal faces. + Axis cube.Axis +} + +// BBox ... +func (Portal) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return nil +} + +// FaceSolid ... +func (Portal) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/model/shulker.go b/server/block/model/shulker.go new file mode 100644 index 0000000000..428f4b1674 --- /dev/null +++ b/server/block/model/shulker.go @@ -0,0 +1,35 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Shulker is the model of a shulker box. The bounding box grows along the +// facing axis as the lid opens. +type Shulker struct { + // Facing is the direction that the lid opens towards. + Facing cube.Face + // Progress is the lid animation progress, ranging from 0 (closed) to 10 (fully open). + Progress int32 +} + +// BBox returns a single bounding box that extends outward along Facing as the +// lid opens. +func (s Shulker) BBox(cube.Pos, world.BlockSource) []cube.BBox { + peak := ShulkerPhysicalPeak(s.Progress) + return []cube.BBox{full.ExtendTowards(s.Facing, peak)} +} + +// ShulkerPhysicalPeak returns the lid extension along the facing axis for a +// given Progress in [0, 10]. The curve eases out cubically so the lid moves +// quickly and settles. +func ShulkerPhysicalPeak(progress int32) float64 { + t := float64(progress) / 10.0 + return (1.0 - (1.0-t)*(1.0-t)*(1.0-t)) * 0.5 +} + +// FaceSolid always returns false. +func (Shulker) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/mud.go b/server/block/mud.go index d37034f8c1..7d8463f9ea 100644 --- a/server/block/mud.go +++ b/server/block/mud.go @@ -10,7 +10,7 @@ type Mud struct { // SoilFor ... func (Mud) SoilFor(block world.Block) bool { switch block.(type) { - case ShortGrass, Fern, DoubleTallGrass, Flower, DoubleFlower, NetherSprouts, PinkPetals, DeadBush, Azalea: + case ShortGrass, Fern, DoubleTallGrass, Flower, DoubleFlower, NetherSprouts, PinkPetals, DeadBush, Azalea, BambooSapling, Bamboo: return true } return false diff --git a/server/block/mud_bricks.go b/server/block/mud_bricks.go index 1acf700c71..9e172c35aa 100644 --- a/server/block/mud_bricks.go +++ b/server/block/mud_bricks.go @@ -8,7 +8,7 @@ type MudBricks struct { // BreakInfo ... func (m MudBricks) BreakInfo() BreakInfo { - return newBreakInfo(1.5, alwaysHarvestable, nothingEffective, oneOf(m)).withBlastResistance(15) + return newBreakInfo(1.5, alwaysHarvestable, nothingEffective, oneOf(m)).withBlastResistance(3) } // EncodeItem ... diff --git a/server/block/muddy_mangrove_roots.go b/server/block/muddy_mangrove_roots.go index 462e65d8c2..19d180a2ee 100644 --- a/server/block/muddy_mangrove_roots.go +++ b/server/block/muddy_mangrove_roots.go @@ -23,7 +23,7 @@ func (m MuddyMangroveRoots) BreakInfo() BreakInfo { // SoilFor ... func (MuddyMangroveRoots) SoilFor(block world.Block) bool { switch block.(type) { - case ShortGrass, Fern, DoubleTallGrass, Flower, DoubleFlower, NetherSprouts, PinkPetals, Azalea: + case ShortGrass, Fern, DoubleTallGrass, Flower, DoubleFlower, NetherSprouts, PinkPetals, Azalea, BambooSapling, Bamboo: return true } return false diff --git a/server/block/nether_brick_fence.go b/server/block/nether_brick_fence.go index 841b1cb396..8761a66535 100644 --- a/server/block/nether_brick_fence.go +++ b/server/block/nether_brick_fence.go @@ -14,7 +14,7 @@ type NetherBrickFence struct { // BreakInfo ... func (n NetherBrickFence) BreakInfo() BreakInfo { - return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(n)).withBlastResistance(30) + return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(n)).withBlastResistance(6) } // SideClosed ... diff --git a/server/block/nether_bricks.go b/server/block/nether_bricks.go index 3b12f64238..f81c48f10e 100644 --- a/server/block/nether_bricks.go +++ b/server/block/nether_bricks.go @@ -17,7 +17,7 @@ type NetherBricks struct { // BreakInfo ... func (n NetherBricks) BreakInfo() BreakInfo { - return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(n)).withBlastResistance(30) + return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(n)).withBlastResistance(6) } // SmeltInfo ... diff --git a/server/block/netherite.go b/server/block/netherite.go index 307bc2263f..f10c723a35 100644 --- a/server/block/netherite.go +++ b/server/block/netherite.go @@ -14,7 +14,7 @@ type Netherite struct { func (n Netherite) BreakInfo() BreakInfo { return newBreakInfo(50, func(t item.Tool) bool { return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierDiamond.HarvestLevel - }, pickaxeEffective, oneOf(n)).withBlastResistance(6000) + }, pickaxeEffective, oneOf(n)).withBlastResistance(1200) } // PowersBeacon ... diff --git a/server/block/note.go b/server/block/note.go index 719d9cd52d..a4095c42b4 100644 --- a/server/block/note.go +++ b/server/block/note.go @@ -22,13 +22,13 @@ type Note struct { Powered bool } -// playNote ... +// playNote emits the configured note sound and particle at pos. func (n Note) playNote(pos cube.Pos, tx *world.Tx) { tx.PlaySound(pos.Vec3(), sound.Note{Instrument: n.instrument(pos, tx), Pitch: n.Pitch}) tx.AddParticle(pos.Vec3(), particle.Note{Instrument: n.Instrument(), Pitch: n.Pitch}) } -// updateInstrument ... +// instrument returns the note block instrument selected by the block below it. func (n Note) instrument(pos cube.Pos, tx *world.Tx) sound.Instrument { if instrumentBlock, ok := tx.Block(pos.Side(cube.FaceDown)).(interface { Instrument() sound.Instrument @@ -38,19 +38,16 @@ func (n Note) instrument(pos cube.Pos, tx *world.Tx) sound.Instrument { return sound.Piano() } -// DecodeNBT ... func (n Note) DecodeNBT(data map[string]any) any { n.Pitch = int(nbtconv.Uint8(data, "note")) n.Powered = nbtconv.Bool(data, "powered") return n } -// EncodeNBT ... func (n Note) EncodeNBT() map[string]any { return map[string]any{"note": byte(n.Pitch), "powered": boolByte(n.Powered)} } -// Activate tunes and plays the note block when there is room above it. func (n Note) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, _ item.User, _ *item.UseContext) bool { if !n.canPlay(pos, tx) { return false @@ -61,19 +58,24 @@ func (n Note) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, _ item.User, _ * return true } -// RedstoneUpdate updates the note block's powered state and plays the note when it first becomes powered. -func (n Note) RedstoneUpdate(pos cube.Pos, tx *world.Tx) { - poweredFaces := n.poweredFaces(pos, tx) - powered := len(poweredFaces) > 0 +// RedstonePowerUpdate records power changes; sound is deferred to post-update so cancellation can suppress it. +func (n Note) RedstonePowerUpdate(pos cube.Pos, tx *world.Tx, power int) (world.Block, bool) { + powered := power > 0 if powered == n.Powered { - return + return n, false } n.Powered = powered - if powered && n.canPlay(pos, tx) { - n.playNote(pos, tx) + return n, true +} + +// RedstonePowerPostUpdate plays the note after an uncancelled rising redstone edge. +func (n Note) RedstonePowerPostUpdate(pos cube.Pos, tx *world.Tx, before, after world.Block, _, _ int) { + beforeNote, beforeOK := before.(Note) + afterNote, afterOK := after.(Note) + if !beforeOK || !afterOK || beforeNote.Powered || !afterNote.Powered || !afterNote.canPlay(pos, tx) { + return } - tx.SetBlock(pos, n, &world.SetOpts{DisableBlockUpdates: true}) - updateAroundRedstone(pos, tx, poweredFaces...) + afterNote.playNote(pos, tx) } // canPlay reports whether the block above the note block is air. @@ -82,33 +84,18 @@ func (n Note) canPlay(pos cube.Pos, tx *world.Tx) bool { return ok } -func (n Note) poweredFaces(pos cube.Pos, tx *world.Tx) []cube.Face { - var faces []cube.Face - for _, face := range cube.Faces() { - adjacentPos := pos.Side(face) - if power := tx.RedstonePower(adjacentPos, face, true); power > 0 { - faces = append(faces, face) - } - } - return faces -} - -// BreakInfo ... func (n Note) BreakInfo() BreakInfo { return newBreakInfo(0.8, alwaysHarvestable, axeEffective, oneOf(Note{})) } -// FuelInfo ... func (Note) FuelInfo() item.FuelInfo { return newFuelInfo(time.Second * 15) } -// EncodeItem ... func (n Note) EncodeItem() (name string, meta int16) { return "minecraft:noteblock", 0 } -// EncodeBlock ... func (n Note) EncodeBlock() (name string, properties map[string]any) { return "minecraft:noteblock", nil } diff --git a/server/block/obsidian.go b/server/block/obsidian.go index 5d99bd4a0d..39ad975d83 100644 --- a/server/block/obsidian.go +++ b/server/block/obsidian.go @@ -2,6 +2,7 @@ package block import ( "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" ) // Obsidian is a dark purple block known for its high blast resistance and strength, most commonly found when @@ -37,9 +38,20 @@ func (o Obsidian) EncodeBlock() (string, map[string]any) { return "minecraft:obsidian", nil } +// Frame returns true if the block can form part of a nether portal frame in the dimension passed. Crying obsidian +// cannot be used as a frame block. +func (o Obsidian) Frame(dimension world.Dimension) bool { + return dimension == world.Nether && !o.Crying +} + +// SupportsEndCrystal returns whether an End crystal may be placed on the obsidian. +func (o Obsidian) SupportsEndCrystal() bool { + return !o.Crying +} + // BreakInfo ... func (o Obsidian) BreakInfo() BreakInfo { return newBreakInfo(35, func(t item.Tool) bool { return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierDiamond.HarvestLevel - }, pickaxeEffective, oneOf(o)).withBlastResistance(6000) + }, pickaxeEffective, oneOf(o)).withBlastResistance(1200) } diff --git a/server/block/packed_mud.go b/server/block/packed_mud.go index e3f388763a..acff516eb8 100644 --- a/server/block/packed_mud.go +++ b/server/block/packed_mud.go @@ -7,7 +7,7 @@ type PackedMud struct { // BreakInfo ... func (p PackedMud) BreakInfo() BreakInfo { - return newBreakInfo(1, alwaysHarvestable, nothingEffective, oneOf(p)).withBlastResistance(15) + return newBreakInfo(1, alwaysHarvestable, nothingEffective, oneOf(p)).withBlastResistance(3) } // EncodeItem ... diff --git a/server/block/pink_petals.go b/server/block/pink_petals.go index 3093836c08..f7ae78f56b 100644 --- a/server/block/pink_petals.go +++ b/server/block/pink_petals.go @@ -70,7 +70,7 @@ func (PinkPetals) HasLiquidDrops() bool { // BreakInfo ... func (p PinkPetals) BreakInfo() BreakInfo { - return newBreakInfo(0, alwaysHarvestable, nothingEffective, simpleDrops(item.NewStack(p, p.AdditionalCount+1))) + return newBreakInfo(0, alwaysHarvestable, nothingEffective, simpleDrops(item.NewStack(PinkPetals{}, p.AdditionalCount+1))) } // FlammabilityInfo ... diff --git a/server/block/planks.go b/server/block/planks.go index 8e08284c25..6afe7dfa26 100644 --- a/server/block/planks.go +++ b/server/block/planks.go @@ -26,7 +26,7 @@ func (p Planks) FlammabilityInfo() FlammabilityInfo { // BreakInfo ... func (p Planks) BreakInfo() BreakInfo { - return newBreakInfo(2, alwaysHarvestable, axeEffective, oneOf(p)).withBlastResistance(15) + return newBreakInfo(2, alwaysHarvestable, axeEffective, oneOf(p)).withBlastResistance(3) } // RepairsWoodTools ... diff --git a/server/block/podzol.go b/server/block/podzol.go index 0060733203..31975feb87 100644 --- a/server/block/podzol.go +++ b/server/block/podzol.go @@ -11,7 +11,7 @@ type Podzol struct { // SoilFor ... func (p Podzol) SoilFor(block world.Block) bool { switch block.(type) { - case ShortGrass, Fern, DoubleTallGrass, Flower, DoubleFlower, NetherSprouts, DeadBush, SugarCane, Azalea, Sapling: + case ShortGrass, Fern, DoubleTallGrass, Flower, DoubleFlower, NetherSprouts, DeadBush, SugarCane, Azalea, Sapling, BambooSapling, Bamboo: return true } return false diff --git a/server/block/polished_blackstone_brick.go b/server/block/polished_blackstone_brick.go index d75e67e414..dcbf90c9e4 100644 --- a/server/block/polished_blackstone_brick.go +++ b/server/block/polished_blackstone_brick.go @@ -13,7 +13,7 @@ type PolishedBlackstoneBrick struct { // BreakInfo ... func (b PolishedBlackstoneBrick) BreakInfo() BreakInfo { - return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(b)).withBlastResistance(30) + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(b)).withBlastResistance(6) } // SmeltInfo ... diff --git a/server/block/polished_cinnabar.go b/server/block/polished_cinnabar.go new file mode 100644 index 0000000000..a6cad03f54 --- /dev/null +++ b/server/block/polished_cinnabar.go @@ -0,0 +1,22 @@ +package block + +// PolishedCinnabar is a decorative variant of Cinnabar. +type PolishedCinnabar struct { + solid + bassDrum +} + +// BreakInfo ... +func (c PolishedCinnabar) BreakInfo() BreakInfo { + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(c)).withBlastResistance(6) +} + +// EncodeItem ... +func (PolishedCinnabar) EncodeItem() (name string, meta int16) { + return "minecraft:polished_cinnabar", 0 +} + +// EncodeBlock ... +func (PolishedCinnabar) EncodeBlock() (string, map[string]any) { + return "minecraft:polished_cinnabar", nil +} diff --git a/server/block/polished_sulfur.go b/server/block/polished_sulfur.go new file mode 100644 index 0000000000..5d83f2655c --- /dev/null +++ b/server/block/polished_sulfur.go @@ -0,0 +1,22 @@ +package block + +// PolishedSulfur is a decorative variant of Sulfur. +type PolishedSulfur struct { + solid + bassDrum +} + +// BreakInfo ... +func (s PolishedSulfur) BreakInfo() BreakInfo { + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(s)).withBlastResistance(6) +} + +// EncodeItem ... +func (PolishedSulfur) EncodeItem() (name string, meta int16) { + return "minecraft:polished_sulfur", 0 +} + +// EncodeBlock ... +func (PolishedSulfur) EncodeBlock() (string, map[string]any) { + return "minecraft:polished_sulfur", nil +} diff --git a/server/block/polished_tuff.go b/server/block/polished_tuff.go index 43480856e5..0a2243cfe6 100644 --- a/server/block/polished_tuff.go +++ b/server/block/polished_tuff.go @@ -10,7 +10,7 @@ type PolishedTuff struct { // BreakInfo ... func (t PolishedTuff) BreakInfo() BreakInfo { - return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(t)).withBlastResistance(30) + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(t)).withBlastResistance(6) } // EncodeItem ... diff --git a/server/block/portal.go b/server/block/portal.go new file mode 100644 index 0000000000..94aabbf899 --- /dev/null +++ b/server/block/portal.go @@ -0,0 +1,69 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/block/model" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/portal" +) + +// Portal is the translucent part of the nether portal that teleports the player to and from the Nether. +type Portal struct { + transparent + + // Axis is the axis which the portal faces. + Axis cube.Axis +} + +// portalTraveller represents an entity that can handle touching a portal block. +type portalTraveller interface { + TravelThroughPortal(tx *world.Tx, target world.Dimension) +} + +// Model ... +func (p Portal) Model() world.BlockModel { + return model.Portal{Axis: p.Axis} +} + +// Portal ... +func (Portal) Portal() world.Dimension { + return world.Nether +} + +// LightEmissionLevel returns 11. +func (Portal) LightEmissionLevel() uint8 { + return 11 +} + +// HasLiquidDrops ... +func (p Portal) HasLiquidDrops() bool { + return false +} + +// EncodeBlock ... +func (p Portal) EncodeBlock() (string, map[string]any) { + return "minecraft:portal", map[string]any{"portal_axis": p.Axis.String()} +} + +// NeighbourUpdateTick ... +func (p Portal) NeighbourUpdateTick(pos, neighbour cube.Pos, tx *world.Tx) { + face, ok := pos.NeighbourFace(neighbour) + if !ok { + return + } + axis := face.Axis() + if axis != cube.Y && axis != p.Axis { + return + } + if n, ok := portal.NetherPortalFromPos(tx, pos); ok && n.Framed() && n.Activated() { + return + } + portal.DeactivateNetherPortal(tx, pos) +} + +// EntityInside ... +func (p Portal) EntityInside(_ cube.Pos, tx *world.Tx, e world.Entity) { + if t, ok := e.(portalTraveller); ok { + t.TravelThroughPortal(tx, p.Portal()) + } +} diff --git a/server/block/prismarine.go b/server/block/prismarine.go index ea6da7a054..ceff7ae6db 100644 --- a/server/block/prismarine.go +++ b/server/block/prismarine.go @@ -15,7 +15,7 @@ type Prismarine struct { // BreakInfo ... func (p Prismarine) BreakInfo() BreakInfo { - return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(p)).withBlastResistance(30) + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(p)).withBlastResistance(6) } // EncodeItem ... diff --git a/server/block/purpur.go b/server/block/purpur.go index 0d1dafee61..c3139bb3db 100644 --- a/server/block/purpur.go +++ b/server/block/purpur.go @@ -25,7 +25,7 @@ type ( // BreakInfo ... func (p Purpur) BreakInfo() BreakInfo { - return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(p)).withBlastResistance(30) + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(p)).withBlastResistance(6) } // EncodeItem ... @@ -52,7 +52,7 @@ func (p PurpurPillar) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx // BreakInfo ... func (p PurpurPillar) BreakInfo() BreakInfo { - return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(p)).withBlastResistance(30) + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(p)).withBlastResistance(6) } // EncodeItem ... diff --git a/server/block/quartz.go b/server/block/quartz.go index 639db14cae..a560cb845c 100644 --- a/server/block/quartz.go +++ b/server/block/quartz.go @@ -45,7 +45,7 @@ func (q QuartzPillar) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx // BreakInfo ... func (q Quartz) BreakInfo() BreakInfo { if q.Smooth { - return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(q)).withBlastResistance(30) + return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(q)).withBlastResistance(6) } return newBreakInfo(0.8, pickaxeHarvestable, pickaxeEffective, oneOf(q)) } diff --git a/server/block/raw_copper.go b/server/block/raw_copper.go index 97fdb1c2df..e0fac2181c 100644 --- a/server/block/raw_copper.go +++ b/server/block/raw_copper.go @@ -14,7 +14,7 @@ type RawCopper struct { func (r RawCopper) BreakInfo() BreakInfo { return newBreakInfo(5, func(t item.Tool) bool { return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierStone.HarvestLevel - }, pickaxeEffective, oneOf(r)).withBlastResistance(30) + }, pickaxeEffective, oneOf(r)).withBlastResistance(6) } // EncodeItem ... diff --git a/server/block/raw_gold.go b/server/block/raw_gold.go index c47082bbde..e9c998b8e1 100644 --- a/server/block/raw_gold.go +++ b/server/block/raw_gold.go @@ -14,7 +14,7 @@ type RawGold struct { func (g RawGold) BreakInfo() BreakInfo { return newBreakInfo(5, func(t item.Tool) bool { return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierIron.HarvestLevel - }, pickaxeEffective, oneOf(g)).withBlastResistance(30) + }, pickaxeEffective, oneOf(g)).withBlastResistance(6) } // EncodeItem ... diff --git a/server/block/raw_iron.go b/server/block/raw_iron.go index 100919ee83..67a753773b 100644 --- a/server/block/raw_iron.go +++ b/server/block/raw_iron.go @@ -14,7 +14,7 @@ type RawIron struct { func (r RawIron) BreakInfo() BreakInfo { return newBreakInfo(5, func(t item.Tool) bool { return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierStone.HarvestLevel - }, pickaxeEffective, oneOf(r)).withBlastResistance(30) + }, pickaxeEffective, oneOf(r)).withBlastResistance(6) } // EncodeItem ... diff --git a/server/block/redstone_block.go b/server/block/redstone_block.go index 2841228426..6af40f0fd0 100644 --- a/server/block/redstone_block.go +++ b/server/block/redstone_block.go @@ -13,48 +13,35 @@ type RedstoneBlock struct { solid } -// BreakInfo ... func (r RedstoneBlock) BreakInfo() BreakInfo { - return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(r)).withBlastResistance(30).withBreakHandler(func(pos cube.Pos, tx *world.Tx, _ item.User) { - updateAroundRedstone(pos, tx) - }) + return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(r)).withBlastResistance(6) } -// EncodeItem ... func (r RedstoneBlock) EncodeItem() (name string, meta int16) { return "minecraft:redstone_block", 0 } -// EncodeBlock ... func (r RedstoneBlock) EncodeBlock() (string, map[string]any) { return "minecraft:redstone_block", nil } -// UseOnBlock ... func (r RedstoneBlock) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { pos, _, used := firstReplaceable(tx, pos, face, r) if !used { return false } place(tx, pos, r, user, ctx) - if placed(ctx) { - updateAroundRedstone(pos, tx) - return true - } - return false -} - -// RedstoneSource ... -func (r RedstoneBlock) RedstoneSource() bool { - return true + return placed(ctx) } -// WeakPower ... -func (r RedstoneBlock) WeakPower(_ cube.Pos, _ cube.Face, _ *world.Tx, _ bool) int { +func (RedstoneBlock) RedstonePower(cube.Pos, *world.Tx, cube.Face) int { return 15 } -// StrongPower ... -func (r RedstoneBlock) StrongPower(_ cube.Pos, _ cube.Face, _ *world.Tx, _ bool) int { +// RedstoneStrongPower returns no strong power. Redstone blocks power adjacent components directly, but do not strongly +// power adjacent opaque blocks. +func (RedstoneBlock) RedstoneStrongPower(cube.Pos, *world.Tx, cube.Face) int { return 0 } + +func (RedstoneBlock) RedstoneNonConductive() {} diff --git a/server/block/redstone_ore.go b/server/block/redstone_ore.go index d027ae5703..fdaba140dd 100644 --- a/server/block/redstone_ore.go +++ b/server/block/redstone_ore.go @@ -111,7 +111,7 @@ func (r RedstoneOre) LightEmissionLevel() uint8 { func (r RedstoneOre) BreakInfo() BreakInfo { return newBreakInfo(r.Type.Hardness(), func(t item.Tool) bool { return t.ToolType() == item.TypePickaxe && t.HarvestLevel() >= item.ToolTierIron.HarvestLevel - }, pickaxeEffective, discreteDrops(RedstoneWire{}, RedstoneOre{Type: r.Type}, 4, 5, 8)).withXPDropRange(1, 5).withBlastResistance(15) + }, pickaxeEffective, discreteDrops(RedstoneWire{}, RedstoneOre{Type: r.Type}, 4, 5, 8)).withXPDropRange(1, 5).withBlastResistance(3) } // SmeltInfo ... diff --git a/server/block/redstone_test.go b/server/block/redstone_test.go new file mode 100644 index 0000000000..8b5ebbe2fa --- /dev/null +++ b/server/block/redstone_test.go @@ -0,0 +1,1363 @@ +package block + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" +) + +func runWorld(w *world.World, f func(*world.Tx)) { + w.Do(f).Wait(context.Background()) +} + +func TestRedstoneWirePowersBlockBelowButNotAbove(t *testing.T) { + wire := RedstoneWire{Power: 15} + pos := cube.Pos{0, 64, 0} + + tests := []struct { + name string + face cube.Face + want int + }{ + {name: "top", face: cube.FaceUp}, + {name: "bottom", face: cube.FaceDown, want: 15}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if power := wire.RedstonePower(pos, nil, test.face); power != test.want { + t.Fatalf("power from %s face = %d, want %d", test.face, power, test.want) + } + }) + } +} + +func TestRedstoneWireVerticalTravel(t *testing.T) { + tests := []struct { + name string + upperSupport world.Block + fromHigh bool + want bool + }{ + {name: "up glowstone", upperSupport: Glowstone{}, want: true}, + {name: "down glowstone", upperSupport: Glowstone{}, fromHigh: true}, + {name: "down glass", upperSupport: Glass{}, fromHigh: true, want: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + w := world.Config{Synchronous: true}.New() + defer w.Close() + + low, high := cube.Pos{1, 64, 0}, cube.Pos{0, 65, 0} + var neighbours []cube.Pos + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(low.Side(cube.FaceDown), Stone{}, nil) + tx.SetBlock(high.Side(cube.FaceDown), test.upperSupport, nil) + tx.SetBlock(low, RedstoneWire{}, nil) + tx.SetBlock(high, RedstoneWire{}, nil) + + from := low + if test.fromHigh { + from = high + } + neighbours = RedstoneWire{}.RedstoneRelayerNeighbours(from, tx) + }) + + to := high + if test.fromHigh { + to = low + } + if got := redstoneWireTestContains(neighbours, to); got != test.want { + t.Fatalf("neighbours = %v, contains %v = %t, want %t", neighbours, to, got, test.want) + } + }) + } +} + +func TestRedstoneWireBreaksWhenSupportRemoved(t *testing.T) { + w := world.Config{Synchronous: true, Entities: redstoneBreakDropTestEntityRegistry()}.New() + defer w.Close() + + wirePos := cube.Pos{0, 64, 0} + supportPos := wirePos.Side(cube.FaceDown) + var blockAfter world.Block + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(supportPos, Stone{}, nil) + tx.SetBlock(wirePos, RedstoneWire{}, nil) + tx.SetBlock(supportPos, nil, nil) + }) + w.AdvanceTick() + runWorld(w, func(tx *world.Tx) { + blockAfter = tx.Block(wirePos) + }) + + if _, ok := blockAfter.(Air); !ok { + t.Fatalf("redstone wire after support removal = %T, want Air", blockAfter) + } +} + +func TestRedstoneWireGlowstoneLadderDoesNotOscillateAfterNeighbourBlockUpdate(t *testing.T) { + for _, test := range []struct { + name string + updatePos cube.Pos + breaking bool + }{ + {name: "place adjacent top dust", updatePos: cube.Pos{0, 67, -1}}, + {name: "place adjacent support", updatePos: cube.Pos{0, 66, -1}}, + {name: "place diagonal top dust", updatePos: cube.Pos{1, 67, -1}}, + {name: "break adjacent top dust", updatePos: cube.Pos{0, 67, -1}, breaking: true}, + {name: "break adjacent support", updatePos: cube.Pos{0, 66, -1}, breaking: true}, + {name: "break diagonal top dust", updatePos: cube.Pos{1, 67, -1}, breaking: true}, + } { + t.Run(test.name, func(t *testing.T) { + w := world.Config{Dim: world.End, Synchronous: true}.New() + defer w.Close() + + viewer := &redstoneWireTestBlockUpdateViewer{} + loader := world.NewLoader(2, w, viewer) + defer func() { + runWorld(w, func(tx *world.Tx) { + loader.Close(tx) + }) + }() + + sourcePos := cube.Pos{2, 64, 0} + dustPositions := []cube.Pos{ + {1, 64, 0}, + {0, 65, 0}, + {1, 66, 0}, + {0, 67, 0}, + } + supportPositions := []cube.Pos{ + dustPositions[0].Side(cube.FaceDown), + {0, 64, 0}, + {1, 65, 0}, + {0, 66, 0}, + } + topDustPos := dustPositions[len(dustPositions)-1] + runWorld(w, func(tx *world.Tx) { + loader.Move(tx, mgl64.Vec3{0, 64, 0}) + loader.Load(tx, 16) + }) + redstoneWireTestSetBlockAndWait(t, w, sourcePos, RedstoneBlock{}) + for i, supportPos := range supportPositions { + if i == 0 { + redstoneWireTestSetBlockAndWait(t, w, supportPos, Stone{}) + } else { + redstoneWireTestSetBlockAndWait(t, w, supportPos, Glowstone{}) + } + } + for _, dustPos := range dustPositions { + redstoneWireTestSetBlockAndWait(t, w, dustPos, RedstoneWire{}) + } + if test.breaking { + redstoneWireTestSetBlockAndWait(t, w, test.updatePos, Stone{}) + } + + redstoneWireTestWaitFor(t, w, func(tx *world.Tx) bool { + wire, ok := tx.Block(topDustPos).(RedstoneWire) + return ok && wire.Power > 0 + }) + viewer.reset() + + var initialPower int + runWorld(w, func(tx *world.Tx) { + initialPower = tx.Block(topDustPos).(RedstoneWire).Power + if test.breaking { + tx.SetBlock(test.updatePos, nil, nil) + } else { + tx.SetBlock(test.updatePos, Stone{}, nil) + } + }) + + lastPower := initialPower + lastTick := int64(-1) + powerChanges := 0 + for range 12 { + lastTick = redstoneWireTestWaitNextTick(t, w, lastTick) + var power int + runWorld(w, func(tx *world.Tx) { + power = tx.Block(topDustPos).(RedstoneWire).Power + }) + if power != lastPower { + powerChanges++ + lastPower = power + } + } + if powerChanges != 0 { + t.Fatalf("top glowstone ladder dust power changed %d times after neighbour update; initial=%d final=%d updatePos=%v breaking=%t", powerChanges, initialPower, lastPower, test.updatePos, test.breaking) + } + if updates := viewer.blockUpdateCount(topDustPos); updates != 0 { + t.Fatalf("top glowstone ladder dust received %d block updates after neighbour update; initial=%d final=%d updatePos=%v breaking=%t", updates, initialPower, lastPower, test.updatePos, test.breaking) + } + }) + } +} + +func TestRedstoneTorchAttachmentPower(t *testing.T) { + tests := []struct { + name string + setup func(tx *world.Tx, attachmentPos cube.Pos) + want bool + }{ + { + name: "ignores non-conductive attachment", + setup: func(tx *world.Tx, attachmentPos cube.Pos) { + tx.SetBlock(attachmentPos, Glass{}, nil) + tx.SetBlock(attachmentPos.Side(cube.FaceNorth), RedstoneBlock{}, nil) + }, + }, + { + name: "powered conductive attachment", + setup: func(tx *world.Tx, attachmentPos cube.Pos) { + tx.SetBlock(attachmentPos, Stone{}, nil) + tx.SetBlock(attachmentPos.Side(cube.FaceNorth), RedstoneWire{Power: 15}, nil) + tx.SetBlock(attachmentPos.Side(cube.FaceNorth).Side(cube.FaceDown), Stone{}, nil) + }, + want: true, + }, + { + name: "redstone block attachment", + setup: func(tx *world.Tx, attachmentPos cube.Pos) { + tx.SetBlock(attachmentPos, RedstoneBlock{}, nil) + }, + want: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + w := world.Config{Synchronous: true}.New() + defer w.Close() + + torchPos := cube.Pos{1, 64, 0} + attachmentPos := torchPos.Side(cube.FaceWest) + var powered bool + runWorld(w, func(tx *world.Tx) { + test.setup(tx, attachmentPos) + + torch := RedstoneTorch{Facing: cube.FaceWest, Lit: true} + powered = torch.attachmentPowered(torchPos, tx) + }) + + if powered != test.want { + t.Fatalf("attachment powered = %t, want %t", powered, test.want) + } + }) + } +} + +func TestRedstoneTorchUnknownFacingDoesNotPowerAttachmentFace(t *testing.T) { + torch := RedstoneTorch{Facing: unknownFace, Lit: true} + pos := cube.Pos{1, 64, 0} + + tests := []struct { + name string + face cube.Face + want int + }{ + {name: "attachment", face: cube.FaceDown}, + {name: "top", face: cube.FaceUp, want: 15}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if power := torch.RedstonePower(pos, nil, test.face); power != test.want { + t.Fatalf("unknown-facing torch power from %s face = %d, want %d", test.face, power, test.want) + } + }) + } +} + +func TestRedstoneTorchUnknownFacingUsesBlockBelowAsAttachment(t *testing.T) { + w := world.Config{Synchronous: true}.New() + defer w.Close() + + torchPos := cube.Pos{1, 64, 0} + attachmentPos := torchPos.Side(cube.FaceDown) + inputPos := attachmentPos.Side(cube.FaceNorth) + var unpoweredAttachment, poweredAttachment, supported bool + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(attachmentPos, Stone{}, nil) + tx.SetBlock(torchPos, RedstoneTorch{Facing: unknownFace, Lit: true}, nil) + + torch := tx.Block(torchPos).(RedstoneTorch) + unpoweredAttachment = torch.attachmentPowered(torchPos, tx) + + tx.SetBlock(inputPos, RedstoneWire{Power: 15}, nil) + tx.SetBlock(inputPos.Side(cube.FaceDown), Stone{}, nil) + poweredAttachment = torch.attachmentPowered(torchPos, tx) + + torch.NeighbourUpdateTick(torchPos, attachmentPos, tx) + _, supported = tx.Block(torchPos).(RedstoneTorch) + }) + + if unpoweredAttachment { + t.Fatal("unknown-facing torch treated itself as a powered attachment") + } + if !poweredAttachment { + t.Fatal("unknown-facing torch did not use the block below as its powered attachment") + } + if !supported { + t.Fatal("unknown-facing torch broke instead of using the block below as support") + } +} + +func TestRedstoneBlockPowersAdjacentComponentsButNotThroughStone(t *testing.T) { + w := world.Config{Synchronous: true}.New() + defer w.Close() + + sourcePos := cube.Pos{0, 64, 0} + adjacentDustPos := sourcePos.Side(cube.FaceEast) + stonePos := sourcePos.Side(cube.FaceWest) + farDustPos := stonePos.Side(cube.FaceWest) + var adjacentPower, farPower int + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(sourcePos, RedstoneBlock{}, nil) + tx.SetBlock(adjacentDustPos, RedstoneWire{}, nil) + tx.SetBlock(adjacentDustPos.Side(cube.FaceDown), Stone{}, nil) + tx.SetBlock(stonePos, Stone{}, nil) + tx.SetBlock(farDustPos, RedstoneWire{}, nil) + tx.SetBlock(farDustPos.Side(cube.FaceDown), Stone{}, nil) + + adjacentPower = tx.RedstonePower(adjacentDustPos) + farPower = tx.RedstonePower(farDustPos) + }) + + if adjacentPower == 0 { + t.Fatal("redstone block did not power adjacent dust") + } + if farPower != 0 { + t.Fatalf("redstone block powered dust through stone with %d, want 0", farPower) + } +} + +func TestRedstoneBlockDoesNotPowerTorchThroughStone(t *testing.T) { + w := world.Config{Synchronous: true}.New() + defer w.Close() + + torchPos := cube.Pos{1, 64, 0} + attachmentPos := torchPos.Side(cube.FaceWest) + var powered bool + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(attachmentPos, Stone{}, nil) + tx.SetBlock(attachmentPos.Side(cube.FaceNorth), RedstoneBlock{}, nil) + + torch := RedstoneTorch{Facing: cube.FaceWest, Lit: true} + powered = torch.attachmentPowered(torchPos, tx) + }) + + if powered { + t.Fatal("torch attachment was powered through stone by redstone block") + } +} + +func TestLeverStrongPowersAttachedBlockFace(t *testing.T) { + w := world.Config{Synchronous: true}.New() + defer w.Close() + + leverPos := cube.Pos{1, 64, 0} + attachedPos := leverPos.Side(cube.FaceWest) + unattachedPos := leverPos.Side(cube.FaceEast) + var attachedPower, unattachedPower int + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(attachedPos, Stone{}, nil) + tx.SetBlock(unattachedPos, Stone{}, nil) + tx.SetBlock(leverPos, Lever{Powered: true, Facing: cube.FaceEast}, nil) + + attachedPower = tx.RedstoneStrongPower(attachedPos) + unattachedPower = tx.RedstoneStrongPower(unattachedPos) + }) + + if attachedPower != 15 { + t.Fatalf("attached block strong power = %d, want 15", attachedPower) + } + if unattachedPower != 0 { + t.Fatalf("unattached block strong power = %d, want 0", unattachedPower) + } +} + +func TestLeverBreaksWhenSupportRemoved(t *testing.T) { + w := world.Config{Synchronous: true, Entities: redstoneBreakDropTestEntityRegistry()}.New() + defer w.Close() + + leverPos := cube.Pos{1, 64, 0} + supportPos := leverPos.Side(cube.FaceWest) + var blockAfter world.Block + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(supportPos, Stone{}, nil) + tx.SetBlock(leverPos, Lever{Facing: cube.FaceEast}, nil) + tx.SetBlock(supportPos, nil, nil) + }) + w.AdvanceTick() + runWorld(w, func(tx *world.Tx) { + blockAfter = tx.Block(leverPos) + }) + + if _, ok := blockAfter.(Air); !ok { + t.Fatalf("lever after support removal = %T, want Air", blockAfter) + } +} + +func TestLeverUpdatesConsumerBehindAttachedBlock(t *testing.T) { + w := world.Config{Synchronous: true}.New() + defer w.Close() + loader := world.NewLoader(1, w, world.NopViewer{}) + runWorld(w, func(tx *world.Tx) { + loader.Load(tx, 1) + }) + defer func() { + runWorld(w, func(tx *world.Tx) { + loader.Close(tx) + }) + }() + + leverPos := cube.Pos{0, 64, 0} + attachmentPos := leverPos.Side(cube.FaceWest) + notePos := attachmentPos.Side(cube.FaceWest) + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(attachmentPos, Stone{}, nil) + tx.SetBlock(notePos, Note{}, nil) + tx.SetBlock(leverPos, Lever{Facing: cube.FaceEast}, nil) + }) + + redstoneWireTestSetBlockAndWait(t, w, leverPos, Lever{Powered: true, Facing: cube.FaceEast}) + + var powered bool + runWorld(w, func(tx *world.Tx) { + powered = tx.Block(notePos).(Note).Powered + }) + if !powered { + t.Fatal("lever did not update consumer behind its attached block") + } +} + +func TestNoteBlockPlaysOnRedstoneRisingEdgeOnly(t *testing.T) { + w := world.Config{Synchronous: true}.New() + defer w.Close() + + handler := &redstoneSoundTestHandler{} + w.Handle(handler) + + leverPos := cube.Pos{0, 64, 0} + attachmentPos := leverPos.Side(cube.FaceWest) + notePos := attachmentPos.Side(cube.FaceWest) + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(attachmentPos, Stone{}, nil) + tx.SetBlock(notePos, Note{}, nil) + tx.SetBlock(leverPos, Lever{Facing: cube.FaceEast}, nil) + }) + + redstoneWireTestSetBlockAndWait(t, w, leverPos, Lever{Powered: true, Facing: cube.FaceEast}) + redstoneWireTestSetBlockAndWait(t, w, leverPos, Lever{Powered: false, Facing: cube.FaceEast}) + + var powered bool + runWorld(w, func(tx *world.Tx) { + powered = tx.Block(notePos).(Note).Powered + }) + if handler.noteSounds != 1 { + t.Fatalf("note sounds after rising and falling edge = %d, want 1", handler.noteSounds) + } + if powered { + t.Fatal("note block stayed powered after falling edge") + } +} + +func TestTNTDoesNotConductRedstonePower(t *testing.T) { + w := world.Config{Synchronous: true}.New() + defer w.Close() + + sourcePos := cube.Pos{0, 64, 0} + tntPos := sourcePos.Side(cube.FaceEast) + dustPos := tntPos.Side(cube.FaceEast) + var power int + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(sourcePos, RedstoneWire{Power: 15}, nil) + tx.SetBlock(sourcePos.Side(cube.FaceDown), Stone{}, nil) + tx.SetBlock(tntPos, TNT{}, nil) + tx.SetBlock(dustPos, RedstoneWire{}, nil) + tx.SetBlock(dustPos.Side(cube.FaceDown), Stone{}, nil) + + power = tx.RedstonePower(dustPos) + }) + + if power != 0 { + t.Fatalf("redstone power conducted through TNT = %d, want 0", power) + } +} + +func TestTNTRedstoneEngineRisingEdgePrimes(t *testing.T) { + w := world.Config{Synchronous: true, Entities: redstoneTNTTestEntityRegistry()}.New() + defer w.Close() + + sourcePos := cube.Pos{0, 64, 0} + tntPos := sourcePos.Side(cube.FaceEast) + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(tntPos, TNT{}, nil) + tx.SetBlock(sourcePos, RedstoneBlock{}, nil) + }) + w.AdvanceTick() + + var blockAfter world.Block + entities := 0 + runWorld(w, func(tx *world.Tx) { + blockAfter = tx.Block(tntPos) + for range tx.Entities() { + entities++ + } + }) + if _, ok := blockAfter.(Air); !ok { + t.Fatalf("TNT after engine redstone update = %T, want Air", blockAfter) + } + if entities != 1 { + t.Fatalf("entities after engine redstone update = %d, want 1 primed TNT", entities) + } +} + +func TestTNTRedstonePowerAction(t *testing.T) { + tests := []struct { + name string + oldPower int + newPower int + wantAir bool + wantEntities int + }{ + {name: "rising edge primes", newPower: 15, wantAir: true, wantEntities: 1}, + {name: "falling edge ignored", oldPower: 15}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + w := world.Config{Synchronous: true, Entities: redstoneTNTTestEntityRegistry()}.New() + defer w.Close() + + pos := cube.Pos{1, 64, 0} + var blockAfter world.Block + entities := 0 + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(pos, TNT{}, nil) + + (TNT{}).RedstonePowerAction(pos, tx, test.oldPower, test.newPower) + blockAfter = tx.Block(pos) + for range tx.Entities() { + entities++ + } + }) + + _, air := blockAfter.(Air) + if air != test.wantAir { + t.Fatalf("block after TNT action = %T, air=%t, want air=%t", blockAfter, air, test.wantAir) + } + if entities != test.wantEntities { + t.Fatalf("entities after TNT action = %d, want %d", entities, test.wantEntities) + } + }) + } +} + +func TestRedstoneTorchBurnsOutAfterRapidSelfTriggeredTurnOffs(t *testing.T) { + w := world.Config{Synchronous: true}.New() + defer w.Close() + + torchPos := cube.Pos{1, 64, 0} + attachmentPos := torchPos.Side(cube.FaceWest) + inputPos := attachmentPos.Side(cube.FaceNorth) + var lit bool + var burnedOut, recoverable bool + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(attachmentPos, Stone{}, nil) + tx.SetBlock(torchPos, RedstoneTorch{Facing: cube.FaceWest, Lit: true}, nil) + + redstoneTorchBurnoutTestToggle(tx, torchPos, inputPos, nil, true) + + lit = tx.Block(torchPos).(RedstoneTorch).Lit + burnedOut, recoverable = tx.Redstone().Torch(torchPos).BurnoutStatus() + }) + + if lit { + t.Fatalf("redstone torch stayed lit after rapid turn-offs; burnedOut=%t recoverable=%t", burnedOut, recoverable) + } + if !burnedOut { + t.Fatal("redstone torch turned off without recording burnout") + } +} + +func TestRedstoneTorchExternalTurnOffsDoNotBurnOut(t *testing.T) { + w := world.Config{Synchronous: true}.New() + defer w.Close() + + torchPos := cube.Pos{1, 64, 0} + attachmentPos := torchPos.Side(cube.FaceWest) + inputPos := attachmentPos.Side(cube.FaceNorth) + var lit bool + var burnedOut bool + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(attachmentPos, Stone{}, nil) + tx.SetBlock(torchPos, RedstoneTorch{Facing: cube.FaceWest, Lit: true}, nil) + + redstoneTorchBurnoutTestToggle(tx, torchPos, inputPos, nil, false) + + lit = tx.Block(torchPos).(RedstoneTorch).Lit + burnedOut, _ = tx.Redstone().Torch(torchPos).BurnoutStatus() + }) + + if lit { + t.Fatal("redstone torch stayed lit after external power was applied") + } + if burnedOut { + t.Fatal("externally toggled redstone torch recorded burnout") + } +} + +func TestRedstoneTorchScheduledTickReloadsLiveState(t *testing.T) { + w := world.Config{Synchronous: true}.New() + defer w.Close() + + torchPos := cube.Pos{1, 64, 0} + attachmentPos := torchPos.Side(cube.FaceWest) + var lit bool + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(attachmentPos, Stone{}, nil) + tx.SetBlock(torchPos, RedstoneTorch{Facing: cube.FaceWest}, &world.SetOpts{DisableRedstoneUpdates: true}) + + stale := RedstoneTorch{Facing: cube.FaceWest, Lit: true} + stale.ScheduledTick(torchPos, tx, nil) + lit = tx.Block(torchPos).(RedstoneTorch).Lit + }) + + if !lit { + t.Fatal("redstone torch scheduled tick used stale receiver state instead of live block state") + } +} + +func TestBurnedOutRedstoneTorchRelightsWhenInputIsRemoved(t *testing.T) { + w := world.Config{Synchronous: true}.New() + defer w.Close() + loader := world.NewLoader(1, w, world.NopViewer{}) + runWorld(w, func(tx *world.Tx) { + loader.Load(tx, 1) + }) + defer func() { + runWorld(w, func(tx *world.Tx) { + loader.Close(tx) + }) + }() + + torchPos := cube.Pos{1, 64, 0} + attachmentPos := torchPos.Side(cube.FaceWest) + inputPos := attachmentPos.Side(cube.FaceNorth) + var lit bool + var burnedOutTick int64 + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(attachmentPos, Stone{}, nil) + tx.SetBlock(torchPos, RedstoneTorch{Facing: cube.FaceWest, Lit: true}, nil) + + redstoneTorchBurnoutTestToggle(tx, torchPos, inputPos, nil, true) + + burnedOutTick = tx.CurrentTick() + }) + redstoneWireTestWaitTick(t, w, burnedOutTick) + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(inputPos, nil, nil) + torch := tx.Block(torchPos).(RedstoneTorch) + torch.RedstonePowerActionUpdate(torchPos, tx, world.RedstoneUpdate{ChangedNeighbour: inputPos, HasChangedNeighbour: true, ChangedRedstoneRelevant: true}) + tx.Block(torchPos).(RedstoneTorch).ScheduledTick(torchPos, tx, nil) + lit = tx.Block(torchPos).(RedstoneTorch).Lit + }) + + if !lit { + t.Fatal("burned-out redstone torch did not relight after its input was removed") + } +} + +func TestBurnedOutRedstoneTorchRecoversFromExternalScheduledUpdate(t *testing.T) { + w := world.Config{Synchronous: true}.New() + defer w.Close() + loader := world.NewLoader(1, w, world.NopViewer{}) + runWorld(w, func(tx *world.Tx) { + loader.Load(tx, 1) + }) + defer func() { + runWorld(w, func(tx *world.Tx) { + loader.Close(tx) + }) + }() + + torchPos := cube.Pos{1, 64, 0} + attachmentPos := torchPos.Side(cube.FaceWest) + inputPos := attachmentPos.Side(cube.FaceNorth) + sourcePos := inputPos.Side(cube.FaceNorth) + var lit bool + var burnedOutTick int64 + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(attachmentPos, Stone{}, nil) + tx.SetBlock(torchPos, RedstoneTorch{Facing: cube.FaceWest, Lit: true}, nil) + + redstoneTorchBurnoutTestToggle(tx, torchPos, inputPos, nil, true) + + burnedOutTick = tx.CurrentTick() + }) + redstoneWireTestWaitTick(t, w, burnedOutTick) + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(inputPos, nil, nil) + torch := tx.Block(torchPos).(RedstoneTorch) + torch.RedstonePowerActionUpdate(torchPos, tx, world.RedstoneUpdate{ + ChangedNeighbour: inputPos, + HasChangedNeighbour: true, + ChangedRedstoneRelevant: true, + Source: sourcePos, + HasSource: true, + Cause: world.RedstoneUpdateCauseScheduledTick, + }) + tx.Block(torchPos).(RedstoneTorch).ScheduledTick(torchPos, tx, nil) + lit = tx.Block(torchPos).(RedstoneTorch).Lit + }) + + if !lit { + t.Fatal("burned-out redstone torch did not recover from an external scheduled update") + } +} + +func TestBurnedOutRedstoneTorchDoesNotRecoverFromInputWirePowerDrop(t *testing.T) { + w := world.Config{Synchronous: true}.New() + defer w.Close() + + torchPos := cube.Pos{1, 64, 0} + attachmentPos := torchPos.Side(cube.FaceWest) + inputPos := attachmentPos.Side(cube.FaceNorth) + var lit bool + var burnedOut bool + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(attachmentPos, Stone{}, nil) + tx.SetBlock(torchPos, RedstoneTorch{Facing: cube.FaceWest, Lit: true}, nil) + + redstoneTorchBurnoutTestToggle(tx, torchPos, inputPos, RedstoneWire{}, true) + + tx.SetBlock(inputPos, RedstoneWire{}, nil) + torch := tx.Block(torchPos).(RedstoneTorch) + torch.RedstonePowerActionUpdate(torchPos, tx, world.RedstoneUpdate{ChangedNeighbour: inputPos, HasChangedNeighbour: true}) + tx.Block(torchPos).(RedstoneTorch).ScheduledTick(torchPos, tx, nil) + lit = tx.Block(torchPos).(RedstoneTorch).Lit + burnedOut, _ = tx.Redstone().Torch(torchPos).BurnoutStatus() + }) + + if lit || !burnedOut { + t.Fatalf("burned-out redstone torch recovered from wire power drop; lit=%t burnedOut=%t", lit, burnedOut) + } +} + +func TestBurnedOutRedstoneTorchRecoversFromZeroPositionInputUpdate(t *testing.T) { + w := world.Config{Synchronous: true}.New() + defer w.Close() + + torchPos := cube.Pos{1, 0, 0} + attachmentPos := cube.Pos{0, 0, 0} + inputPos := attachmentPos.Side(cube.FaceNorth) + var lit bool + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(attachmentPos, Stone{}, nil) + tx.SetBlock(torchPos, RedstoneTorch{Facing: cube.FaceWest, Lit: true}, nil) + + redstoneTorchBurnoutTestToggle(tx, torchPos, inputPos, nil, true) + + tx.SetBlock(inputPos, nil, nil) + torch := tx.Block(torchPos).(RedstoneTorch) + torch.RedstonePowerActionUpdate(torchPos, tx, world.RedstoneUpdate{ChangedNeighbour: attachmentPos, HasChangedNeighbour: true}) + tx.Block(torchPos).(RedstoneTorch).ScheduledTick(torchPos, tx, nil) + lit = tx.Block(torchPos).(RedstoneTorch).Lit + }) + + if !lit { + t.Fatal("burned-out redstone torch did not recover from a valid zero-position input update") + } +} + +func TestBurnedOutRedstoneTorchDoesNotRelightFromDisconnectedUpdate(t *testing.T) { + w := world.Config{Synchronous: true}.New() + defer w.Close() + loader := world.NewLoader(1, w, world.NopViewer{}) + runWorld(w, func(tx *world.Tx) { + loader.Load(tx, 1) + }) + defer func() { + runWorld(w, func(tx *world.Tx) { + loader.Close(tx) + }) + }() + + torchPos := cube.Pos{1, 64, 0} + attachmentPos := torchPos.Side(cube.FaceWest) + inputPos := attachmentPos.Side(cube.FaceNorth) + var lit, recoverable bool + var burnedOutTick int64 + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(attachmentPos, Stone{}, nil) + tx.SetBlock(torchPos, RedstoneTorch{Facing: cube.FaceWest, Lit: true}, nil) + + redstoneTorchBurnoutTestToggle(tx, torchPos, inputPos, nil, true) + + burnedOutTick = tx.CurrentTick() + }) + redstoneWireTestWaitTick(t, w, burnedOutTick) + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(inputPos, nil, &world.SetOpts{DisableBlockUpdates: true, DisableRedstoneUpdates: true}) + torch := tx.Block(torchPos).(RedstoneTorch) + torch.RedstonePowerActionUpdate(torchPos, tx, world.RedstoneUpdate{ChangedNeighbour: inputPos.Side(cube.FaceNorth).Side(cube.FaceNorth), HasChangedNeighbour: true}) + tx.Block(torchPos).(RedstoneTorch).ScheduledTick(torchPos, tx, nil) + lit = tx.Block(torchPos).(RedstoneTorch).Lit + _, recoverable = tx.Redstone().Torch(torchPos).BurnoutStatus() + }) + + if lit || recoverable { + t.Fatalf("burned-out redstone torch recovered from a disconnected update; lit=%t recoverable=%t", lit, recoverable) + } +} + +func TestBurnedOutRedstoneTorchDoesNotSelfRecoverWhenLoopUnpowersInput(t *testing.T) { + w := world.Config{Synchronous: true}.New() + defer w.Close() + + torchPos := cube.Pos{1, 64, 0} + attachmentPos := torchPos.Side(cube.FaceWest) + inputPos := attachmentPos.Side(cube.FaceNorth) + var lit bool + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(attachmentPos, Stone{}, nil) + tx.SetBlock(torchPos, RedstoneTorch{Facing: cube.FaceWest, Lit: true}, nil) + + redstoneTorchBurnoutTestToggle(tx, torchPos, inputPos, nil, true) + + tx.SetBlock(inputPos, nil, nil) + torch := tx.Block(torchPos).(RedstoneTorch) + torch.RedstonePowerActionUpdate(torchPos, tx, world.RedstoneUpdate{ChangedNeighbour: torchPos, HasChangedNeighbour: true, Cause: world.RedstoneUpdateCauseScheduledTick}) + tx.Block(torchPos).(RedstoneTorch).ScheduledTick(torchPos, tx, nil) + lit = tx.Block(torchPos).(RedstoneTorch).Lit + }) + + if lit { + t.Fatal("burned-out redstone torch self-recovered after its own loop unpowered the input") + } +} + +func redstoneWireTestWaitTick(t *testing.T, w *world.World, tick int64) { + t.Helper() + for range 200 { + w.AdvanceTick() + var current int64 + runWorld(w, func(tx *world.Tx) { + current = tx.CurrentTick() + }) + if current > tick { + return + } + } + t.Fatalf("world tick did not advance past %d", tick) +} + +func redstoneWireTestWaitNextTick(t *testing.T, w *world.World, tick int64) int64 { + t.Helper() + for range 200 { + w.AdvanceTick() + var current int64 + runWorld(w, func(tx *world.Tx) { + current = tx.CurrentTick() + }) + if current > tick { + return current + } + } + t.Fatalf("world tick did not advance past %d", tick) + return tick +} + +func redstoneWireTestWaitFor(t *testing.T, w *world.World, ready func(tx *world.Tx) bool) { + t.Helper() + for range 200 { + w.AdvanceTick() + done := false + runWorld(w, func(tx *world.Tx) { + done = ready(tx) + }) + if done { + return + } + } + t.Fatal("condition was not reached") +} + +func redstoneWireTestSetBlockAndWait(t *testing.T, w *world.World, pos cube.Pos, b world.Block) { + t.Helper() + var tick int64 + runWorld(w, func(tx *world.Tx) { + tick = tx.CurrentTick() + tx.SetBlock(pos, b, nil) + }) + redstoneWireTestWaitTick(t, w, tick) +} + +func redstoneWireTestContains(positions []cube.Pos, pos cube.Pos) bool { + for _, p := range positions { + if p == pos { + return true + } + } + return false +} + +type redstoneWireTestBlockUpdateViewer struct { + world.NopViewer + + mu sync.Mutex + updates map[cube.Pos]int +} + +func (v *redstoneWireTestBlockUpdateViewer) ViewBlockUpdate(pos cube.Pos, _ world.Block, _ int) { + v.mu.Lock() + defer v.mu.Unlock() + if v.updates == nil { + v.updates = make(map[cube.Pos]int) + } + v.updates[pos]++ +} + +func (v *redstoneWireTestBlockUpdateViewer) reset() { + v.mu.Lock() + defer v.mu.Unlock() + clear(v.updates) +} + +func (v *redstoneWireTestBlockUpdateViewer) blockUpdateCount(pos cube.Pos) int { + v.mu.Lock() + defer v.mu.Unlock() + return v.updates[pos] +} + +type redstoneSoundTestHandler struct { + world.NopHandler + + noteSounds int +} + +func (h *redstoneSoundTestHandler) HandleSound(_ *world.Context, s world.Sound, _ mgl64.Vec3) { + if _, ok := s.(sound.Note); ok { + h.noteSounds++ + } +} + +func redstoneTNTTestEntityRegistry() world.EntityRegistry { + return world.EntityRegistryConfig{ + TNT: func(opts world.EntitySpawnOpts, _ time.Duration) *world.EntityHandle { + return opts.New(redstoneTNTTestEntityType{}, redstoneTNTTestEntityConfig{}) + }, + }.New([]world.EntityType{redstoneTNTTestEntityType{}}) +} + +func redstoneBreakDropTestEntityRegistry() world.EntityRegistry { + return world.EntityRegistryConfig{ + Item: func(opts world.EntitySpawnOpts, _ any) *world.EntityHandle { + return opts.New(redstoneTNTTestEntityType{}, redstoneTNTTestEntityConfig{}) + }, + }.New([]world.EntityType{redstoneTNTTestEntityType{}}) +} + +type redstoneTNTTestEntityConfig struct{} + +func (redstoneTNTTestEntityConfig) Apply(*world.EntityData) {} + +type redstoneTNTTestEntityType struct{} + +func (redstoneTNTTestEntityType) Open(_ *world.Tx, handle *world.EntityHandle, data *world.EntityData) world.Entity { + return redstoneTNTTestEntity{handle: handle, data: data} +} + +func (redstoneTNTTestEntityType) EncodeEntity() string { return "test:tnt" } +func (redstoneTNTTestEntityType) BBox(world.Entity) cube.BBox { + return cube.Box(-0.49, 0, -0.49, 0.49, 0.98, 0.49) +} +func (redstoneTNTTestEntityType) DecodeNBT(map[string]any, *world.EntityData) {} +func (redstoneTNTTestEntityType) EncodeNBT(*world.EntityData) map[string]any { + return nil +} + +type redstoneTNTTestEntity struct { + handle *world.EntityHandle + data *world.EntityData +} + +func (e redstoneTNTTestEntity) Close() error { + return nil +} + +func (e redstoneTNTTestEntity) H() *world.EntityHandle { + return e.handle +} + +func (e redstoneTNTTestEntity) Position() mgl64.Vec3 { + return e.data.Pos +} + +func (e redstoneTNTTestEntity) Rotation() cube.Rotation { + return e.data.Rot +} +func TestRedstoneTorchLoopBurnsOutThroughWorldScheduler(t *testing.T) { + w := world.Config{Dim: world.End, Synchronous: true}.New() + defer w.Close() + + loader := world.NewLoader(2, w, world.NopViewer{}) + defer func() { + runWorld(w, func(tx *world.Tx) { + loader.Close(tx) + }) + }() + + torchPos := cube.Pos{1, 64, 0} + attachmentPos := cube.Pos{0, 64, 0} + dustPositions := []cube.Pos{ + {1, 66, 0}, + {0, 65, 0}, + } + runWorld(w, func(tx *world.Tx) { + loader.Move(tx, mgl64.Vec3{0, 64, 0}) + loader.Load(tx, 16) + + tx.SetBlock(attachmentPos, Stone{}, nil) + tx.SetBlock(torchPos.Side(cube.FaceUp), Stone{}, nil) + for _, pos := range dustPositions { + tx.SetBlock(pos, RedstoneWire{}, nil) + } + tx.SetBlock(torchPos.Side(cube.FaceDown), Stone{}, nil) + tx.SetBlock(torchPos, RedstoneTorch{Facing: cube.FaceWest, Lit: true}, nil) + }) + + var lit, burnedOut, attachmentPowered bool + var currentTick, burnedOutTick int64 + dustPower := make(map[cube.Pos]int, len(dustPositions)) + + for range 200 { + w.AdvanceTick() + redstoneTorchBurnoutTestSnapshot(w, torchPos, dustPositions, ¤tTick, &lit, &burnedOut, &attachmentPowered, dustPower) + if burnedOut && !lit { + burnedOutTick = currentTick + break + } + } + if burnedOutTick == 0 { + t.Fatalf("redstone torch loop did not burn out through world scheduler; tick=%d lit=%t burnedOut=%t attachmentPowered=%t dust=%v", currentTick, lit, burnedOut, attachmentPowered, dustPower) + } + for currentTick < burnedOutTick+100 { + w.AdvanceTick() + redstoneTorchBurnoutTestSnapshot(w, torchPos, dustPositions, ¤tTick, &lit, &burnedOut, &attachmentPowered, dustPower) + if !burnedOut || lit { + t.Fatalf("redstone torch loop recovered without an external update; tick=%d burnedOutTick=%d lit=%t burnedOut=%t attachmentPowered=%t dust=%v", currentTick, burnedOutTick, lit, burnedOut, attachmentPowered, dustPower) + } + } +} + +func TestBurnedOutRedstoneTorchRelightsWhenLoopWireBreaks(t *testing.T) { + w := world.Config{Dim: world.End, Synchronous: true}.New() + defer w.Close() + + loader := world.NewLoader(2, w, world.NopViewer{}) + defer func() { + runWorld(w, func(tx *world.Tx) { + loader.Close(tx) + }) + }() + + torchPos := cube.Pos{1, 64, 0} + attachmentPos := cube.Pos{0, 64, 0} + loopWirePos := cube.Pos{0, 65, 0} + dustPositions := []cube.Pos{ + {1, 66, 0}, + loopWirePos, + } + runWorld(w, func(tx *world.Tx) { + loader.Move(tx, mgl64.Vec3{0, 64, 0}) + loader.Load(tx, 16) + + tx.SetBlock(attachmentPos, Stone{}, nil) + tx.SetBlock(torchPos.Side(cube.FaceUp), Stone{}, nil) + for _, pos := range dustPositions { + tx.SetBlock(pos, RedstoneWire{}, nil) + } + tx.SetBlock(torchPos.Side(cube.FaceDown), Stone{}, nil) + tx.SetBlock(torchPos, RedstoneTorch{Facing: cube.FaceWest, Lit: true}, nil) + }) + + var lit, burnedOut, attachmentPowered bool + var currentTick int64 + dustPower := make(map[cube.Pos]int, len(dustPositions)) + + redstoneTorchBurnoutTestWaitFor(t, w, func() bool { + redstoneTorchBurnoutTestSnapshot(w, torchPos, dustPositions, ¤tTick, &lit, &burnedOut, &attachmentPowered, dustPower) + return burnedOut && !lit + }, func() string { + return fmt.Sprintf("torch did not burn out before wire break; tick=%d lit=%t burnedOut=%t attachmentPowered=%t dust=%v", currentTick, lit, burnedOut, attachmentPowered, dustPower) + }) + runWorld(w, func(tx *world.Tx) { + tx.SetBlock(loopWirePos, nil, nil) + }) + + redstoneTorchBurnoutTestWaitFor(t, w, func() bool { + redstoneTorchBurnoutTestSnapshot(w, torchPos, dustPositions, ¤tTick, &lit, &burnedOut, &attachmentPowered, dustPower) + return lit && !burnedOut && !attachmentPowered + }, func() string { + return fmt.Sprintf("torch did not relight after loop wire broke; tick=%d lit=%t burnedOut=%t attachmentPowered=%t dust=%v", currentTick, lit, burnedOut, attachmentPowered, dustPower) + }) +} + +func TestBurnedOutRedstoneTorchRecoveryUpdates(t *testing.T) { + torchPos := cube.Pos{1, 64, 0} + attachmentPos := cube.Pos{0, 64, 0} + east := torchPos.Side(cube.FaceEast) + eastTwo := east.Side(cube.FaceEast) + eastThree := eastTwo.Side(cube.FaceEast) + unrelatedWire := cube.Pos{10, 64, 10} + + tests := []struct { + name string + dustPositions []cube.Pos + waitRecoverable bool + wantRecover bool + setup func(tx *world.Tx, opts *world.SetOpts) + update func(tx *world.Tx) + }{ + { + name: "TestBurnedOutRedstoneTorchRecoversFromAdjacentBlockUpdate", + waitRecoverable: true, + wantRecover: true, + update: func(tx *world.Tx) { + tx.SetBlock(east, Stone{}, nil) + }, + }, + { + name: "TestBurnedOutRedstoneTorchRecoversFromVerticalBlockUpdate/up", + waitRecoverable: true, + wantRecover: true, + update: func(tx *world.Tx) { + tx.SetBlock(torchPos.Side(cube.FaceUp), Stone{}, nil) + }, + }, + { + name: "TestBurnedOutRedstoneTorchRecoversFromVerticalBlockUpdate/down", + waitRecoverable: true, + wantRecover: true, + update: func(tx *world.Tx) { + tx.SetBlock(torchPos.Side(cube.FaceDown), Stone{}, nil) + }, + }, + { + name: "TestBurnedOutRedstoneTorchRecoversFromWireNeighbourUpdate", + dustPositions: []cube.Pos{east}, + waitRecoverable: true, + wantRecover: true, + setup: func(tx *world.Tx, opts *world.SetOpts) { + tx.SetBlock(east.Side(cube.FaceDown), Stone{}, opts) + tx.SetBlock(east, RedstoneWire{}, opts) + }, + update: func(tx *world.Tx) { + tx.SetBlock(east.Side(cube.FaceNorth), Stone{}, nil) + }, + }, + { + name: "TestBurnedOutRedstoneTorchDoesNotRecoverFromRedstoneDustPastAdjacentWire", + dustPositions: []cube.Pos{east, eastTwo}, + waitRecoverable: true, + setup: func(tx *world.Tx, opts *world.SetOpts) { + tx.SetBlock(east.Side(cube.FaceDown), Stone{}, opts) + tx.SetBlock(eastTwo.Side(cube.FaceDown), Stone{}, opts) + tx.SetBlock(east, RedstoneWire{}, opts) + }, + update: func(tx *world.Tx) { + tx.SetBlock(eastTwo, RedstoneWire{}, nil) + }, + }, + { + name: "TestBurnedOutRedstoneTorchDoesNotRecoverFromDistantPathWireNeighbourUpdate", + dustPositions: []cube.Pos{east, eastTwo, eastThree}, + waitRecoverable: true, + setup: func(tx *world.Tx, opts *world.SetOpts) { + for _, pos := range []cube.Pos{east, eastTwo, eastThree} { + tx.SetBlock(pos.Side(cube.FaceDown), Stone{}, opts) + tx.SetBlock(pos, RedstoneWire{}, opts) + } + }, + update: func(tx *world.Tx) { + tx.SetBlock(eastThree.Side(cube.FaceNorth), Stone{}, nil) + }, + }, + { + name: "TestBurnedOutRedstoneTorchDoesNotRecoverFromDistantWireUpdate", + dustPositions: []cube.Pos{east}, + waitRecoverable: true, + setup: func(tx *world.Tx, opts *world.SetOpts) { + tx.SetBlock(east.Side(cube.FaceDown), Stone{}, opts) + tx.SetBlock(east, RedstoneWire{}, opts) + }, + update: func(tx *world.Tx) { + tx.SetBlock(east.Side(cube.FaceNorth).Side(cube.FaceNorth), Stone{}, nil) + }, + }, + { + name: "TestBurnedOutRedstoneTorchDoesNotRecoverFromDistantPathWireBreak", + dustPositions: []cube.Pos{east, eastTwo, eastThree}, + setup: func(tx *world.Tx, opts *world.SetOpts) { + for _, pos := range []cube.Pos{east, eastTwo, eastThree} { + tx.SetBlock(pos.Side(cube.FaceDown), Stone{}, opts) + tx.SetBlock(pos, RedstoneWire{}, opts) + } + }, + update: func(tx *world.Tx) { + tx.SetBlock(eastThree, nil, nil) + }, + }, + { + name: "TestBurnedOutRedstoneTorchDoesNotRecoverFromUnrelatedWireBreak", + setup: func(tx *world.Tx, opts *world.SetOpts) { + tx.SetBlock(unrelatedWire.Side(cube.FaceDown), Stone{}, opts) + tx.SetBlock(unrelatedWire, RedstoneWire{}, opts) + }, + update: func(tx *world.Tx) { + tx.SetBlock(unrelatedWire, nil, nil) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + w := world.Config{Dim: world.End, Synchronous: true}.New() + defer w.Close() + + loader := world.NewLoader(2, w, world.NopViewer{}) + defer func() { + runWorld(w, func(tx *world.Tx) { + loader.Close(tx) + }) + }() + + runWorld(w, func(tx *world.Tx) { + loader.Move(tx, mgl64.Vec3{0, 64, 0}) + loader.Load(tx, 16) + + opts := &world.SetOpts{DisableBlockUpdates: true, DisableRedstoneUpdates: true} + tx.SetBlock(attachmentPos, Stone{}, opts) + if test.setup != nil { + test.setup(tx, opts) + } + tx.SetBlock(torchPos, RedstoneTorch{Facing: cube.FaceWest}, opts) + redstoneTorchBurnoutTestForceBurnedOut(tx, torchPos) + }) + + var lit, burnedOut, recoverable, attachmentPowered bool + var currentTick int64 + dustPower := make(map[cube.Pos]int, len(test.dustPositions)) + + if test.waitRecoverable { + redstoneTorchBurnoutTestWaitFor(t, w, func() bool { + runWorld(w, func(tx *world.Tx) { + currentTick = tx.CurrentTick() + burnedOut, recoverable = tx.Redstone().Torch(torchPos).BurnoutStatus() + }) + return burnedOut && recoverable + }, func() string { + return fmt.Sprintf("torch did not become recoverable; tick=%d burnedOut=%t recoverable=%t", currentTick, burnedOut, recoverable) + }) + } else { + runWorld(w, func(tx *world.Tx) { + currentTick = tx.CurrentTick() + burnedOut, recoverable = tx.Redstone().Torch(torchPos).BurnoutStatus() + }) + if !burnedOut || recoverable { + t.Fatalf("torch was not in immediate burnout window; tick=%d burnedOut=%t recoverable=%t", currentTick, burnedOut, recoverable) + } + } + + var updateTick int64 + runWorld(w, func(tx *world.Tx) { + updateTick = tx.CurrentTick() + test.update(tx) + }) + + if test.wantRecover { + redstoneTorchBurnoutTestWaitFor(t, w, func() bool { + redstoneTorchBurnoutTestSnapshot(w, torchPos, test.dustPositions, ¤tTick, &lit, &burnedOut, &attachmentPowered, dustPower) + return lit && !burnedOut && !attachmentPowered + }, func() string { + return fmt.Sprintf("torch did not recover; tick=%d lit=%t burnedOut=%t attachmentPowered=%t dust=%v", currentTick, lit, burnedOut, attachmentPowered, dustPower) + }) + return + } + + for currentTick <= updateTick+10 { + w.AdvanceTick() + redstoneTorchBurnoutTestSnapshot(w, torchPos, test.dustPositions, ¤tTick, &lit, &burnedOut, &attachmentPowered, dustPower) + if lit || !burnedOut { + t.Fatalf("torch recovered from non-local update; tick=%d updateTick=%d lit=%t burnedOut=%t attachmentPowered=%t dust=%v", currentTick, updateTick, lit, burnedOut, attachmentPowered, dustPower) + } + } + }) + } +} +func redstoneTorchBurnoutTestSnapshot(w *world.World, torchPos cube.Pos, dustPositions []cube.Pos, currentTick *int64, lit, burnedOut, attachmentPowered *bool, dustPower map[cube.Pos]int) { + runWorld(w, func(tx *world.Tx) { + *currentTick = tx.CurrentTick() + if torch, ok := tx.Block(torchPos).(RedstoneTorch); ok { + *lit = torch.Lit + *attachmentPowered = torch.attachmentPowered(torchPos, tx) + } + for _, pos := range dustPositions { + if wire, ok := tx.Block(pos).(RedstoneWire); ok { + if dustPower != nil { + dustPower[pos] = wire.Power + } + } else { + if dustPower != nil { + delete(dustPower, pos) + } + } + } + *burnedOut, _ = tx.Redstone().Torch(torchPos).BurnoutStatus() + }) +} + +func redstoneTorchBurnoutTestForceBurnedOut(tx *world.Tx, pos cube.Pos) { + for range 10 { + tx.Redstone().Torch(pos).MarkSelfTriggered() + tx.Redstone().Torch(pos).RecordTurnOff() + } +} + +func redstoneTorchBurnoutTestToggle(tx *world.Tx, torchPos, inputPos cube.Pos, unpoweredInput world.Block, selfTriggered bool) { + setPoweredInput := func() { + tx.SetBlock(inputPos, RedstoneWire{Power: 15}, nil) + tx.SetBlock(inputPos.Side(cube.FaceDown), Stone{}, nil) + } + tick := func() { + if selfTriggered { + tx.Redstone().Torch(torchPos).MarkSelfTriggered() + } + tx.Block(torchPos).(RedstoneTorch).ScheduledTick(torchPos, tx, nil) + } + for range 8 { + setPoweredInput() + tick() + tx.SetBlock(inputPos, unpoweredInput, nil) + tx.Block(torchPos).(RedstoneTorch).ScheduledTick(torchPos, tx, nil) + } + setPoweredInput() + tick() +} + +func redstoneTorchBurnoutTestWaitFor(t *testing.T, w *world.World, ready func() bool, fail func() string) { + t.Helper() + for range 200 { + w.AdvanceTick() + if ready() { + return + } + } + t.Fatal(fail()) +} diff --git a/server/block/redstone_torch.go b/server/block/redstone_torch.go index 6aef001820..7906e3ca51 100644 --- a/server/block/redstone_torch.go +++ b/server/block/redstone_torch.go @@ -2,7 +2,6 @@ package block import ( "math/rand/v2" - "time" "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/item" @@ -11,6 +10,12 @@ import ( "github.com/go-gl/mathgl/mgl64" ) +var ( + _ world.RedstonePowerSource = RedstoneTorch{} + _ world.RedstonePowerContextAction = RedstoneTorch{} + _ world.ScheduledTicker = RedstoneTorch{} +) + // RedstoneTorch is a non-solid block that emits light and provides a full-strength redstone signal when lit. type RedstoneTorch struct { transparent @@ -22,12 +27,10 @@ type RedstoneTorch struct { Lit bool } -// HasLiquidDrops returns whether the redstone torch drops its item when flowing liquid breaks it. func (RedstoneTorch) HasLiquidDrops() bool { return true } -// LightEmissionLevel returns the light level emitted by the redstone torch (7 when lit, 0 when unlit). func (t RedstoneTorch) LightEmissionLevel() uint8 { if t.Lit { return 7 @@ -35,21 +38,22 @@ func (t RedstoneTorch) LightEmissionLevel() uint8 { return 0 } -// BreakInfo returns information about breaking the redstone torch. +func (t RedstoneTorch) facing() cube.Face { + if t.Facing == unknownFace { + return cube.FaceDown + } + return t.Facing +} + func (t RedstoneTorch) BreakInfo() BreakInfo { return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(t)).withBreakHandler(func(pos cube.Pos, tx *world.Tx, _ item.User) { - tx.Redstone().ClearTorchBurnout(pos) - updateTorchRedstone(pos, tx) + tx.Redstone().Torch(pos).ClearBurnout() }) } -// UseOnBlock handles the placement of a redstone torch on a block surface. func (t RedstoneTorch) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { pos, face, used := firstReplaceable(tx, pos, face, t) - if !used { - return false - } - if face == cube.FaceDown { + if !used || face == cube.FaceDown { return false } if _, ok := tx.Block(pos).(world.Liquid); ok { @@ -63,202 +67,208 @@ func (t RedstoneTorch) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx face = fallbackFace } t.Facing = face.Opposite() - t.Lit = true + t.Lit = !t.attachmentPowered(pos, tx) place(tx, pos, t, user, ctx) - if placed(ctx) { - // Initialise the freshly placed torch state before propagating its output. - t.RedstoneUpdate(pos, tx) - updateTorchRedstone(pos, tx) - return true + ok := placed(ctx) + if ok { + tx.ScheduleBlockUpdate(pos, t, redstoneTicks(1)) } - return false + return ok } -// NeighbourUpdateTick is called when a neighbouring block is updated. -func (t RedstoneTorch) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { - if !tx.Block(pos.Side(t.Facing)).Model().FaceSolid(pos.Side(t.Facing), t.Facing.Opposite(), tx) { - tx.Redstone().ClearTorchBurnout(pos) +// NeighbourUpdateTick breaks unsupported torches and otherwise schedules inverse-state refreshes. +func (t RedstoneTorch) NeighbourUpdateTick(pos, changed cube.Pos, tx *world.Tx) { + facing := t.facing() + if !tx.Block(pos.Side(facing)).Model().FaceSolid(pos.Side(facing), facing.Opposite(), tx) { + tx.Redstone().Torch(pos).ClearBurnout() breakBlock(t, pos, tx) return } - if t.recoverFromBurnout(pos, tx) { + torch := tx.Redstone().Torch(pos) + if burnedOut, recoverable := torch.BurnoutStatus(); burnedOut { + if t.recoverBurnout(pos, changed, tx, recoverable, false, t.attachmentPowered(pos, tx)) { + torch.ClearBurnout() + tx.ScheduleBlockUpdate(pos, t, redstoneTicks(1)) + } return } - updateRedstone(pos, tx) + tx.ScheduleBlockUpdate(pos, t, redstoneTicks(1)) } -// RedstoneUpdate is called when the redstone power state changes nearby. This method ignores burned-out torches and -// schedules state changes for active torches. -func (t RedstoneTorch) RedstoneUpdate(pos cube.Pos, tx *world.Tx) { - currentTick := tx.CurrentTick() - if burnedOut, _ := tx.Redstone().TorchBurnoutStatus(pos, currentTick); burnedOut { - if t.updateSourceTouchesInput(pos, tx) { - t.recoverFromBurnout(pos, tx) - } +// ScheduledTick refreshes the lit state after the torch's one-redstone-tick inversion delay. +func (t RedstoneTorch) ScheduledTick(pos cube.Pos, tx *world.Tx, _ *rand.Rand) { + if tx == nil { return } - - shouldBeLit := t.inputStrength(pos, tx) == 0 - if shouldBeLit == t.Lit { + var ok bool + if t, ok = redstoneTorchAt(pos, tx); !ok { return } - tx.Redstone().MarkTorchSelfTriggeredIfActive(pos) - tx.ScheduleBlockUpdate(pos, t, time.Millisecond*100) + redstone := tx.Redstone() + torch := redstone.Torch(pos) + selfTriggered := torch.ConsumeSelfTriggered() + if burnedOut, _ := torch.BurnoutStatus(); burnedOut { + return + } + attachmentPowered := t.attachmentPowered(pos, tx) + lit := !attachmentPowered + if t.Lit != lit { + if !lit && selfTriggered && torch.RecordTurnOff() { + t.Lit = false + tx.PlaySound(pos.Vec3Centre(), sound.Fizz{}) + tx.SetBlock(pos, t, &world.SetOpts{DisableRedstoneUpdates: true}) + redstone.ScheduleUpdate(pos) + return + } + t.Lit = lit + tx.SetBlock(pos, t, &world.SetOpts{DisableRedstoneUpdates: true}) + redstone.ScheduleUpdate(pos) + } } -// recoverFromBurnout relights a burned-out torch after a real neighbouring block update once its rapid-toggle history -// has expired. Redstone propagation alone may visit calculation-only positions, so it must not recover burned-out -// torches that did not receive an actual neighbour update. -func (RedstoneTorch) recoverFromBurnout(pos cube.Pos, tx *world.Tx) bool { - torch, ok := redstoneTorchAt(pos, tx) +// redstoneTorchAt returns the live redstone torch at pos. Scheduled tick callers may carry stale block state, so +// mutation paths must reload the world block before writing torch state back. +func redstoneTorchAt(pos cube.Pos, tx *world.Tx) (RedstoneTorch, bool) { + t, ok := tx.Block(pos).(RedstoneTorch) if !ok { - return false + tx.Redstone().Torch(pos).ClearBurnout() } + return t, ok +} - currentTick := tx.CurrentTick() - burnedOut, recoverable := tx.Redstone().TorchBurnoutStatus(pos, currentTick) - if !burnedOut { - return false - } - if !recoverable { - return true +// RedstonePower emits power from every side except the attached block while lit. +func (t RedstoneTorch) RedstonePower(_ cube.Pos, _ *world.Tx, face cube.Face) int { + if t.Lit && face != t.facing() { + return 15 } - tx.Redstone().ClearTorchBurnout(pos) - - torch.Lit = torch.inputStrength(pos, tx) == 0 - tx.SetBlock(pos, torch, nil) - updateTorchRedstone(pos, tx) - return true + return 0 } -// updateSourceTouchesInput reports whether the current redstone update came from the block the torch is attached to, -// or from a block directly beside it. Dust on top of the attached block can legitimately recover a burnout loop, while -// disconnected dust visited by the broad wire walk should not. -func (t RedstoneTorch) updateSourceTouchesInput(pos cube.Pos, tx *world.Tx) bool { - source, ok := tx.Redstone().UpdateSource() - if !ok { - return false - } - inputPos := pos.Side(t.Facing) - if source == inputPos { - return true +// RedstoneStrongPower strongly powers the block above the torch while lit. +func (t RedstoneTorch) RedstoneStrongPower(pos cube.Pos, tx *world.Tx, face cube.Face) int { + if face == cube.FaceUp { + return t.RedstonePower(pos, tx, face) } - for _, face := range cube.Faces() { - if source == inputPos.Side(face) { - return true - } - } - return false + return 0 } -// ScheduledTick is called when a scheduled block update occurs. -// This method handles state changes and checks for burnout conditions. -func (RedstoneTorch) ScheduledTick(pos cube.Pos, tx *world.Tx, _ *rand.Rand) { - torch, ok := redstoneTorchAt(pos, tx) - if !ok { +// RedstonePowerActionUpdate schedules torch refreshes and keeps burnout from self-recovering through its own loop. +func (t RedstoneTorch) RedstonePowerActionUpdate(pos cube.Pos, tx *world.Tx, update world.RedstoneUpdate) { + if tx == nil { return } - - currentTick := tx.CurrentTick() - if burnedOut, _ := tx.Redstone().TorchBurnoutStatus(pos, currentTick); burnedOut { + torch := tx.Redstone().Torch(pos) + if burnedOut, recoverable := torch.BurnoutStatus(); burnedOut { + attachmentPowered := update.NewPower > 0 && t.attachmentPowered(pos, tx) + if !update.HasChangedNeighbour || redstoneTorchSelfTriggered(pos, update) || !t.recoverBurnout(pos, update.ChangedNeighbour, tx, recoverable, update.ChangedRedstoneRelevant, attachmentPowered) { + return + } + torch.ClearBurnout() + tx.ScheduleBlockUpdate(pos, t, redstoneTicks(1)) return } - - shouldBeLit := torch.inputStrength(pos, tx) == 0 - if shouldBeLit == torch.Lit { - tx.Redstone().PruneTorchBurnout(pos, currentTick) + attachmentPowered := t.attachmentPowered(pos, tx) + if t.Lit == !attachmentPowered { return } - - if tx.Redstone().RecordTorchToggle(pos, currentTick) { - torch.burnOut(pos, tx) - return + if attachmentPowered && redstoneTorchSelfTriggered(pos, update) { + torch.MarkSelfTriggered() } - - torch.Lit = !torch.Lit - tx.SetBlock(pos, torch, nil) - updateTorchRedstone(pos, tx) + tx.ScheduleBlockUpdate(pos, t, redstoneTicks(1)) } -// burnOut puts the redstone torch into burnout state, turning it off and playing effects. -func (RedstoneTorch) burnOut(pos cube.Pos, tx *world.Tx) { - torch, ok := redstoneTorchAt(pos, tx) - if !ok { - return +// attachmentPowered reports whether the block the torch is attached to is powered. +func (t RedstoneTorch) attachmentPowered(pos cube.Pos, tx *world.Tx) bool { + if tx == nil { + return false } - - tx.Redstone().BurnOutTorch(pos) - torch.Lit = false - tx.PlaySound(pos.Vec3Centre(), sound.Fizz{}) - tx.SetBlock(pos, torch, nil) - updateTorchRedstone(pos, tx) -} - -// redstoneTorchAt returns the current torch at pos. Scheduled redstone updates carry an old block value, so mutation -// paths must reload the live world block before writing torch state back. -func redstoneTorchAt(pos cube.Pos, tx *world.Tx) (RedstoneTorch, bool) { - t, ok := tx.Block(pos).(RedstoneTorch) - if !ok { - tx.Redstone().ClearTorchBurnout(pos) + attached := pos.Side(t.facing()) + attachedBlock := tx.Block(attached) + if source, ok := attachedBlock.(world.RedstonePowerSource); ok { + for _, face := range cube.Faces() { + if world.ClampRedstonePower(source.RedstonePower(attached, tx, face)) > 0 { + return true + } + } } - return t, ok + return world.RedstoneFullPowerConductor(attached, attachedBlock, tx) && tx.RedstoneConductivePower(attached) > 0 } -// updateTorchRedstone updates receivers around the torch and behind the block it strongly powers above. -func updateTorchRedstone(pos cube.Pos, tx *world.Tx) { - tx.Redstone().WithActiveTorchUpdate(pos, func() { - updateDirectionalRedstone(pos, tx, cube.FaceUp) - }) -} - -// EncodeItem encodes the redstone torch as an item. -func (RedstoneTorch) EncodeItem() (name string, meta int16) { - return "minecraft:redstone_torch", 0 +// recoverBurnout reports whether an update should relight a burned-out torch. +func (t RedstoneTorch) recoverBurnout(pos, changed cube.Pos, tx *world.Tx, recoverable, changedRedstoneRelevant, attachmentPowered bool) bool { + if changed == pos || attachmentPowered { + return false + } + touchesRecoveryArea := t.changeTouchesRecoveryArea(pos, changed, tx) + if changedRedstoneRelevant { + return touchesRecoveryArea + } + if changed == pos.Side(t.facing()) { + return true + } + return recoverable && touchesRecoveryArea } -// EncodeBlock encodes the redstone torch as a block for network transmission. -func (t RedstoneTorch) EncodeBlock() (name string, properties map[string]any) { - face := "unknown" - if t.Facing != unknownFace { - face = t.Facing.String() - if t.Facing == cube.FaceDown { - face = "top" +// changeTouchesRecoveryArea reports whether changed is close enough to the torch, its input, or an output wire directly +// beside it to count as a real neighbour update. A dust line extending straight away from the torch is not local. +func (t RedstoneTorch) changeTouchesRecoveryArea(pos, changed cube.Pos, tx *world.Tx) bool { + inputPos := pos.Side(t.facing()) + if changed == inputPos { + return true + } + for _, face := range cube.Faces() { + if changed == pos.Side(face) || changed == inputPos.Side(face) { + return true } } - if t.Lit { - return "minecraft:redstone_torch", map[string]any{"torch_facing_direction": face} + for _, face := range cube.HorizontalFaces() { + neighbour := pos.Side(face) + if _, ok := tx.Block(neighbour).(RedstoneWire); !ok { + continue + } + for _, wireFace := range cube.HorizontalFaces() { + if wireFace == face || wireFace == face.Opposite() { + continue + } + if changed == neighbour.Side(wireFace) { + return true + } + } } - return "minecraft:unlit_redstone_torch", map[string]any{"torch_facing_direction": face} -} - -// RedstoneSource ... -func (t RedstoneTorch) RedstoneSource() bool { - return true + return false } -// WeakPower returns the weak redstone power level provided to adjacent blocks. -func (t RedstoneTorch) WeakPower(_ cube.Pos, face cube.Face, _ *world.Tx, _ bool) int { - if !t.Lit { - return 0 +// redstoneTorchSelfTriggered reports whether this update came from the torch's own scheduled output propagation. +func redstoneTorchSelfTriggered(pos cube.Pos, update world.RedstoneUpdate) bool { + if update.Cause != world.RedstoneUpdateCauseScheduledTick { + return false } - if face.Opposite() == t.Facing { - return 0 + if update.HasSource { + return update.Source == pos } - return 15 + return update.HasChangedNeighbour && update.ChangedNeighbour == pos } -// StrongPower returns the strong redstone power level provided to the block above the torch. -func (t RedstoneTorch) StrongPower(_ cube.Pos, face cube.Face, _ *world.Tx, _ bool) int { - if t.Lit && face == cube.FaceDown { - return 15 - } - return 0 +func (RedstoneTorch) EncodeItem() (name string, meta int16) { + return "minecraft:redstone_torch", 0 } -// inputStrength returns the redstone power level received by the block the torch is attached to. -func (t RedstoneTorch) inputStrength(pos cube.Pos, tx *world.Tx) int { - return tx.RedstonePower(pos.Side(t.Facing), t.Facing, true) +func (t RedstoneTorch) EncodeBlock() (name string, properties map[string]any) { + name = "minecraft:unlit_redstone_torch" + if t.Lit { + name = "minecraft:redstone_torch" + } + var direction string + switch t.Facing { + case cube.FaceDown: + direction = "top" + case unknownFace: + direction = "unknown" + default: + direction = t.Facing.String() + } + return name, map[string]any{"torch_facing_direction": direction} } // redstoneTorchFallbackSides lists the faces to check for placing a redstone torch on a non-solid block. @@ -271,7 +281,6 @@ var redstoneTorchFallbackSides = [...]cube.Face{ } // findTorchPlacementFace finds a valid face for placing a redstone torch on a non-solid block. -// It returns the face the torch should be placed on and whether it was found. func findTorchPlacementFace(pos cube.Pos, tx *world.Tx) (cube.Face, bool) { for _, side := range redstoneTorchFallbackSides { if tx.Block(pos.Side(side)).Model().FaceSolid(pos.Side(side), side.Opposite(), tx) { @@ -281,14 +290,12 @@ func findTorchPlacementFace(pos cube.Pos, tx *world.Tx) (cube.Face, bool) { return 0, false } -// allRedstoneTorches returns all possible redstone torch block states. func allRedstoneTorches() (all []world.Block) { - for _, f := range append(cube.Faces(), unknownFace) { - if f == cube.FaceUp { - continue + for _, face := range cube.Faces() { + if face == cube.FaceUp { + face = unknownFace } - all = append(all, RedstoneTorch{Facing: f, Lit: true}) - all = append(all, RedstoneTorch{Facing: f}) + all = append(all, RedstoneTorch{Facing: face}, RedstoneTorch{Facing: face, Lit: true}) } return } diff --git a/server/block/redstone_wire.go b/server/block/redstone_wire.go index 3c110dcc48..9a9337ff35 100644 --- a/server/block/redstone_wire.go +++ b/server/block/redstone_wire.go @@ -1,8 +1,9 @@ package block import ( + "time" + "github.com/df-mc/dragonfly/server/block/cube" - "github.com/df-mc/dragonfly/server/block/model" "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" @@ -20,254 +21,278 @@ type RedstoneWire struct { Power int } -// HasLiquidDrops ... -func (RedstoneWire) HasLiquidDrops() bool { - return true -} - -// BreakInfo ... -func (r RedstoneWire) BreakInfo() BreakInfo { - return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(RedstoneWire{})).withBreakHandler(func(pos cube.Pos, tx *world.Tx, _ item.User) { - updateStrongRedstone(pos, tx) - }) -} - -// EncodeBlock ... -func (r RedstoneWire) EncodeBlock() (string, map[string]any) { - return "minecraft:redstone_wire", map[string]any{ - "redstone_signal": int32(r.Power), - } -} - -// EncodeItem ... -func (RedstoneWire) EncodeItem() (name string, meta int16) { - return "minecraft:redstone", 0 -} - -// UseOnBlock ... func (r RedstoneWire) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { pos, _, used := firstReplaceable(tx, pos, face, r) - if !used { - return false - } - belowPos := pos.Side(cube.FaceDown) - if !tx.Block(belowPos).Model().FaceSolid(belowPos, cube.FaceUp, tx) { + if !used || !redstoneWireSupported(tx, pos) { return false } - r.Power = r.calculatePower(pos, tx) place(tx, pos, r, user, ctx) - if placed(ctx) { - updateStrongRedstone(pos, tx) - return true - } - return false + return placed(ctx) } -// NeighbourUpdateTick ... -func (r RedstoneWire) NeighbourUpdateTick(pos, neighbour cube.Pos, tx *world.Tx) { - if pos == neighbour { - // Ignore the self-update sent after this wire's block state changes. - return - } - below := pos.Side(cube.FaceDown) - if !tx.Block(below).Model().FaceSolid(below, cube.FaceUp, tx) { - breakBlock(r, pos, tx) - return +// RedstonePower returns the wire's current signal strength from connected faces. +func (r RedstoneWire) RedstonePower(pos cube.Pos, tx *world.Tx, face cube.Face) int { + if face == cube.FaceUp { + return 0 } - if changed, ok := r.updateFromNeighbour(pos, tx); ok && !changed { - updateStrongRedstone(pos, tx) + if tx != nil && redstoneWireFaceHorizontal(face) && !redstoneWirePowersHorizontalFace(pos, tx, face) { + return 0 } + return r.Power +} + +func (RedstoneWire) RedstoneWeaklyPowersBlocks() bool { + return true } -// RedstoneUpdate ... -func (r RedstoneWire) RedstoneUpdate(pos cube.Pos, tx *world.Tx) { - r.updatePower(pos, tx) +func (RedstoneWire) RedstoneSignalLoss(cube.Pos, *world.Tx) int { + return 1 } -// updateFromNeighbour updates the wire after a neighbour change. changed reports whether the wire's power changed, -// and ok reports whether the update was allowed by the redstone update handler. -func (r RedstoneWire) updateFromNeighbour(pos cube.Pos, tx *world.Tx) (changed bool, ok bool) { - if redstoneUpdateCancelled(pos, tx) { - return false, false +// RedstoneRelayerNeighbours returns all wire positions directly connected to this dust, including dust stepping up or +// down adjacent blocks. +func (RedstoneWire) RedstoneRelayerNeighbours(pos cube.Pos, tx *world.Tx) []cube.Pos { + neighbours := make([]cube.Pos, 0, 12) + faces := redstoneWirePoweredHorizontalFaces(pos, tx) + for _, face := range cube.HorizontalFaces() { + if !faces[face] { + continue + } + side := pos.Side(face) + if side.OutOfBounds(tx.Range()) { + continue + } + positions := redstoneWireHorizontalConnectionPositions(pos, tx, face) + if len(positions) != 0 { + neighbours = append(neighbours, positions...) + continue + } + if redstoneWireRelevantLoaded(tx, side) { + neighbours = append(neighbours, side) + } } - return r.updatePower(pos, tx), true + return neighbours } -// updatePower recalculates the wire's power and propagates the network when the power changes. -func (r RedstoneWire) updatePower(pos cube.Pos, tx *world.Tx) bool { - if power := r.calculatePower(pos, tx); r.Power != power { - r.Power = power - tx.SetBlock(pos, r, &world.SetOpts{DisableBlockUpdates: true}) - updateStrongRedstone(pos, tx) - return true +func (r RedstoneWire) RedstonePowerUpdate(_ cube.Pos, _ *world.Tx, power int) (world.Block, bool) { + power = world.ClampRedstonePower(power) + if r.Power == power { + return r, false } - return false + r.Power = power + return r, true } -// RedstoneSource ... -func (RedstoneWire) RedstoneSource() bool { - return false +func (r RedstoneWire) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if !redstoneWireSupported(tx, pos) { + breakBlock(r, pos, tx) + } } -// WeaklyPowersBlocks returns true because powered redstone dust weakly powers conductive blocks it points into or rests on top of. -func (RedstoneWire) WeaklyPowersBlocks() bool { +func (RedstoneWire) HasLiquidDrops() bool { return true } -// WeakPower returns the power emitted by the wire toward a neighbouring receiver. Dust powers upward, never powers -// downward, and only powers horizontal receivers in connected directions. A powered wire with no horizontal -// connections behaves as an unconnected cross and powers every horizontal side. -func (r RedstoneWire) WeakPower(pos cube.Pos, face cube.Face, tx *world.Tx, accountForDust bool) int { - if !accountForDust { - return 0 - } - if face == cube.FaceUp { - return r.Power - } - if face == cube.FaceDown { - return 0 - } - if !r.hasHorizontalRedstoneConnection(pos, tx) { - return r.Power - } - if r.connection(pos, face.Opposite(), tx) { - return r.Power - } - if r.connection(pos, face, tx) && !r.connection(pos, face.RotateLeft(), tx) && !r.connection(pos, face.RotateRight(), tx) { - return r.Power - } - return 0 +func (r RedstoneWire) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(RedstoneWire{})) } -// StrongPower returns 0 because redstone dust weakly powers conductive blocks rather than strongly powering them. -func (RedstoneWire) StrongPower(cube.Pos, cube.Face, *world.Tx, bool) int { - return 0 +func (RedstoneWire) SideClosed(cube.Pos, cube.Pos, *world.Tx) bool { + return false } -// calculatePower returns the highest level of received redstone power at the provided position. -func (r RedstoneWire) calculatePower(pos cube.Pos, tx *world.Tx) int { - return calculateRedstoneWirePower(pos, tx, tx.Block) +func (r RedstoneWire) EncodeBlock() (string, map[string]any) { + return "minecraft:redstone_wire", map[string]any{"redstone_signal": int32(world.ClampRedstonePower(r.Power))} } -// calculateRedstoneWirePower returns the highest level of received redstone power at the provided position. blockAt is -// injected so direct updates and the BFS wire network share the same rules while the BFS path may still read cached -// node state. -func calculateRedstoneWirePower(pos cube.Pos, tx *world.Tx, blockAt func(cube.Pos) world.Block) int { - aboveBlocksVerticalTravel := blocksRedstoneWireVerticalTravel(blockAt(pos.Side(cube.FaceUp))) - var blockPower, wirePower int - for _, side := range cube.Faces() { - neighbourPos := pos.Side(side) - neighbour := blockAt(neighbourPos) - - wirePower = maxRedstoneWirePower(neighbour, wirePower) - blockPower = max(blockPower, tx.RedstonePower(neighbourPos, side, false)) - - if side.Axis() == cube.Y { - // Only check horizontal neighbours from here on. - continue - } +func (RedstoneWire) EncodeItem() (name string, meta int16) { + return "minecraft:redstone", 0 +} - if canRedstoneWireStepDown(pos, neighbourPos, neighbour, tx) && !aboveBlocksVerticalTravel { - wirePower = maxRedstoneWirePower(blockAt(neighbourPos.Side(cube.FaceUp)), wirePower) - } - if canRedstoneWireStepDown(neighbourPos.Side(cube.FaceDown), neighbourPos, neighbour, tx) && !blocksRedstoneWireVerticalTravel(neighbour) { - wirePower = maxRedstoneWirePower(blockAt(neighbourPos.Side(cube.FaceDown)), wirePower) - } +// TrimMaterial delegates to item.RedstoneWire so the block form stays valid for smithing trim decoding too. +func (RedstoneWire) TrimMaterial() string { + return item.RedstoneWire{}.TrimMaterial() +} - if _, neighbourSolid := neighbour.Model().(model.Solid); !neighbourSolid { - wirePower = maxRedstoneWirePower(blockAt(neighbourPos.Side(cube.FaceDown)), wirePower) - } - } - return max(blockPower, wirePower-1) +// MaterialColour delegates to item.RedstoneWire to keep trim metadata defined in one place. +func (RedstoneWire) MaterialColour() string { + return item.RedstoneWire{}.MaterialColour() } -// hasHorizontalRedstoneConnection checks if the dust connects horizontally to redstone wire or a redstone source. It -// does not include passive receivers such as doors, trapdoors, or note blocks. -func (r RedstoneWire) hasHorizontalRedstoneConnection(pos cube.Pos, tx *world.Tx) bool { - for _, face := range cube.HorizontalFaces() { - if r.connection(pos, face, tx) { - return true - } +func allRedstoneWires() (all []world.Block) { + for i := range 16 { + all = append(all, RedstoneWire{Power: i}) } - return false + return } -// connection returns true if the dust shape connects through the given face to another wire or a redstone source. It -// also accounts for valid one-block vertical wire connections. -func (r RedstoneWire) connection(pos cube.Pos, face cube.Face, tx *world.Tx) bool { - sidePos := pos.Side(face) - sideBlock := tx.Block(sidePos) - if r.connectsAbove(pos, sidePos, sideBlock, tx) || r.connectsTo(sideBlock, true) { - return true - } - return r.connectsBelow(sidePos, sideBlock, tx) +// redstoneTicks converts redstone ticks to a wall-clock duration at 10 redstone ticks per second. +func redstoneTicks(ticks int) time.Duration { + return time.Duration(max(ticks, 1)) * time.Second / 10 } -// connectsAbove checks if the redstone wire can connect to the block above it. -func (r RedstoneWire) connectsAbove(pos, sidePos cube.Pos, sideBlock world.Block, tx *world.Tx) bool { - if blocksRedstoneWireVerticalTravel(tx.Block(pos.Side(cube.FaceUp))) || !r.canRunOnTop(tx, sidePos, sideBlock) { +// redstoneWireSupported reports whether redstone wire can stay placed at pos. +func redstoneWireSupported(tx *world.Tx, pos cube.Pos) bool { + below := pos.Side(cube.FaceDown) + if below.OutOfBounds(tx.Range()) { return false } - return r.connectsTo(tx.Block(sidePos.Side(cube.FaceUp)), false) + return tx.Block(below).Model().FaceSolid(below, cube.FaceUp, tx) } -// connectsBelow checks if the redstone wire can connect to the block below it. -func (r RedstoneWire) connectsBelow(sidePos cube.Pos, sideBlock world.Block, tx *world.Tx) bool { - _, sideSolid := sideBlock.Model().(model.Solid) - return !sideSolid && r.connectsTo(tx.Block(sidePos.Side(cube.FaceDown)), false) +// redstoneWireSupportedLoaded checks support without loading neighbouring chunks. +func redstoneWireSupportedLoaded(tx *world.Tx, pos cube.Pos) bool { + below := pos.Side(cube.FaceDown) + if below.OutOfBounds(tx.Range()) { + return false + } + b, ok := tx.BlockLoaded(below) + return ok && b.Model().FaceSolid(below, cube.FaceUp, tx) } -// connectsTo reports whether a block is part of the redstone wire connection graph. Passive redstone receivers are not -// connections; direct source conductors count only when allowDirectSources is true. -func (RedstoneWire) connectsTo(block world.Block, allowDirectSources bool) bool { - if _, ok := block.(RedstoneWire); ok { +// redstoneWireBlocksConnectionLoaded reports whether a loaded block blocks wire connection through face. +func redstoneWireBlocksConnectionLoaded(tx *world.Tx, pos cube.Pos, face cube.Face) bool { + if pos.OutOfBounds(tx.Range()) { return true } - c, ok := block.(world.Conductor) - return ok && allowDirectSources && c.RedstoneSource() + b, ok := tx.BlockLoaded(pos) + return ok && b.Model().FaceSolid(pos, face, tx) && world.RedstoneFullPowerConductor(pos, b, tx) } -// canRunOnTop checks whether redstone dust can be placed on top of the block. -func (RedstoneWire) canRunOnTop(tx *world.Tx, pos cube.Pos, block world.Block) bool { - return block.Model().FaceSolid(pos, cube.FaceUp, tx) +// redstoneWirePowersHorizontalFace reports whether wire power is exposed through a horizontal face. +func redstoneWirePowersHorizontalFace(pos cube.Pos, tx *world.Tx, face cube.Face) bool { + return redstoneWirePoweredHorizontalFaces(pos, tx)[face] } -// blocksRedstoneWireVerticalTravel checks if the block above redstone wire blocks vertical wire travel. -func blocksRedstoneWireVerticalTravel(block world.Block) bool { - if _, ok := block.Model().(model.Solid); !ok { - return false +// redstoneWirePoweredHorizontalFaces returns the horizontal faces connected by the wire shape. +func redstoneWirePoweredHorizontalFaces(pos cube.Pos, tx *world.Tx) map[cube.Face]bool { + connections := make(map[cube.Face]bool, len(cube.HorizontalFaces())) + for _, face := range cube.HorizontalFaces() { + if len(redstoneWireHorizontalConnectionPositions(pos, tx, face)) != 0 { + connections[face] = true + } } - diffuser, ok := block.(LightDiffuser) - return !ok || diffuser.LightDiffusionLevel() != 0 + switch len(connections) { + case 0: + for _, face := range cube.HorizontalFaces() { + connections[face] = true + } + case 1: + for face := range connections { + connections[face.Opposite()] = true + } + } + return connections } -// canRedstoneWireStepDown checks if redstone dust can provide power while travelling down around the side block. -func canRedstoneWireStepDown(from, side cube.Pos, block world.Block, tx *world.Tx) bool { - if stepDowner, ok := block.(RedstoneWireStepDowner); ok { - return stepDowner.CanRedstoneWireStepDown(side, from, tx) +// redstoneWireHorizontalConnectionPositions returns direct, step-up, and step-down connections through face. +func redstoneWireHorizontalConnectionPositions(pos cube.Pos, tx *world.Tx, face cube.Face) []cube.Pos { + side := pos.Side(face) + if side.OutOfBounds(tx.Range()) { + return nil + } + positions := make([]cube.Pos, 0, 3) + if redstoneWireDirectConnectionLoaded(tx, side, face.Opposite()) { + positions = append(positions, side) + } + + above := pos.Side(cube.FaceUp) + sideAbove := side.Side(cube.FaceUp) + if !redstoneWireBlocksConnectionLoaded(tx, above, cube.FaceDown) && redstoneWireAtLoaded(tx, sideAbove) && redstoneWireSupportedLoaded(tx, sideAbove) { + positions = append(positions, sideAbove) + } + if !redstoneWireBlocksConnectionLoaded(tx, side, cube.FaceUp) { + down := side.Side(cube.FaceDown) + if !down.OutOfBounds(tx.Range()) && redstoneWireAtLoaded(tx, down) && redstoneWireCanTransmitDown(tx, pos) { + positions = append(positions, down) + } + } + return positions +} + +// redstoneWireCanTransmitDown reports whether dust at pos may power dust one block lower. +func redstoneWireCanTransmitDown(tx *world.Tx, pos cube.Pos) bool { + supportPos := pos.Side(cube.FaceDown) + if supportPos.OutOfBounds(tx.Range()) { + return false + } + support, ok := tx.BlockLoaded(supportPos) + if !ok || !support.Model().FaceSolid(supportPos, cube.FaceUp, tx) { + return false + } + if stepDowner, ok := support.(RedstoneWireStepDowner); ok { + return stepDowner.CanRedstoneWireStepDown(supportPos, pos, tx) } for _, face := range cube.Faces() { - if !block.Model().FaceSolid(side, face, tx) { + if !support.Model().FaceSolid(supportPos, face, tx) { return false } } return true } -// TrimMaterial delegates to item.RedstoneWire so the block form stays valid for smithing trim decoding too. -func (RedstoneWire) TrimMaterial() string { - return item.RedstoneWire{}.TrimMaterial() +// redstoneWireDirectConnectionLoaded reports whether a loaded block can directly connect to dust. +func redstoneWireDirectConnectionLoaded(tx *world.Tx, pos cube.Pos, face cube.Face) bool { + b, ok := tx.BlockLoaded(pos) + if !ok { + return false + } + switch b.(type) { + case RedstoneWire, world.RedstonePowerSource, world.RedstoneStrongPowerSource, world.RedstonePowerRelayer: + return true + } + return redstoneWireNonSolidComponent(pos, b, tx, face) } -// MaterialColour delegates to item.RedstoneWire to keep trim metadata defined in one place. -func (RedstoneWire) MaterialColour() string { - return item.RedstoneWire{}.MaterialColour() +// redstoneWireNonSolidComponent reports whether a non-solid loaded block is a redstone endpoint. +func redstoneWireNonSolidComponent(pos cube.Pos, b world.Block, tx *world.Tx, face cube.Face) bool { + model := b.Model() + if model == nil || model.FaceSolid(pos, face, tx) { + return false + } + switch b.(type) { + case world.RedstonePowerConsumer, world.RedstonePowerAction, world.RedstonePowerContextAction: + return true + } + return false } -// allRedstoneWires returns a list of all redstone dust states. -func allRedstoneWires() (all []world.Block) { - for i := range 16 { - all = append(all, RedstoneWire{Power: i}) +// redstoneWireAtLoaded reports whether loaded block data at pos is redstone wire. +func redstoneWireAtLoaded(tx *world.Tx, pos cube.Pos) bool { + b, ok := tx.BlockLoaded(pos) + if !ok { + return false + } + _, ok = b.(RedstoneWire) + return ok +} + +// redstoneWireRelevantLoaded reports whether a loaded block participates in redstone propagation. +func redstoneWireRelevantLoaded(tx *world.Tx, pos cube.Pos) bool { + b, ok := tx.BlockLoaded(pos) + return ok && redstoneWireRelevant(b) +} + +// redstoneWireRelevant reports whether b participates in redstone propagation. +func redstoneWireRelevant(b world.Block) bool { + switch b.(type) { + case world.RedstonePowerSource, + world.RedstoneStrongPowerSource, + world.RedstonePowerRelayer, + world.RedstonePowerConsumer, + world.RedstonePowerAction, + world.RedstonePowerContextAction: + return true + } + return false +} + +// redstoneWireFaceHorizontal reports whether face is one of the four horizontal faces. +func redstoneWireFaceHorizontal(face cube.Face) bool { + switch face { + case cube.FaceNorth, cube.FaceSouth, cube.FaceWest, cube.FaceEast: + return true + default: + return false } - return } diff --git a/server/block/register.go b/server/block/register.go index 57aca28432..2ba3bf633e 100644 --- a/server/block/register.go +++ b/server/block/register.go @@ -1,11 +1,13 @@ package block import ( + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/world" ) //go:generate go run ../../cmd/blockhash -o hash.go . +//go:generate go run ../../cmd/blockaccessor -o accessor.go . // init registers all blocks implemented by Dragonfly. @@ -15,6 +17,7 @@ func init() { world.RegisterBlock(AncientDebris{}) world.RegisterBlock(Andesite{Polished: true}) world.RegisterBlock(Andesite{}) + world.RegisterBlock(BambooMosaic{}) world.RegisterBlock(Barrier{}) world.RegisterBlock(Beacon{}) world.RegisterBlock(Bedrock{InfiniteBurning: true}) @@ -23,6 +26,10 @@ func init() { world.RegisterBlock(Bookshelf{}) world.RegisterBlock(Bricks{}) world.RegisterBlock(Calcite{}) + world.RegisterBlock(Cinnabar{}) + world.RegisterBlock(Cinnabar{Chiseled: true}) + world.RegisterBlock(CinnabarBricks{}) + world.RegisterBlock(PolishedCinnabar{}) world.RegisterBlock(Clay{}) world.RegisterBlock(Coal{}) world.RegisterBlock(Cobblestone{Mossy: true}) @@ -46,6 +53,10 @@ func init() { world.RegisterBlock(Emerald{}) world.RegisterBlock(EnchantingTable{}) world.RegisterBlock(EndBricks{}) + world.RegisterBlock(EndPortal{}) + for _, f := range allEndPortalFrames() { + world.RegisterBlock(f) + } world.RegisterBlock(EndStone{}) world.RegisterBlock(FletchingTable{}) world.RegisterBlock(GlassPane{}) @@ -94,6 +105,8 @@ func init() { world.RegisterBlock(Podzol{}) world.RegisterBlock(PolishedBlackstoneBrick{Cracked: true}) world.RegisterBlock(PolishedBlackstoneBrick{}) + world.RegisterBlock(Portal{Axis: cube.X}) + world.RegisterBlock(Portal{Axis: cube.Z}) world.RegisterBlock(QuartzBricks{}) world.RegisterBlock(RawCopper{}) world.RegisterBlock(RawGold{}) @@ -119,8 +132,13 @@ func init() { world.RegisterBlock(SporeBlossom{}) world.RegisterBlock(Stone{Smooth: true}) world.RegisterBlock(Stone{}) + world.RegisterBlock(Sulfur{}) + world.RegisterBlock(Sulfur{Chiseled: true}) + world.RegisterBlock(SulfurBricks{}) + world.RegisterBlock(PolishedSulfur{}) world.RegisterBlock(TNT{}) world.RegisterBlock(Terracotta{}) + world.RegisterBlock(TintedGlass{}) world.RegisterBlock(Tuff{}) world.RegisterBlock(Tuff{Chiseled: true}) world.RegisterBlock(TuffBricks{}) @@ -142,7 +160,9 @@ func init() { } registerAll(allAnvils()) - registerAll(allAzalea()) + registerAll(allBambooBlocks()) + registerAll(allBamboos()) + registerAll(allBambooSaplings()) registerAll(allBanners()) registerAll(allBarrels()) registerAll(allBasalt()) @@ -247,6 +267,7 @@ func init() { registerAll(allCopperLanterns()) registerAll(allCopperTorches()) registerAll(allCopperTrapdoors()) + registerAll(allShulkerBoxes()) world.DefaultBlockRegistry.Finalize() } @@ -256,6 +277,10 @@ func init() { world.RegisterItem(AncientDebris{}) world.RegisterItem(Andesite{Polished: true}) world.RegisterItem(Andesite{}) + world.RegisterItem(Bamboo{}) + world.RegisterItem(BambooBlock{}) + world.RegisterItem(BambooBlock{Stripped: true}) + world.RegisterItem(BambooMosaic{}) world.RegisterItem(Azalea{}) world.RegisterItem(Azalea{Flowering: true}) world.RegisterItem(Barrel{}) @@ -275,6 +300,10 @@ func init() { world.RegisterItem(Cake{}) world.RegisterItem(Calcite{}) world.RegisterItem(Carrot{}) + world.RegisterItem(Cinnabar{}) + world.RegisterItem(Cinnabar{Chiseled: true}) + world.RegisterItem(CinnabarBricks{}) + world.RegisterItem(PolishedCinnabar{}) world.RegisterItem(IronChain{}) world.RegisterItem(Chest{}) world.RegisterItem(ChiseledQuartz{}) @@ -304,6 +333,7 @@ func init() { world.RegisterItem(Emerald{}) world.RegisterItem(EnchantingTable{}) world.RegisterItem(EndBricks{}) + world.RegisterItem(EndPortalFrame{}) world.RegisterItem(EndRod{}) world.RegisterItem(EndStone{}) world.RegisterItem(EnderChest{}) @@ -410,8 +440,13 @@ func init() { world.RegisterItem(Stone{}) world.RegisterItem(String{}) world.RegisterItem(SugarCane{}) + world.RegisterItem(Sulfur{}) + world.RegisterItem(Sulfur{Chiseled: true}) + world.RegisterItem(SulfurBricks{}) + world.RegisterItem(PolishedSulfur{}) world.RegisterItem(TNT{}) world.RegisterItem(Terracotta{}) + world.RegisterItem(TintedGlass{}) world.RegisterItem(Tuff{}) world.RegisterItem(Tuff{Chiseled: true}) world.RegisterItem(TuffBricks{}) @@ -461,20 +496,21 @@ func init() { world.RegisterItem(Wool{Colour: c}) } for _, w := range WoodTypes() { - if w != WarpedWood() && w != CrimsonWood() { - t, _ := w.Leaves() + if t, ok := w.Leaves(); ok { world.RegisterItem(Leaves{Type: t, Persistent: true}) } - world.RegisterItem(Log{Wood: w, Stripped: true}) - world.RegisterItem(Log{Wood: w}) + if w != BambooWood() { + world.RegisterItem(Log{Wood: w, Stripped: true}) + world.RegisterItem(Log{Wood: w}) + world.RegisterItem(Wood{Wood: w, Stripped: true}) + world.RegisterItem(Wood{Wood: w}) + } world.RegisterItem(Planks{Wood: w}) world.RegisterItem(Sign{Wood: w}) world.RegisterItem(WoodDoor{Wood: w}) world.RegisterItem(WoodFenceGate{Wood: w}) world.RegisterItem(WoodFence{Wood: w}) world.RegisterItem(WoodTrapdoor{Wood: w}) - world.RegisterItem(Wood{Wood: w, Stripped: true}) - world.RegisterItem(Wood{Wood: w}) world.RegisterItem(Sapling{Wood: w}) } world.RegisterItem(Leaves{Type: AzaleaLeaves(), Persistent: true}) @@ -489,6 +525,9 @@ func init() { world.RegisterItem(LapisOre{Type: ore}) world.RegisterItem(RedstoneOre{Type: ore}) } + for _, c := range item.OptionalColours() { + world.RegisterItem(Candle{Colour: c}) + } for _, f := range FireTypes() { world.RegisterItem(Lantern{Type: f}) world.RegisterItem(Torch{Type: f}) @@ -559,6 +598,10 @@ func init() { for _, c := range item.Colours() { world.RegisterItem(Candle{Colour: c, Dyed: true}) } + + for _, c := range item.OptionalColours() { + world.RegisterItem(ShulkerBox{Colour: c}) + } } func registerAll(blocks []world.Block) { diff --git a/server/block/reinforced_deepslate.go b/server/block/reinforced_deepslate.go index 149a132ed5..4337003130 100644 --- a/server/block/reinforced_deepslate.go +++ b/server/block/reinforced_deepslate.go @@ -8,7 +8,7 @@ type ReinforcedDeepslate struct { // BreakInfo ... func (r ReinforcedDeepslate) BreakInfo() BreakInfo { - return newBreakInfo(55, alwaysHarvestable, nothingEffective, oneOf(r)).withBlastResistance(6000) + return newBreakInfo(55, alwaysHarvestable, nothingEffective, oneOf(r)).withBlastResistance(1200) } // EncodeItem ... diff --git a/server/block/resin_bricks.go b/server/block/resin_bricks.go index 384dbacae5..168f37b1a6 100644 --- a/server/block/resin_bricks.go +++ b/server/block/resin_bricks.go @@ -11,7 +11,7 @@ type ResinBricks struct { // BreakInfo ... func (r ResinBricks) BreakInfo() BreakInfo { - return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(r)).withBlastResistance(30) + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(r)).withBlastResistance(6) } // EncodeItem ... diff --git a/server/block/sand.go b/server/block/sand.go index f7ad54c696..d2fa6596fc 100644 --- a/server/block/sand.go +++ b/server/block/sand.go @@ -19,7 +19,7 @@ type Sand struct { // SoilFor ... func (s Sand) SoilFor(block world.Block) bool { switch block.(type) { - case Cactus, DeadBush, SugarCane: + case Cactus, DeadBush, SugarCane, BambooSapling, Bamboo: return true } return false diff --git a/server/block/sandstone.go b/server/block/sandstone.go index 466cf2547b..dbb4b99e54 100644 --- a/server/block/sandstone.go +++ b/server/block/sandstone.go @@ -21,7 +21,7 @@ type Sandstone struct { // BreakInfo ... func (s Sandstone) BreakInfo() BreakInfo { if s.Type == SmoothSandstone() { - return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(s)).withBlastResistance(30) + return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(s)).withBlastResistance(6) } return newBreakInfo(0.8, pickaxeHarvestable, pickaxeEffective, oneOf(s)) } diff --git a/server/block/sea_pickle.go b/server/block/sea_pickle.go index 96df4d893b..1c36ef4062 100644 --- a/server/block/sea_pickle.go +++ b/server/block/sea_pickle.go @@ -145,7 +145,7 @@ func (s SeaPickle) LightEmissionLevel() uint8 { // BreakInfo ... func (s SeaPickle) BreakInfo() BreakInfo { - return newBreakInfo(0, alwaysHarvestable, nothingEffective, simpleDrops(item.NewStack(s, s.AdditionalCount+1))) + return newBreakInfo(0, alwaysHarvestable, nothingEffective, simpleDrops(item.NewStack(SeaPickle{}, s.AdditionalCount+1))) } // FlammabilityInfo ... diff --git a/server/block/shulker_box.go b/server/block/shulker_box.go new file mode 100644 index 0000000000..d527218828 --- /dev/null +++ b/server/block/shulker_box.go @@ -0,0 +1,300 @@ +package block + +import ( + "fmt" + "math/rand/v2" + "strings" + "sync" + "sync/atomic" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/block/model" + "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/inventory" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" +) + +const ( + shulkerStateClosed int32 = iota + shulkerStateOpening + shulkerStateOpened + shulkerStateClosing +) + +// shulkerLidTicks is the number of scheduled ticks between fully closed and fully open. +const shulkerLidTicks int32 = 10 + +// ShulkerBox is a dye-able block that stores items. Unlike other blocks, it keeps its contents when broken. +type ShulkerBox struct { + transparent + sourceWaterDisplacer + + // Colour is the colour of the shulker box. A zero OptionalColour represents + // the undyed variant (minecraft:undyed_shulker_box). + Colour item.OptionalColour + // Facing is the direction that the shulker box is facing. + Facing cube.Face + // CustomName is the custom name of the shulker box. This name is displayed when the shulker box is opened, and may + // include colour codes. + CustomName string + + inventory *inventory.Inventory + viewerMu *sync.RWMutex + viewers map[ContainerViewer]struct{} + // progress is the lid opening progress in [0, 10]. + progress *atomic.Int32 + // animationStatus is the current openness state of the shulker box (whether it's opened, closing, etc.). + animationStatus *atomic.Int32 +} + +// NewShulkerBox creates a new initialised shulker box. The inventory is properly initialised. +func NewShulkerBox() ShulkerBox { + s := ShulkerBox{ + viewerMu: new(sync.RWMutex), + viewers: make(map[ContainerViewer]struct{}, 1), + progress: new(atomic.Int32), + animationStatus: new(atomic.Int32), + } + + s.inventory = inventory.New(27, func(slot int, _, after item.Stack) { + s.viewerMu.RLock() + defer s.viewerMu.RUnlock() + for viewer := range s.viewers { + viewer.ViewSlotChange(slot, after) + } + }) + s.inventory.SlotValidatorFunc(canStoreInShulkerBox) + + return s +} + +// canStoreInShulkerBox rejects nested shulker boxes. +func canStoreInShulkerBox(s item.Stack, _ int) bool { + if s.Empty() { + return true + } + _, nested := s.Item().(ShulkerBox) + return !nested +} + +func (s ShulkerBox) Model() world.BlockModel { + return model.Shulker{Facing: s.Facing, Progress: s.progress.Load()} +} + +func (s ShulkerBox) WithName(a ...any) world.Item { + s.CustomName = strings.TrimSuffix(fmt.Sprintln(a...), "\n") + return s +} + +func (s ShulkerBox) AddViewer(v ContainerViewer, tx *world.Tx, pos cube.Pos) { + s.viewerMu.Lock() + defer s.viewerMu.Unlock() + if len(s.viewers) == 0 { + s.open(tx, pos) + } + s.viewers[v] = struct{}{} +} + +func (s ShulkerBox) RemoveViewer(v ContainerViewer, tx *world.Tx, pos cube.Pos) { + s.viewerMu.Lock() + defer s.viewerMu.Unlock() + if len(s.viewers) == 0 { + return + } + delete(s.viewers, v) + if len(s.viewers) == 0 { + s.close(tx, pos) + } +} + +func (s ShulkerBox) Inventory(*world.Tx, cube.Pos) *inventory.Inventory { + return s.inventory +} + +func (s ShulkerBox) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + opener, ok := u.(ContainerOpener) + if !ok { + return false + } + if d, ok := tx.Block(pos.Side(s.Facing)).(LightDiffuser); ok && d.LightDiffusionLevel() <= 2 { + opener.OpenBlockContainer(pos, tx) + } + return true +} + +func (s ShulkerBox) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(tx, pos, face, s) + if !used { + return + } + s = s.initialised() + s.Facing = face + place(tx, pos, s, user, ctx) + return placed(ctx) +} + +// initialised lazily populates runtime fields on values created via struct +// literal (e.g. those returned from allShulkerBoxes). +func (s ShulkerBox) initialised() ShulkerBox { + if s.inventory != nil { + return s + } + n := NewShulkerBox() + n.Colour, n.Facing, n.CustomName = s.Colour, s.Facing, s.CustomName + return n +} + +// open opens the shulker box, displaying the animation and playing a sound. +func (s ShulkerBox) open(tx *world.Tx, pos cube.Pos) { + s.animationStatus.Store(shulkerStateOpening) + for _, v := range tx.Viewers(pos.Vec3()) { + v.ViewBlockAction(pos, OpenAction{}) + } + tx.PlaySound(pos.Vec3Centre(), sound.ShulkerBoxOpen{}) + tx.ScheduleBlockUpdate(pos, s, 0) +} + +// close closes the shulker box, displaying the animation and playing a sound. +func (s ShulkerBox) close(tx *world.Tx, pos cube.Pos) { + s.animationStatus.Store(shulkerStateClosing) + for _, v := range tx.Viewers(pos.Vec3()) { + v.ViewBlockAction(pos, CloseAction{}) + } + tx.ScheduleBlockUpdate(pos, s, 0) +} + +func (s ShulkerBox) ScheduledTick(pos cube.Pos, tx *world.Tx, _ *rand.Rand) { + switch s.animationStatus.Load() { + case shulkerStateClosed: + s.progress.Store(0) + case shulkerStateOpening: + s.progress.Add(1) + s.pushEntities(pos, tx) + if s.progress.Load() >= shulkerLidTicks { + s.progress.Store(shulkerLidTicks) + s.animationStatus.Store(shulkerStateOpened) + } + tx.ScheduleBlockUpdate(pos, s, 0) + case shulkerStateOpened: + s.progress.Store(shulkerLidTicks) + case shulkerStateClosing: + s.progress.Add(-1) + if s.progress.Load() <= 0 { + tx.PlaySound(pos.Vec3Centre(), sound.ShulkerBoxClose{}) + s.progress.Store(0) + s.animationStatus.Store(shulkerStateClosed) + } + tx.ScheduleBlockUpdate(pos, s, 0) + } +} + +// pushEntities pushes all entities touching the shulker box lid during opening. +func (s ShulkerBox) pushEntities(pos cube.Pos, tx *world.Tx) { + shulkerBBoxes := s.Model().BBox(pos, tx) + if len(shulkerBBoxes) == 0 { + return + } + searchBox := shulkerBBoxes[0].Translate(pos.Vec3()).Grow(0.35) + for e := range tx.EntitiesWithin(searchBox) { + s.push(pos, tx, e) + } +} + +// push pushes entities when the shulker box lid is opening. +func (s ShulkerBox) push(pos cube.Pos, tx *world.Tx, e world.Entity) { + if s.animationStatus.Load() != shulkerStateOpening { + return + } + mover, ok := e.(interface { + Displace(deltaPos mgl64.Vec3) + }) + if !ok { + return + } + shulkerBBoxes := s.Model().BBox(pos, tx) + if len(shulkerBBoxes) == 0 { + return + } + shulkerBBox := shulkerBBoxes[0].Translate(pos.Vec3()) + entityBBox := e.H().Type().BBox(e).Translate(e.Position()) + if !shulkerBBox.IntersectsWith(entityBBox) { + return + } + + // Move the entity out along the lid's facing axis by the penetration depth + // between the shulker lid box and the entity box. + delta := shulkerPushDelta(s.Facing, shulkerBBox, entityBBox) + if delta != (mgl64.Vec3{}) { + mover.Displace(delta) + } +} + +func shulkerPushDelta(facing cube.Face, shulkerBBox, entityBBox cube.BBox) (delta mgl64.Vec3) { + switch facing { + case cube.FaceDown: + delta[1] = shulkerBBox.Min().Y() - entityBBox.Max().Y() + case cube.FaceUp: + delta[1] = shulkerBBox.Max().Y() - entityBBox.Min().Y() + case cube.FaceEast: + delta[0] = shulkerBBox.Max().X() - entityBBox.Min().X() + case cube.FaceWest: + delta[0] = shulkerBBox.Min().X() - entityBBox.Max().X() + case cube.FaceSouth: + delta[2] = shulkerBBox.Max().Z() - entityBBox.Min().Z() + case cube.FaceNorth: + delta[2] = shulkerBBox.Min().Z() - entityBBox.Max().Z() + } + return delta +} + +func (s ShulkerBox) BreakInfo() BreakInfo { + return newBreakInfo(2, alwaysHarvestable, pickaxeEffective, oneOf(s)) +} + +func (s ShulkerBox) MaxCount() int { + return 1 +} + +func (s ShulkerBox) EncodeBlock() (name string, properties map[string]any) { + if c, ok := s.Colour.Colour(); ok { + return "minecraft:" + c.String() + "_shulker_box", nil + } + return "minecraft:undyed_shulker_box", nil +} + +func (s ShulkerBox) EncodeItem() (id string, meta int16) { + name, _ := s.EncodeBlock() + return name, 0 +} + +func (s ShulkerBox) DecodeNBT(data map[string]any) any { + s = s.initialised() + nbtconv.InvFromNBT(s.inventory, nbtconv.Slice(data, "Items")) + s.Facing = cube.Face(nbtconv.Uint8(data, "facing")) + s.CustomName = nbtconv.String(data, "CustomName") + return s +} + +func (s ShulkerBox) EncodeNBT() map[string]any { + s = s.initialised() + m := map[string]any{ + "Items": nbtconv.InvToNBT(s.inventory), + "id": "ShulkerBox", + "facing": uint8(s.Facing), + } + if s.CustomName != "" { + m["CustomName"] = s.CustomName + } + return m +} + +// allShulkerBoxes returns one shulker box per item.OptionalColour, including the undyed variant. +func allShulkerBoxes() (boxes []world.Block) { + for _, c := range item.OptionalColours() { + boxes = append(boxes, ShulkerBox{Colour: c}) + } + return boxes +} diff --git a/server/block/sign.go b/server/block/sign.go index eaae6b65de..836beb78d7 100644 --- a/server/block/sign.go +++ b/server/block/sign.go @@ -200,18 +200,20 @@ func (s Sign) DecodeNBT(data map[string]any) any { front, ok := data["FrontText"].(map[string]any) if ok { - s.Front.BaseColour = nbtconv.RGBAFromInt32(nbtconv.Int32(front, "Color")) - s.Front.Glowing = nbtconv.Bool(front, "GlowingText") + s.Front.BaseColour = nbtconv.RGBAFromInt32(nbtconv.Int32(front, "SignTextColor")) + s.Front.Glowing = nbtconv.Bool(front, "IgnoreLighting") s.Front.Text = nbtconv.String(front, "Text") - s.Front.Owner = nbtconv.String(front, "Owner") + s.Front.Owner = nbtconv.String(front, "TextOwner") } + s.Waxed = nbtconv.Bool(data, "IsWaxed") + back, ok := data["BackText"].(map[string]any) if ok { - s.Back.BaseColour = nbtconv.RGBAFromInt32(nbtconv.Int32(back, "Color")) - s.Back.Glowing = nbtconv.Bool(back, "GlowingText") + s.Back.BaseColour = nbtconv.RGBAFromInt32(nbtconv.Int32(back, "SignTextColor")) + s.Back.Glowing = nbtconv.Bool(back, "IgnoreLighting") s.Back.Text = nbtconv.String(back, "Text") - s.Back.Owner = nbtconv.String(back, "Owner") + s.Back.Owner = nbtconv.String(back, "TextOwner") } return s diff --git a/server/block/slab.go b/server/block/slab.go index 13cb1d90f5..423e6defb8 100644 --- a/server/block/slab.go +++ b/server/block/slab.go @@ -63,6 +63,9 @@ func (s Slab) Instrument() sound.Instrument { if _, ok := s.Block.(Planks); ok { return sound.Bass() } + if _, ok := s.Block.(BambooMosaic); ok { + return sound.Bass() + } return sound.BassDrum() } @@ -109,11 +112,13 @@ func (s Slab) CanRedstoneWireStepDown(cube.Pos, cube.Pos, *world.Tx) bool { // BreakInfo ... func (s Slab) BreakInfo() BreakInfo { - hardness, blastResistance, harvestable, effective := 2.0, 30.0, pickaxeHarvestable, pickaxeEffective + hardness, blastResistance, harvestable, effective := 2.0, 6.0, pickaxeHarvestable, pickaxeEffective switch block := s.Block.(type) { case Stone, Sandstone, Quartz, Purpur, Blackstone, PolishedBlackstoneBrick: // These slab types do not match their block's hardness or blast resistance + case EndBricks: + hardness = 3 case StoneBricks: if block.Type == MossyStoneBricks() { hardness = 1.5 @@ -123,10 +128,11 @@ func (s Slab) BreakInfo() BreakInfo { hardness, blastResistance, harvestable, effective = breakInfo.Hardness, breakInfo.BlastResistance, breakInfo.Harvestable, breakInfo.Effective } return newBreakInfo(hardness, harvestable, effective, func(tool item.Tool, enchantments []item.Enchantment) []item.Stack { + single := Slab{Block: s.Block} if s.Double { - return []item.Stack{item.NewStack(s, 2)} + return []item.Stack{item.NewStack(single, 2)} } - return []item.Stack{item.NewStack(s, 1)} + return []item.Stack{item.NewStack(single, 1)} }).withBlastResistance(blastResistance) } diff --git a/server/block/slab_type.go b/server/block/slab_type.go index f5e4185bfa..bd787b1538 100644 --- a/server/block/slab_type.go +++ b/server/block/slab_type.go @@ -25,8 +25,16 @@ func encodeSlabBlock(block world.Block, double bool) (id string, suffix string) } else if block.Type == PolishedBlackstone() { return "polished_blackstone", suffix } + case BambooMosaic: + return "bamboo_mosaic", suffix case Bricks: return "brick", suffix + case Cinnabar: + if !block.Chiseled { + return "cinnabar", suffix + } + case CinnabarBricks: + return "cinnabar_brick", suffix case Cobblestone: if block.Mossy { return "mossy_cobblestone", suffix @@ -86,6 +94,10 @@ func encodeSlabBlock(block world.Block, double bool) (id string, suffix string) if !block.Cracked { return "polished_blackstone_brick", suffix } + case PolishedCinnabar: + return "polished_cinnabar", suffix + case PolishedSulfur: + return "polished_sulfur", suffix case PolishedTuff: return "polished_tuff", suffix case Prismarine: @@ -136,6 +148,12 @@ func encodeSlabBlock(block world.Block, double bool) (id string, suffix string) return "mossy_stone_brick", suffix } return "stone_brick", suffix + case Sulfur: + if !block.Chiseled { + return "sulfur", suffix + } + case SulfurBricks: + return "sulfur_brick", suffix case Tuff: if !block.Chiseled { return "tuff", suffix @@ -153,9 +171,12 @@ func SlabBlocks() []world.Block { b := []world.Block{ Andesite{Polished: true}, Andesite{}, + BambooMosaic{}, Blackstone{Type: PolishedBlackstone()}, Blackstone{}, Bricks{}, + Cinnabar{}, + CinnabarBricks{}, Cobblestone{Mossy: true}, Cobblestone{}, DeepslateBricks{}, @@ -171,6 +192,8 @@ func SlabBlocks() []world.Block { NetherBricks{Type: RedNetherBricks()}, NetherBricks{}, PolishedBlackstoneBrick{}, + PolishedCinnabar{}, + PolishedSulfur{}, PolishedTuff{}, Purpur{}, Quartz{Smooth: true}, @@ -180,6 +203,8 @@ func SlabBlocks() []world.Block { StoneBricks{}, Stone{Smooth: true}, Stone{}, + Sulfur{}, + SulfurBricks{}, Tuff{}, TuffBricks{}, } diff --git a/server/block/smoker.go b/server/block/smoker.go index 63959750b0..a4c07e690e 100644 --- a/server/block/smoker.go +++ b/server/block/smoker.go @@ -81,7 +81,10 @@ func (s Smoker) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world // BreakInfo ... func (s Smoker) BreakInfo() BreakInfo { - xp := s.Experience() + xp := 0 + if s.smelter != nil { + xp = s.Experience() + } return newBreakInfo(3.5, alwaysHarvestable, pickaxeEffective, oneOf(Smoker{})).withXPDropRange(xp, xp).withBreakHandler(func(pos cube.Pos, tx *world.Tx, u item.User) { for _, i := range s.Inventory(tx, pos).Clear() { dropItem(tx, i, pos.Vec3()) diff --git a/server/block/smooth_basalt.go b/server/block/smooth_basalt.go index 7030f373bf..c4eb10df69 100644 --- a/server/block/smooth_basalt.go +++ b/server/block/smooth_basalt.go @@ -20,7 +20,7 @@ func (SmoothBasalt) EncodeItem() (name string, meta int16) { // BreakInfo ... func (s SmoothBasalt) BreakInfo() BreakInfo { - return newBreakInfo(1.25, pickaxeHarvestable, pickaxeEffective, oneOf(s)).withBlastResistance(21) + return newBreakInfo(1.25, pickaxeHarvestable, pickaxeEffective, oneOf(s)).withBlastResistance(4.2) } func (s SmoothBasalt) Color() color.RGBA { diff --git a/server/block/stained_terracotta.go b/server/block/stained_terracotta.go index c1ae9c3c24..65cddfacc5 100644 --- a/server/block/stained_terracotta.go +++ b/server/block/stained_terracotta.go @@ -23,7 +23,7 @@ func (t StainedTerracotta) SoilFor(block world.Block) bool { // BreakInfo ... func (t StainedTerracotta) BreakInfo() BreakInfo { - return newBreakInfo(1.25, pickaxeHarvestable, pickaxeEffective, oneOf(t)).withBlastResistance(21) + return newBreakInfo(1.25, pickaxeHarvestable, pickaxeEffective, oneOf(t)) } // SmeltInfo ... diff --git a/server/block/stairs.go b/server/block/stairs.go index 665199100e..ff0e4827d9 100644 --- a/server/block/stairs.go +++ b/server/block/stairs.go @@ -55,6 +55,9 @@ func (s Stairs) Instrument() sound.Instrument { if _, ok := s.Block.(Planks); ok { return sound.Bass() } + if _, ok := s.Block.(BambooMosaic); ok { + return sound.Bass() + } return sound.BassDrum() } diff --git a/server/block/stairs_type.go b/server/block/stairs_type.go index e3b2d9f94b..2c98f00d3f 100644 --- a/server/block/stairs_type.go +++ b/server/block/stairs_type.go @@ -18,8 +18,16 @@ func encodeStairsBlock(block world.Block) string { } else if block.Type == PolishedBlackstone() { return "polished_blackstone" } + case BambooMosaic: + return "bamboo_mosaic" case Bricks: return "brick" + case Cinnabar: + if !block.Chiseled { + return "cinnabar" + } + case CinnabarBricks: + return "cinnabar_brick" case Cobblestone: if block.Mossy { return "mossy_cobblestone" @@ -75,6 +83,10 @@ func encodeStairsBlock(block world.Block) string { if !block.Cracked { return "polished_blackstone_brick" } + case PolishedCinnabar: + return "polished_cinnabar" + case PolishedSulfur: + return "polished_sulfur" case PolishedTuff: return "polished_tuff" case Prismarine: @@ -119,6 +131,12 @@ func encodeStairsBlock(block world.Block) string { return "mossy_stone_brick" } return "stone_brick" + case Sulfur: + if !block.Chiseled { + return "sulfur" + } + case SulfurBricks: + return "sulfur_brick" case Tuff: if !block.Chiseled { return "tuff" @@ -136,9 +154,12 @@ func StairsBlocks() []world.Block { b := []world.Block{ Andesite{Polished: true}, Andesite{}, + BambooMosaic{}, Blackstone{Type: PolishedBlackstone()}, Blackstone{}, Bricks{}, + Cinnabar{}, + CinnabarBricks{}, Cobblestone{Mossy: true}, Cobblestone{}, DeepslateBricks{}, @@ -154,6 +175,8 @@ func StairsBlocks() []world.Block { NetherBricks{Type: RedNetherBricks()}, NetherBricks{}, PolishedBlackstoneBrick{}, + PolishedCinnabar{}, + PolishedSulfur{}, PolishedTuff{}, Purpur{}, Quartz{Smooth: true}, @@ -162,6 +185,8 @@ func StairsBlocks() []world.Block { StoneBricks{Type: MossyStoneBricks()}, StoneBricks{}, Stone{}, + Sulfur{}, + SulfurBricks{}, Tuff{}, TuffBricks{}, } diff --git a/server/block/stone.go b/server/block/stone.go index 3727132bb9..8b73dedf53 100644 --- a/server/block/stone.go +++ b/server/block/stone.go @@ -32,24 +32,24 @@ type ( // BreakInfo ... func (s Stone) BreakInfo() BreakInfo { if s.Smooth { - return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(s)).withBlastResistance(30) + return newBreakInfo(2, pickaxeHarvestable, pickaxeEffective, oneOf(s)).withBlastResistance(6) } - return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, silkTouchOneOf(Cobblestone{}, Stone{})).withBlastResistance(30) + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, silkTouchOneOf(Cobblestone{}, Stone{})).withBlastResistance(6) } // BreakInfo ... func (g Granite) BreakInfo() BreakInfo { - return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(g)).withBlastResistance(30) + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(g)).withBlastResistance(6) } // BreakInfo ... func (d Diorite) BreakInfo() BreakInfo { - return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(d)).withBlastResistance(30) + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(d)).withBlastResistance(6) } // BreakInfo ... func (a Andesite) BreakInfo() BreakInfo { - return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(a)).withBlastResistance(30) + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(a)).withBlastResistance(6) } // SmeltInfo ... diff --git a/server/block/stone_bricks.go b/server/block/stone_bricks.go index 0e043ce5e6..861f184e92 100644 --- a/server/block/stone_bricks.go +++ b/server/block/stone_bricks.go @@ -17,7 +17,7 @@ type StoneBricks struct { // BreakInfo ... func (s StoneBricks) BreakInfo() BreakInfo { - return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(s)).withBlastResistance(30) + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(s)).withBlastResistance(6) } // SmeltInfo ... diff --git a/server/block/sulfur.go b/server/block/sulfur.go new file mode 100644 index 0000000000..e76ab06409 --- /dev/null +++ b/server/block/sulfur.go @@ -0,0 +1,31 @@ +package block + +// Sulfur is a decorative rock that generates throughout sulfur caves and as part of sulfur springs. +type Sulfur struct { + solid + bassDrum + + // Chiseled specifies if the sulfur is chiseled. + Chiseled bool +} + +// BreakInfo ... +func (s Sulfur) BreakInfo() BreakInfo { + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(s)).withBlastResistance(6) +} + +// EncodeItem ... +func (s Sulfur) EncodeItem() (name string, meta int16) { + if s.Chiseled { + return "minecraft:chiseled_sulfur", 0 + } + return "minecraft:sulfur", 0 +} + +// EncodeBlock ... +func (s Sulfur) EncodeBlock() (string, map[string]any) { + if s.Chiseled { + return "minecraft:chiseled_sulfur", nil + } + return "minecraft:sulfur", nil +} diff --git a/server/block/sulfur_bricks.go b/server/block/sulfur_bricks.go new file mode 100644 index 0000000000..19de908078 --- /dev/null +++ b/server/block/sulfur_bricks.go @@ -0,0 +1,22 @@ +package block + +// SulfurBricks is a decorative variant of Sulfur. +type SulfurBricks struct { + solid + bassDrum +} + +// BreakInfo ... +func (s SulfurBricks) BreakInfo() BreakInfo { + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(s)).withBlastResistance(6) +} + +// EncodeItem ... +func (SulfurBricks) EncodeItem() (name string, meta int16) { + return "minecraft:sulfur_bricks", 0 +} + +// EncodeBlock ... +func (SulfurBricks) EncodeBlock() (string, map[string]any) { + return "minecraft:sulfur_bricks", nil +} diff --git a/server/block/terracotta.go b/server/block/terracotta.go index 0490068a92..0ed96029ea 100644 --- a/server/block/terracotta.go +++ b/server/block/terracotta.go @@ -17,7 +17,7 @@ func (Terracotta) SoilFor(block world.Block) bool { // BreakInfo ... func (t Terracotta) BreakInfo() BreakInfo { - return newBreakInfo(1.25, pickaxeHarvestable, pickaxeEffective, oneOf(t)).withBlastResistance(21) + return newBreakInfo(1.25, pickaxeHarvestable, pickaxeEffective, oneOf(t)).withBlastResistance(4.2) } // EncodeItem ... diff --git a/server/block/tinted_glass.go b/server/block/tinted_glass.go new file mode 100644 index 0000000000..251f8cf336 --- /dev/null +++ b/server/block/tinted_glass.go @@ -0,0 +1,29 @@ +package block + +// TintedGlass is a decorative, solid block that is visually see-through but, unlike regular glass, blocks +// all light passing through it. +type TintedGlass struct { + solid + clicksAndSticks +} + +// PreventsSuffocation always returns true. Tinted glass blocks light entirely but, like regular glass, never +// suffocates an entity standing inside it. +func (TintedGlass) PreventsSuffocation() bool { + return true +} + +// BreakInfo ... +func (g TintedGlass) BreakInfo() BreakInfo { + return newBreakInfo(0.3, alwaysHarvestable, nothingEffective, oneOf(g)).withBlastResistance(0.3) +} + +// EncodeItem ... +func (TintedGlass) EncodeItem() (name string, meta int16) { + return "minecraft:tinted_glass", 0 +} + +// EncodeBlock ... +func (TintedGlass) EncodeBlock() (string, map[string]any) { + return "minecraft:tinted_glass", nil +} diff --git a/server/block/tnt.go b/server/block/tnt.go index 4606f45126..8e9923c087 100644 --- a/server/block/tnt.go +++ b/server/block/tnt.go @@ -1,14 +1,14 @@ package block import ( + "math/rand/v2" + "time" + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/item/enchantment" "github.com/df-mc/dragonfly/server/world" "github.com/df-mc/dragonfly/server/world/sound" - "github.com/go-gl/mathgl/mgl64" - "math/rand/v2" - "time" ) // TNT is an explosive block that can be primed to generate an explosion. @@ -16,6 +16,18 @@ type TNT struct { solid } +var _ world.RedstonePowerAction = TNT{} + +func (TNT) RedstoneNonConductive() {} + +// RedstonePowerAction primes TNT when it first receives redstone power. +func (t TNT) RedstonePowerAction(pos cube.Pos, tx *world.Tx, oldPower, newPower int) { + if oldPower > 0 || newPower == 0 { + return + } + t.Ignite(pos, tx, nil) +} + // ProjectileHit ... func (t TNT) ProjectileHit(pos cube.Pos, tx *world.Tx, e world.Entity, _ cube.Face) { if f, ok := e.(flammableEntity); ok && f.OnFireDuration() > 0 { @@ -41,7 +53,7 @@ func (t TNT) Ignite(pos cube.Pos, tx *world.Tx, _ world.Entity) bool { } // Explode ... -func (t TNT) Explode(_ mgl64.Vec3, pos cube.Pos, tx *world.Tx, _ ExplosionConfig) { +func (t TNT) Explode(_ world.ExplosionSource, pos cube.Pos, tx *world.Tx) { spawnTnt(pos, tx, time.Second/2+time.Duration(rand.IntN(int(time.Second+time.Second/2)))) } diff --git a/server/block/tuff.go b/server/block/tuff.go index 5ea6689083..c0404926ce 100644 --- a/server/block/tuff.go +++ b/server/block/tuff.go @@ -11,7 +11,7 @@ type Tuff struct { // BreakInfo ... func (t Tuff) BreakInfo() BreakInfo { - return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(t)).withBlastResistance(30) + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(t)).withBlastResistance(6) } // EncodeItem ... diff --git a/server/block/tuff_bricks.go b/server/block/tuff_bricks.go index c5520354af..4daa98900c 100644 --- a/server/block/tuff_bricks.go +++ b/server/block/tuff_bricks.go @@ -13,7 +13,7 @@ type TuffBricks struct { // BreakInfo ... func (t TuffBricks) BreakInfo() BreakInfo { - return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(t)).withBlastResistance(30) + return newBreakInfo(1.5, pickaxeHarvestable, pickaxeEffective, oneOf(t)).withBlastResistance(6) } // EncodeItem ... diff --git a/server/block/wall_type.go b/server/block/wall_type.go index cf79f3f840..8477e97493 100644 --- a/server/block/wall_type.go +++ b/server/block/wall_type.go @@ -17,6 +17,12 @@ func encodeWallBlock(block world.Block) string { } case Bricks: return "brick" + case Cinnabar: + if !block.Chiseled { + return "cinnabar" + } + case CinnabarBricks: + return "cinnabar_brick" case Cobblestone: if block.Mossy { return "mossy_cobblestone" @@ -58,6 +64,10 @@ func encodeWallBlock(block world.Block) string { if !block.Cracked { return "polished_blackstone_brick" } + case PolishedCinnabar: + return "polished_cinnabar" + case PolishedSulfur: + return "polished_sulfur" case PolishedTuff: return "polished_tuff" case Prismarine: @@ -79,6 +89,12 @@ func encodeWallBlock(block world.Block) string { } else if block.Type == MossyStoneBricks() { return "mossy_stone_brick" } + case Sulfur: + if !block.Chiseled { + return "sulfur" + } + case SulfurBricks: + return "sulfur_brick" case Tuff: if !block.Chiseled { return "tuff" @@ -98,6 +114,8 @@ func WallBlocks() []world.Block { Blackstone{Type: PolishedBlackstone()}, Blackstone{}, Bricks{}, + Cinnabar{}, + CinnabarBricks{}, Cobblestone{Mossy: true}, Cobblestone{}, DeepslateBricks{}, @@ -111,6 +129,8 @@ func WallBlocks() []world.Block { NetherBricks{Type: RedNetherBricks()}, NetherBricks{}, PolishedBlackstoneBrick{}, + PolishedCinnabar{}, + PolishedSulfur{}, PolishedTuff{}, Prismarine{}, ResinBricks{}, @@ -118,6 +138,8 @@ func WallBlocks() []world.Block { Sandstone{}, StoneBricks{Type: MossyStoneBricks()}, StoneBricks{}, + Sulfur{}, + SulfurBricks{}, Tuff{}, TuffBricks{}, } diff --git a/server/block/water.go b/server/block/water.go index 040e83a646..af987fe8f5 100644 --- a/server/block/water.go +++ b/server/block/water.go @@ -5,7 +5,6 @@ import ( "time" "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/potion" "github.com/df-mc/dragonfly/server/world" @@ -77,9 +76,9 @@ func (w Water) LiquidFalling() bool { return w.Falling } -// BlastResistance always returns 500. +// BlastResistance ... func (Water) BlastResistance() float64 { - return 500 + return 100 } // HasLiquidDrops ... @@ -126,7 +125,7 @@ func (w Water) ScheduledTick(pos cube.Pos, tx *world.Tx, _ *rand.Rand) { // Only form a new source block if there either is no water below this block, or if the water // below this is not falling (full source block). res := Water{Depth: 8, Still: true} - ctx := event.C(tx) + ctx := tx.Event() if tx.World().Handler().HandleLiquidFlow(ctx, pos, pos, res, w); ctx.Cancelled() { return } @@ -158,7 +157,7 @@ func (w Water) Harden(pos cube.Pos, tx *world.Tx, flownIntoBy *cube.Pos) bool { return false } if lava, ok := tx.Block(pos.Side(cube.FaceUp)).(Lava); ok { - ctx := event.C(tx) + ctx := tx.Event() if tx.World().Handler().HandleLiquidHarden(ctx, pos, w, lava, Stone{}); ctx.Cancelled() { return false } @@ -166,7 +165,7 @@ func (w Water) Harden(pos cube.Pos, tx *world.Tx, flownIntoBy *cube.Pos) bool { tx.PlaySound(pos.Vec3Centre(), sound.Fizz{}) return true } else if lava, ok := tx.Block(*flownIntoBy).(Lava); ok { - ctx := event.C(tx) + ctx := tx.Event() if tx.World().Handler().HandleLiquidHarden(ctx, pos, w, lava, Cobblestone{}); ctx.Cancelled() { return false } diff --git a/server/block/wood.go b/server/block/wood.go index 787c8573cd..c58a07091c 100644 --- a/server/block/wood.go +++ b/server/block/wood.go @@ -98,9 +98,13 @@ func (w Wood) EncodeBlock() (name string, properties map[string]any) { } } -// allWood returns a list of all possible wood states. +// allWood returns all possible Wood block states, excluding bamboo blocks, +// which are registered separately through allBambooBlocks. func allWood() (wood []world.Block) { for _, w := range WoodTypes() { + if w == BambooWood() { + continue + } for axis := cube.Axis(0); axis < 3; axis++ { wood = append(wood, Wood{Axis: axis, Stripped: true, Wood: w}) wood = append(wood, Wood{Axis: axis, Stripped: false, Wood: w}) diff --git a/server/block/wood_fence.go b/server/block/wood_fence.go index d318d1eb59..3c9c3b45a8 100644 --- a/server/block/wood_fence.go +++ b/server/block/wood_fence.go @@ -22,7 +22,7 @@ type WoodFence struct { // BreakInfo ... func (w WoodFence) BreakInfo() BreakInfo { - return newBreakInfo(2, alwaysHarvestable, axeEffective, oneOf(w)).withBlastResistance(15) + return newBreakInfo(2, alwaysHarvestable, axeEffective, oneOf(w)).withBlastResistance(3) } // SideClosed ... diff --git a/server/block/wood_fence_gate.go b/server/block/wood_fence_gate.go index 60ff01dae7..f7e0c7305d 100644 --- a/server/block/wood_fence_gate.go +++ b/server/block/wood_fence_gate.go @@ -29,7 +29,7 @@ type WoodFenceGate struct { // BreakInfo ... func (f WoodFenceGate) BreakInfo() BreakInfo { - return newBreakInfo(2, alwaysHarvestable, axeEffective, oneOf(f)).withBlastResistance(15) + return newBreakInfo(2, alwaysHarvestable, axeEffective, oneOf(f)).withBlastResistance(3) } // FlammabilityInfo ... diff --git a/server/block/wood_type.go b/server/block/wood_type.go index ded6df1999..cafd9b0648 100644 --- a/server/block/wood_type.go +++ b/server/block/wood_type.go @@ -61,9 +61,14 @@ func PaleOakWood() WoodType { return WoodType{10} } +// BambooWood returns bamboo wood material. +func BambooWood() WoodType { + return WoodType{11} +} + // WoodTypes returns a list of all wood types func WoodTypes() []WoodType { - return []WoodType{OakWood(), SpruceWood(), BirchWood(), JungleWood(), AcaciaWood(), DarkOakWood(), CrimsonWood(), WarpedWood(), MangroveWood(), CherryWood(), PaleOakWood()} + return []WoodType{OakWood(), SpruceWood(), BirchWood(), JungleWood(), AcaciaWood(), DarkOakWood(), CrimsonWood(), WarpedWood(), MangroveWood(), CherryWood(), PaleOakWood(), BambooWood()} } type wood uint8 @@ -98,6 +103,8 @@ func (w wood) Name() string { return "Cherry Wood" case 10: return "Pale Oak Wood" + case 11: + return "Bamboo Wood" } panic("unknown wood type") } @@ -127,6 +134,8 @@ func (w wood) String() string { return "cherry" case 10: return "pale_oak" + case 11: + return "bamboo" } panic("unknown wood type") } diff --git a/server/cmd/argument.go b/server/cmd/argument.go index 1b76ade3af..c11dcc1493 100644 --- a/server/cmd/argument.go +++ b/server/cmd/argument.go @@ -275,6 +275,9 @@ func (p parser) parseTargets(line *Line, tx *world.Tx) ([]Target, error) { if !ok { return nil, line.UsageError() } + if strings.HasPrefix(first, "@") && tx == nil { + return nil, MessageNoTargets.F() + } switch first[:min(len(first), 2)] { case "@p": pos := line.src.Position() diff --git a/server/cmd/command.go b/server/cmd/command.go index c32500f606..1a99e315ef 100644 --- a/server/cmd/command.go +++ b/server/cmd/command.go @@ -31,7 +31,8 @@ import ( type Runnable interface { // Run runs the Command, using the arguments passed to the Command. The source is passed to the method, // which is the source of the Command execution, and the output is passed, to which messages may be - // added which get sent to the source. + // added which get sent to the source. tx is nil for sources not attached to + // a world, such as a console source. Runnables that use tx must nil-check it. Run(src Source, o *Output, tx *world.Tx) } @@ -123,6 +124,9 @@ func (cmd Command) Aliases() []string { // If parsing of all Runnables was unsuccessful, a command output with an error message is sent to the Source // passed, and the Run method of the Runnables are not called. // The Source passed must not be nil. The method will panic if a nil Source is passed. +// tx may be nil for sources that are not attached to a world. Selector parsing +// reports an error when it requires a transaction; Runnable bodies must +// independently nil-check tx before using it. func (cmd Command) Execute(args string, source Source, tx *world.Tx) { if source == nil { panic("execute: invalid command source: source must not be nil") diff --git a/server/cmd/target.go b/server/cmd/target.go index 088dfdbbed..dcb0d6793c 100644 --- a/server/cmd/target.go +++ b/server/cmd/target.go @@ -23,6 +23,9 @@ type NamedTarget interface { // targets returns all Targets selectable by the Source passed. func targets(tx *world.Tx) (entities []Target, players []NamedTarget) { + if tx == nil { + return nil, nil + } ent := sliceutil.Convert[Target](slices.Collect(tx.Entities())) pl := sliceutil.Convert[NamedTarget](slices.Collect(tx.Players())) return ent, pl diff --git a/server/conf.go b/server/conf.go index e14d33fbbf..20df04d2b7 100644 --- a/server/conf.go +++ b/server/conf.go @@ -117,6 +117,10 @@ type Config struct { // ChunkUnloadInterval should not be used to prevent chunks from unloading // altogether. This should be done using a Loader with a custom Viewer. ChunkUnloadInterval time.Duration + // ChunkLoadWorkers is the number of background workers that load and generate + // chunks in each world, defaulting to 1. Values above 1 generate chunks + // concurrently and require a concurrency-safe Generator. + ChunkLoadWorkers int // Entities is a world.EntityRegistry with all entity types registered that // may be added to the Server's worlds. If no entity types are registered, // Entities will be set to entity.DefaultRegistry. diff --git a/server/entity/area_effect_cloud.go b/server/entity/area_effect_cloud.go index 23c52d67a6..52198846c4 100644 --- a/server/entity/area_effect_cloud.go +++ b/server/entity/area_effect_cloud.go @@ -65,8 +65,8 @@ func (areaEffectCloudType) DecodeNBT(m map[string]any, data *world.EntityData) { RadiusUseGrowth: float64(nbtconv.Float32(m, "RadiusOnUse")), RadiusTickGrowth: float64(nbtconv.Float32(m, "RadiusPerTick")), Duration: nbtconv.TickDuration[int32](m, "Duration"), - DurationUseGrowth: nbtconv.TickDuration[int32](m, "ReapplicationDelay"), - ReapplicationDelay: nbtconv.TickDuration[int32](m, "DurationOnUse"), + DurationUseGrowth: nbtconv.TickDuration[int32](m, "DurationOnUse"), + ReapplicationDelay: nbtconv.TickDuration[int32](m, "ReapplicationDelay"), }.New() } @@ -74,11 +74,11 @@ func (areaEffectCloudType) EncodeNBT(data *world.EntityData) map[string]any { a := data.Data.(*AreaEffectCloudBehaviour) return map[string]any{ "PotionId": int32(a.conf.Potion.Uint8()), - "ReapplicationDelay": int32(a.conf.ReapplicationDelay), + "ReapplicationDelay": int32(a.conf.ReapplicationDelay / (time.Second / 20)), "RadiusPerTick": float32(a.conf.RadiusTickGrowth), "RadiusOnUse": float32(a.conf.RadiusUseGrowth), - "DurationOnUse": int32(a.conf.DurationUseGrowth), + "DurationOnUse": int32(a.conf.DurationUseGrowth / (time.Second / 20)), "Radius": float32(a.radius), - "Duration": int32(a.duration), + "Duration": int32(a.duration / (time.Second / 20)), } } diff --git a/server/entity/area_effect_cloud_behaviour.go b/server/entity/area_effect_cloud_behaviour.go index 056b944439..86539a05a8 100644 --- a/server/entity/area_effect_cloud_behaviour.go +++ b/server/entity/area_effect_cloud_behaviour.go @@ -1,12 +1,13 @@ package entity import ( + "iter" + "time" + "github.com/df-mc/dragonfly/server/entity/effect" "github.com/df-mc/dragonfly/server/item/potion" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" - "iter" - "time" ) // AreaEffectCloudBehaviourConfig contains optional parameters for an area @@ -66,6 +67,11 @@ type AreaEffectCloudBehaviour struct { targets map[*world.EntityHandle]time.Duration } +// PortalTravelComputer returns the interdimensional travel state for the behaviour. +func (a *AreaEffectCloudBehaviour) PortalTravelComputer() *PortalTravelComputer { + return a.stationary.PortalTravelComputer() +} + // Radius returns the current radius of the area effect cloud. func (a *AreaEffectCloudBehaviour) Radius() float64 { return a.radius @@ -92,7 +98,7 @@ func (a *AreaEffectCloudBehaviour) Tick(e *Ent, tx *world.Tx) *Movement { } } - if int16(e.Age()/(time.Second*20))%10 != 0 { + if (e.Age()/(time.Second/20))%10 != 0 { // Area effect clouds only trigger updates every ten ticks. return nil } diff --git a/server/entity/base_behaviour.go b/server/entity/base_behaviour.go new file mode 100644 index 0000000000..0f36fe0ab2 --- /dev/null +++ b/server/entity/base_behaviour.go @@ -0,0 +1,20 @@ +package entity + +// BaseBehaviour provides shared runtime state for Ent behaviours. Embed it +// to inherit common functionality, or forward methods to another instance. +type BaseBehaviour struct { + portalTravel *PortalTravelComputer +} + +// NewBaseBehaviour returns a BaseBehaviour initialised with the default Ent runtime behaviour. +func NewBaseBehaviour() BaseBehaviour { + return BaseBehaviour{portalTravel: NewPortalTravelComputer()} +} + +// PortalTravelComputer returns the portal travel state for a behaviour. +func (b *BaseBehaviour) PortalTravelComputer() *PortalTravelComputer { + if b.portalTravel == nil { + b.portalTravel = NewPortalTravelComputer() + } + return b.portalTravel +} diff --git a/server/entity/damage.go b/server/entity/damage.go index 110378661c..beb350b196 100644 --- a/server/entity/damage.go +++ b/server/entity/damage.go @@ -45,7 +45,10 @@ type ( } // ExplosionDamageSource is used for damage caused by an explosion. - ExplosionDamageSource struct{} + ExplosionDamageSource struct { + // Source is the source of the explosion that dealt the damage. + Source world.ExplosionSource + } ) func (FallDamageSource) ReducedByArmour() bool { return false } diff --git a/server/entity/damageable.go b/server/entity/damageable.go new file mode 100644 index 0000000000..6060510097 --- /dev/null +++ b/server/entity/damageable.go @@ -0,0 +1,38 @@ +package entity + +import "github.com/df-mc/dragonfly/server/world" + +// behaviourDamageable represents a Behaviour that may be hurt directly without +// implementing Living. +type behaviourDamageable interface { + Hurt(e *Ent, damage float64, src world.DamageSource) (n float64, vulnerable bool) +} + +// HurtEntity hurts an entity if it is either Living or has a Behaviour that may +// be hurt directly. It returns the damage dealt, whether the entity was +// vulnerable to the damage, and whether the entity could be damaged. +func HurtEntity(e world.Entity, damage float64, src world.DamageSource) (n float64, vulnerable, ok bool) { + if l, ok := e.(Living); ok { + n, vulnerable = l.Hurt(damage, src) + return n, vulnerable, true + } + if ent, ok := e.(*Ent); ok { + if d, ok := ent.Behaviour().(behaviourDamageable); ok { + n, vulnerable = d.Hurt(ent, damage, src) + return n, vulnerable, true + } + } + return 0, false, false +} + +// DamageableEntity checks if an entity may be damaged. +func DamageableEntity(e world.Entity) bool { + if _, ok := e.(Living); ok { + return true + } + if ent, ok := e.(*Ent); ok { + _, ok = ent.Behaviour().(behaviourDamageable) + return ok + } + return false +} diff --git a/server/entity/effect/effect.go b/server/entity/effect/effect.go index b3cad3f887..0cf00802c4 100644 --- a/server/entity/effect/effect.go +++ b/server/entity/effect/effect.go @@ -1,9 +1,10 @@ package effect import ( - "github.com/df-mc/dragonfly/server/world" "image/color" "time" + + "github.com/df-mc/dragonfly/server/world" ) // LastingType represents an effect type that can have a duration. An effect @@ -183,7 +184,7 @@ type living interface { // Heal heals the entity for a given amount of health. The source passed represents the cause of the // healing, for example entity.FoodHealingSource if the entity healed by having a full food bar. If the health // added to the original health exceeds the entity's max health, Heal may not add the full amount. - Heal(health float64, source world.HealingSource) + Heal(health float64, source world.HealingSource) float64 // speed returns the current speed of the living entity. The default value is different for each entity. Speed() float64 // SetSpeed sets the speed of an entity to a new value. diff --git a/server/entity/effect/fatal_poison.go b/server/entity/effect/fatal_poison.go index efed2d4751..5deef04d15 100644 --- a/server/entity/effect/fatal_poison.go +++ b/server/entity/effect/fatal_poison.go @@ -16,7 +16,7 @@ type fatalPoison struct { // Apply ... func (fatalPoison) Apply(e world.Entity, eff Effect) { - interval := max(50>>(eff.Level()-1), 1) + interval := max(25>>(eff.Level()-1), 1) if eff.Tick()%interval == 0 { if l, ok := e.(living); ok { l.Hurt(1, PoisonDamageSource{Fatal: true}) diff --git a/server/entity/effect/poison.go b/server/entity/effect/poison.go index 9bbb80eeb6..9ef3a9c846 100644 --- a/server/entity/effect/poison.go +++ b/server/entity/effect/poison.go @@ -15,7 +15,7 @@ type poison struct { // Apply ... func (poison) Apply(e world.Entity, eff Effect) { - interval := max(50>>(eff.Level()-1), 1) + interval := max(25>>(eff.Level()-1), 1) if eff.Tick()%interval == 0 { if l, ok := e.(living); ok && l.Health() > 1 { l.Hurt(1, PoisonDamageSource{}) diff --git a/server/entity/end_crystal.go b/server/entity/end_crystal.go new file mode 100644 index 0000000000..1f06d4c87e --- /dev/null +++ b/server/entity/end_crystal.go @@ -0,0 +1,85 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/world" +) + +// NewEndCrystal creates a new End crystal entity. +func NewEndCrystal(opts world.EntitySpawnOpts) *world.EntityHandle { + return EndCrystalConfig{}.New(opts) +} + +// EndCrystalConfig holds configuration for an End crystal entity. +type EndCrystalConfig struct { + // ExplosionSize is the size of the explosion created when the End crystal is destroyed. It defaults to 6. + ExplosionSize float64 + // ShowBase specifies whether the End crystal renders its bottom base. + ShowBase bool +} + +// New creates an End crystal entity with the configuration c. +func (c EndCrystalConfig) New(opts world.EntitySpawnOpts) *world.EntityHandle { + return opts.New(EndCrystalType, c) +} + +// Apply applies the End crystal configuration to data. +func (c EndCrystalConfig) Apply(data *world.EntityData) { + if c.ExplosionSize == 0 { + c.ExplosionSize = 6 + } + data.Data = endCrystalBehaviour{ + showBase: c.ShowBase, + explosionSize: c.ExplosionSize, + } +} + +// EndCrystalType is a world.EntityType implementation for End crystals. +var EndCrystalType endCrystalType + +type endCrystalType struct{} + +func (endCrystalType) Open(tx *world.Tx, handle *world.EntityHandle, data *world.EntityData) world.Entity { + return Open(tx, handle, data) +} + +func (endCrystalType) EncodeEntity() string { + return "minecraft:ender_crystal" +} + +func (endCrystalType) BBox(world.Entity) cube.BBox { + return cube.Box(-1, 0, -1, 1, 2, 1) +} + +func (endCrystalType) DecodeNBT(m map[string]any, data *world.EntityData) { + b := endCrystalBehaviour{ + showBase: nbtconv.Bool(m, "ShowBottom"), + explosionSize: nbtconv.Float64(m, "ExplosionSize"), + } + if b.explosionSize == 0 { + b.explosionSize = 6 + } + x, hasX := m["BlockTargetX"].(int32) + y, hasY := m["BlockTargetY"].(int32) + z, hasZ := m["BlockTargetZ"].(int32) + if hasX && hasY && hasZ { + b.beamTarget = cube.Pos{int(x), int(y), int(z)} + b.hasBeamTarget = true + } + b.Apply(data) +} + +func (endCrystalType) EncodeNBT(data *world.EntityData) map[string]any { + b := data.Data.(endCrystalBehaviour) + m := map[string]any{ + "ShowBottom": boolByte(b.showBase), + "ExplosionSize": b.explosionSize, + } + if b.hasBeamTarget { + m["BlockTargetX"] = int32(b.beamTarget[0]) + m["BlockTargetY"] = int32(b.beamTarget[1]) + m["BlockTargetZ"] = int32(b.beamTarget[2]) + } + return m +} diff --git a/server/entity/end_crystal_behaviour.go b/server/entity/end_crystal_behaviour.go new file mode 100644 index 0000000000..a0a1c64468 --- /dev/null +++ b/server/entity/end_crystal_behaviour.go @@ -0,0 +1,85 @@ +package entity + +import ( + "math/rand/v2" + "time" + + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +type endCrystalBehaviour struct { + showBase bool + beamTarget cube.Pos + hasBeamTarget bool + explosionSize float64 +} + +func (b endCrystalBehaviour) Apply(data *world.EntityData) { + data.Data = b +} + +// Tick continuously generates fire at the End crystal's position while in the +// End, if the block at that position is air and the block below it is not air. +func (endCrystalBehaviour) Tick(e *Ent, tx *world.Tx) *Movement { + if tx.World().Dimension() == world.End { + pos := cube.PosFromVec3(e.Position()) + if _, air := tx.Block(pos.Side(cube.FaceDown)).(block.Air); !air { + if _, air := tx.Block(pos).(block.Air); air { + fire := block.Fire{} + tx.SetBlock(pos, fire, nil) + tx.ScheduleBlockUpdate(pos, fire, time.Duration(30+rand.IntN(10))*time.Second/20) + } + } + } + return nil +} + +// Explode makes the End crystal explode itself when hit by another explosion, +// causing a chain reaction. +func (b endCrystalBehaviour) Explode(e *Ent, _ world.ExplosionSource, impact float64) { + if impact <= 0 { + return + } + explodeEndCrystal(e, b.explosionSize) +} + +// Hurt makes the End crystal explode when damaged by any source, even by +// damage that deals no health. Void damage removes it without an explosion. +func (b endCrystalBehaviour) Hurt(e *Ent, damage float64, src world.DamageSource) (float64, bool) { + damage = max(damage, 0) + if _, ok := src.(VoidDamageSource); ok { + _ = e.Close() + return damage, true + } + explodeEndCrystal(e, b.explosionSize) + return damage, true +} + +func (endCrystalBehaviour) Immobile() bool { + return true +} + +func (b endCrystalBehaviour) ShowBase() bool { + return b.showBase +} + +func (b endCrystalBehaviour) BeamTarget() (cube.Pos, bool) { + return b.beamTarget, b.hasBeamTarget +} + +// explodeEndCrystal closes the End crystal and creates a non-incendiary +// explosion at its base, if the crystal was not closed yet. +func explodeEndCrystal(e *Ent, explosionSize float64) { + if _, ok := e.H().Entity(e.tx); !ok { + return + } + _ = e.Close() + block.ExplosionConfig{ + SuppressUnderwaterImpact: true, + }.Explode(e.tx, world.EntityExplosionSource{ + Entity: e, + ExplosionSize: explosionSize, + }) +} diff --git a/server/entity/ender_pearl.go b/server/entity/ender_pearl.go index 5fd44e3749..42b905af1a 100644 --- a/server/entity/ender_pearl.go +++ b/server/entity/ender_pearl.go @@ -34,7 +34,11 @@ type teleporter interface { // teleport teleports the owner of an Ent to a trace.Result's position. func teleport(e *Ent, tx *world.Tx, target trace.Result) { - owner, _ := e.Behaviour().(*ProjectileBehaviour).Owner().Entity(tx) + behaviour := e.Behaviour().(*ProjectileBehaviour) + if behaviour.PortalTravel() { + return + } + owner, _ := behaviour.Owner().Entity(tx) if user, ok := owner.(teleporter); ok { tx.PlaySound(user.Position(), sound.Teleport{}) user.Teleport(target.Position()) diff --git a/server/entity/ent.go b/server/entity/ent.go index 1f060e4d4f..08b40857be 100644 --- a/server/entity/ent.go +++ b/server/entity/ent.go @@ -4,7 +4,6 @@ import ( "sync" "time" - "github.com/df-mc/dragonfly/server/block" "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" @@ -22,10 +21,11 @@ type Behaviour interface { // share a lot of code. It is currently under development and is prone to // (breaking) changes. type Ent struct { - tx *world.Tx - handle *world.EntityHandle - data *world.EntityData - once sync.Once + tx *world.Tx + handle *world.EntityHandle + data *world.EntityData + deferPortalTravel bool + once sync.Once } // Open converts a world.EntityHandle to an Ent in a world.Tx. @@ -42,11 +42,11 @@ func (e *Ent) Behaviour() Behaviour { } // Explode propagates the explosion behaviour of the underlying Behaviour. -func (e *Ent) Explode(src mgl64.Vec3, impact float64, conf block.ExplosionConfig) { +func (e *Ent) Explode(src world.ExplosionSource, impact float64) { if expl, ok := e.Behaviour().(interface { - Explode(e *Ent, src mgl64.Vec3, impact float64, conf block.ExplosionConfig) + Explode(e *Ent, src world.ExplosionSource, impact float64) }); ok { - expl.Explode(e, src, impact, conf) + expl.Explode(e, src, impact) } } @@ -67,6 +67,15 @@ func (e *Ent) SetVelocity(v mgl64.Vec3) { e.data.Vel = v } +// Teleport teleports the entity to the position given. +func (e *Ent) Teleport(pos mgl64.Vec3) { + viewers := e.tx.Viewers(e.data.Pos) + e.data.Pos = pos + for _, v := range viewers { + v.ViewEntityTeleport(e, pos) + } +} + // Rotation returns the rotation of the entity. func (e *Ent) Rotation() cube.Rotation { return e.data.Rot @@ -90,9 +99,7 @@ func (e *Ent) SetOnFire(duration time.Duration) { e.data.FireDuration = duration if stateChanged { - for _, v := range e.tx.Viewers(e.data.Pos) { - v.ViewEntityState(e) - } + e.updateState() } } @@ -111,7 +118,25 @@ func (e *Ent) NameTag() string { // empty string is passed. func (e *Ent) SetNameTag(s string) { e.data.Name = s - for _, v := range e.tx.Viewers(e.Position()) { + e.updateState() +} + +// AlwaysShowNameTag returns whether the name tag of the entity is shown at all +// distances instead of only when the entity is looked at from up close. +func (e *Ent) AlwaysShowNameTag() bool { + return e.data.AlwaysShowNameTag +} + +// SetAlwaysShowNameTag changes whether the name tag of the entity is shown at +// all distances instead of only when the entity is looked at from up close. +func (e *Ent) SetAlwaysShowNameTag(alwaysShow bool) { + e.data.AlwaysShowNameTag = alwaysShow + e.updateState() +} + +// updateState updates the state of the entity for all viewers of the entity. +func (e *Ent) updateState() { + for _, v := range e.tx.Viewers(e.data.Pos) { v.ViewEntityState(e) } } @@ -119,6 +144,11 @@ func (e *Ent) SetNameTag(s string) { // Tick ticks Ent, progressing its lifetime and closing the entity if it is // in the void. func (e *Ent) Tick(tx *world.Tx, current int64) { + e.deferPortalTravel = true + defer func() { + e.deferPortalTravel = false + }() + y := e.data.Pos[1] if y < float64(tx.Range()[0]) && current%10 == 0 { _ = e.Close() @@ -126,9 +156,17 @@ func (e *Ent) Tick(tx *world.Tx, current int64) { } e.SetOnFire(e.OnFireDuration() - time.Second/20) - if m := e.Behaviour().Tick(e, tx); m != nil { + m := e.Behaviour().Tick(e, tx) + if e.finishPendingPortalTravel(tx) { + return + } + if m != nil { m.Send() } + if e.checkPortalInsiders() && e.finishPendingPortalTravel(tx) { + return + } + e.stopPortalContact() e.data.Age += time.Second / 20 } @@ -140,3 +178,66 @@ func (e *Ent) Close() error { }) return nil } + +// TravelThroughPortal handles the entity touching a portal block. +func (e *Ent) TravelThroughPortal(tx *world.Tx, target world.Dimension) { + if tc := e.portalTravelComputer(); tc != nil { + if e.deferPortalTravel { + tc.queuePortalTravel(tx, target) + return + } + tc.EnterPortal(e, tx, target) + } +} + +// portalTravelComputer returns the behaviour's portal travel state, if any. +func (e *Ent) portalTravelComputer() *PortalTravelComputer { + if b, ok := e.Behaviour().(portalTravelComputerProvider); ok { + return b.PortalTravelComputer() + } + return nil +} + +// stopPortalContact resets portal contact state when no portal was touched. +func (e *Ent) stopPortalContact() { + if tc := e.portalTravelComputer(); tc != nil { + tc.StopPortalContact() + } +} + +// pendingPortalTravel reports whether this tick queued terminal portal travel. +func (e *Ent) pendingPortalTravel() bool { + if tc := e.portalTravelComputer(); tc != nil { + return tc.hasPendingPortalTravel() + } + return false +} + +// finishPendingPortalTravel starts queued terminal portal travel, if present. +func (e *Ent) finishPendingPortalTravel(tx *world.Tx) bool { + if tc := e.portalTravelComputer(); tc != nil { + return tc.finishPendingPortalTravel(e, tx) + } + return false +} + +type portalBlock interface { + Portal() world.Dimension +} + +// checkPortalInsiders checks whether the entity is inside portal blocks. +// Other EntityInsider blocks are intentionally left to entity physics. +func (e *Ent) checkPortalInsiders() bool { + box := e.H().Type().BBox(e).Translate(e.Position()).Grow(-0.0001) + low, high := cube.PosFromVec3(box.Min()), cube.PosFromVec3(box.Max()) + + for blockPos := range cube.Range3D(low, high) { + if p, ok := e.tx.Block(blockPos).(portalBlock); ok { + e.TravelThroughPortal(e.tx, p.Portal()) + if e.pendingPortalTravel() { + return true + } + } + } + return false +} diff --git a/server/entity/experience_orb_behaviour.go b/server/entity/experience_orb_behaviour.go index f90453ede9..eea158ddc0 100644 --- a/server/entity/experience_orb_behaviour.go +++ b/server/entity/experience_orb_behaviour.go @@ -1,11 +1,12 @@ package entity import ( + "math" + "time" + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" - "math" - "time" ) // ExperienceOrbBehaviourConfig holds optional parameters for the creation of @@ -47,14 +48,18 @@ func (conf ExperienceOrbBehaviourConfig) New() *ExperienceOrbBehaviour { // ExperienceOrbBehaviour implements Behaviour for an experience orb entity. type ExperienceOrbBehaviour struct { - conf ExperienceOrbBehaviourConfig - + conf ExperienceOrbBehaviourConfig passive *PassiveBehaviour lastSearch time.Time target *world.EntityHandle } +// PortalTravelComputer returns the interdimensional travel state for the behaviour. +func (exp *ExperienceOrbBehaviour) PortalTravelComputer() *PortalTravelComputer { + return exp.passive.PortalTravelComputer() +} + // Experience returns the amount of experience the orb carries. func (exp *ExperienceOrbBehaviour) Experience() int { return exp.conf.Experience diff --git a/server/entity/firework.go b/server/entity/firework.go index db1353200e..a9fc8c48d0 100644 --- a/server/entity/firework.go +++ b/server/entity/firework.go @@ -2,7 +2,6 @@ package entity import ( "github.com/df-mc/dragonfly/server/block/cube" - "github.com/df-mc/dragonfly/server/internal/nbtconv" "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/world" ) @@ -49,12 +48,12 @@ func (fireworkType) BBox(world.Entity) cube.BBox { return cube.BBox{} } func (fireworkType) DecodeNBT(m map[string]any, data *world.EntityData) { conf := fireworkConf - conf.Firework = nbtconv.MapItem(m, "Item").Item().(item.Firework) + conf.Firework = item.MapNBT(m, "Item").Item().(item.Firework) conf.ExistenceDuration = conf.Firework.RandomisedDuration() data.Data = conf.New() } func (fireworkType) EncodeNBT(data *world.EntityData) map[string]any { - return map[string]any{"Item": nbtconv.WriteItem(item.NewStack(data.Data.(*FireworkBehaviour).Firework(), 1), true)} + return map[string]any{"Item": item.WriteNBT(item.NewStack(data.Data.(*FireworkBehaviour).Firework(), 1), true)} } diff --git a/server/entity/firework_behaviour.go b/server/entity/firework_behaviour.go index a09a09f554..ae7f68c426 100644 --- a/server/entity/firework_behaviour.go +++ b/server/entity/firework_behaviour.go @@ -51,6 +51,11 @@ type FireworkBehaviour struct { passive *PassiveBehaviour } +// PortalTravelComputer returns the interdimensional travel state for the behaviour. +func (f *FireworkBehaviour) PortalTravelComputer() *PortalTravelComputer { + return f.passive.PortalTravelComputer() +} + // Firework returns the underlying item.Firework of the FireworkBehaviour. func (f *FireworkBehaviour) Firework() item.Firework { return f.conf.Firework diff --git a/server/entity/healing.go b/server/entity/healing.go index 388963fc31..8378ef3fc1 100644 --- a/server/entity/healing.go +++ b/server/entity/healing.go @@ -3,7 +3,7 @@ package entity type ( // FoodHealingSource is a healing source used for when an entity regenerates health automatically when their food // bar is at least 90% filled. - FoodHealingSource struct{} + FoodHealingSource struct{ QuickRegeneration bool } ) func (FoodHealingSource) HealingSource() {} diff --git a/server/entity/item.go b/server/entity/item.go index edd72e250e..4b332f9457 100644 --- a/server/entity/item.go +++ b/server/entity/item.go @@ -51,7 +51,7 @@ func (itemType) BBox(world.Entity) cube.BBox { func (itemType) DecodeNBT(m map[string]any, data *world.EntityData) { conf := itemConf - conf.Item = nbtconv.MapItem(m, "Item") + conf.Item = item.MapNBT(m, "Item") conf.PickupDelay = time.Duration(nbtconv.Int64(m, "PickupDelay")) * (time.Second / 20) data.Data = conf.New() @@ -61,7 +61,7 @@ func (itemType) EncodeNBT(data *world.EntityData) map[string]any { b := data.Data.(*ItemBehaviour) return map[string]any{ "Health": int16(5), - "PickupDelay": int64(b.pickupDelay / (time.Second * 20)), - "Item": nbtconv.WriteItem(b.Item(), true), + "PickupDelay": int64(b.pickupDelay / (time.Second / 20)), + "Item": item.WriteNBT(b.Item(), true), } } diff --git a/server/entity/item_behaviour.go b/server/entity/item_behaviour.go index 807506459e..ed9e575ffc 100644 --- a/server/entity/item_behaviour.go +++ b/server/entity/item_behaviour.go @@ -6,7 +6,6 @@ import ( "github.com/df-mc/dragonfly/server/block" "github.com/df-mc/dragonfly/server/block/cube" - "github.com/df-mc/dragonfly/server/internal/nbtconv" "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" @@ -38,7 +37,7 @@ func (conf ItemBehaviourConfig) New() *ItemBehaviour { if i.Count() > i.MaxCount() { i = i.Grow(i.MaxCount() - i.Count()) } - i = nbtconv.Item(nbtconv.WriteItem(i, true), nil) + i = item.ReadNBT(item.WriteNBT(i, true), nil) if conf.PickupDelay == 0 { conf.PickupDelay = time.Second / 2 @@ -66,6 +65,11 @@ type ItemBehaviour struct { pickupDelay time.Duration } +// PortalTravelComputer returns the interdimensional travel state for the behaviour. +func (i *ItemBehaviour) PortalTravelComputer() *PortalTravelComputer { + return i.passive.PortalTravelComputer() +} + // Item returns the item.Stack held by the entity. func (i *ItemBehaviour) Item() item.Stack { return i.i @@ -93,13 +97,14 @@ func (i *ItemBehaviour) Tick(e *Ent, tx *world.Tx) *Movement { _ = e.Close() bl.CollectCooldown = 8 tx.SetBlock(blockPos, bl, nil) + return nil } return i.passive.Tick(e, tx) } // Explode reacts to explosions. The item entity is destroyed, unless the item // type is blast proof. -func (i *ItemBehaviour) Explode(e *Ent, src mgl64.Vec3, impact float64, conf block.ExplosionConfig) { +func (i *ItemBehaviour) Explode(e *Ent, _ world.ExplosionSource, impact float64) { if impact > 0 { if expl, ok := i.Item().Item().(interface{ BlastProof() bool }); ok && expl.BlastProof() { return diff --git a/server/entity/living.go b/server/entity/living.go index 0c2e30ed79..327ef97bcc 100644 --- a/server/entity/living.go +++ b/server/entity/living.go @@ -27,8 +27,9 @@ type Living interface { Hurt(damage float64, src world.DamageSource) (n float64, vulnerable bool) // Heal heals the entity for a given amount of health. The source passed represents the cause of the // healing, for example FoodHealingSource if the entity healed by having a full food bar. If the health - // added to the original health exceeds the entity's max health, Heal may not add the full amount. - Heal(health float64, src world.HealingSource) + // added to the original health exceeds the entity's max health, Heal may not add the full amount, Heal + // returns the amount of health regenerated. + Heal(health float64, src world.HealingSource) float64 // KnockBack knocks the entity back with a given force and height. A source is passed which indicates the // source of the velocity, typically the position of an attacking entity. The source is used to calculate // the direction which the entity should be knocked back in. diff --git a/server/entity/movement.go b/server/entity/movement.go index c68940c763..02ebf65d2e 100644 --- a/server/entity/movement.go +++ b/server/entity/movement.go @@ -67,7 +67,7 @@ func (c *MovementComputer) TickMovement(e world.Entity, pos, vel mgl64.Vec3, rot velBefore := vel vel = c.applyHorizontalForces(tx, pos, c.applyVerticalForces(vel)) - dPos, vel := c.checkCollision(tx, e, pos, vel) + dPos, vel := c.CheckCollision(tx, e, pos, vel) return &Movement{v: viewers, e: e, pos: pos.Add(dPos), vel: vel, dpos: dPos, dvel: vel.Sub(velBefore), @@ -115,10 +115,10 @@ func (c *MovementComputer) applyHorizontalForces(tx *world.Tx, pos, vel mgl64.Ve return vel } -// checkCollision handles the collision of the entity with blocks, adapting the velocity of the entity if it +// CheckCollision handles the collision of the entity with blocks, adapting the velocity of the entity if it // happens to collide with a block. // The final velocity and the Vec3 that the entity should move is returned. -func (c *MovementComputer) checkCollision(tx *world.Tx, e world.Entity, pos, vel mgl64.Vec3) (mgl64.Vec3, mgl64.Vec3) { +func (c *MovementComputer) CheckCollision(tx *world.Tx, e world.Entity, pos, vel mgl64.Vec3) (mgl64.Vec3, mgl64.Vec3) { // TODO: Implement collision with other entities. deltaX, deltaY, deltaZ := vel[0], vel[1], vel[2] @@ -174,15 +174,23 @@ func blockBBoxsAround(tx *world.Tx, box cube.BBox) []cube.BBox { grown := box.Grow(0.25) min, max := grown.Min(), grown.Max() minX, minY, minZ := int(math.Floor(min[0])), int(math.Floor(min[1])), int(math.Floor(min[2])) + // The maximum bounds are exclusive: A block starting exactly at the box's + // maximum cannot collide with it. maxX, maxY, maxZ := int(math.Ceil(max[0])), int(math.Ceil(max[1])), int(math.Ceil(max[2])) - // A prediction of one BBox per block, plus an additional 2, in case - blockBBoxs := make([]cube.BBox, 0, (maxX-minX)*(maxY-minY)*(maxZ-minZ)+2) - for y := minY; y <= maxY; y++ { - for x := minX; x <= maxX; x++ { - for z := minZ; z <= maxZ; z++ { + // A prediction of one BBox per block, plus an additional 2, in case. Allocate + // it lazily so that entities moving through air do not allocate an empty slice + // every tick. + predicted := (maxX-minX)*(maxY-minY)*(maxZ-minZ) + 2 + var blockBBoxs []cube.BBox + for y := minY; y < maxY; y++ { + for x := minX; x < maxX; x++ { + for z := minZ; z < maxZ; z++ { pos := cube.Pos{x, y, z} boxes := tx.Block(pos).Model().BBox(pos, tx) + if len(boxes) != 0 && blockBBoxs == nil { + blockBBoxs = make([]cube.BBox, 0, predicted) + } for _, box := range boxes { blockBBoxs = append(blockBBoxs, box.Translate(mgl64.Vec3{float64(x), float64(y), float64(z)})) } diff --git a/server/entity/passive.go b/server/entity/passive.go index d1d8b500ab..1759c06693 100644 --- a/server/entity/passive.go +++ b/server/entity/passive.go @@ -1,11 +1,10 @@ package entity import ( - "github.com/df-mc/dragonfly/server/block" - "github.com/df-mc/dragonfly/server/world" - "github.com/go-gl/mathgl/mgl64" "math" "time" + + "github.com/df-mc/dragonfly/server/world" ) // PassiveBehaviourConfig holds optional parameters for a PassiveBehaviour. @@ -36,17 +35,24 @@ func (conf PassiveBehaviourConfig) New() *PassiveBehaviour { if conf.ExistenceDuration == 0 { conf.ExistenceDuration = math.MaxInt64 } - return &PassiveBehaviour{conf: conf, fuse: conf.ExistenceDuration, mc: &MovementComputer{ - Gravity: conf.Gravity, - Drag: conf.Drag, - DragBeforeGravity: true, - }} + return &PassiveBehaviour{ + BaseBehaviour: NewBaseBehaviour(), + conf: conf, + fuse: conf.ExistenceDuration, + mc: &MovementComputer{ + Gravity: conf.Gravity, + Drag: conf.Drag, + DragBeforeGravity: true, + }, + } } // PassiveBehaviour implements Behaviour for entities that act passively. This // means that they can move, but only under influence of the environment, which // includes, for example, falling, and flowing water. type PassiveBehaviour struct { + BaseBehaviour + conf PassiveBehaviourConfig mc *MovementComputer @@ -57,8 +63,8 @@ type PassiveBehaviour struct { // Explode adds velocity to a passive entity to blast it away from the // explosion's source. -func (p *PassiveBehaviour) Explode(e *Ent, src mgl64.Vec3, impact float64, _ block.ExplosionConfig) { - e.data.Vel = e.data.Vel.Add(e.data.Pos.Sub(src).Normalize().Mul(impact)) +func (p *PassiveBehaviour) Explode(e *Ent, src world.ExplosionSource, impact float64) { + e.data.Vel = e.data.Vel.Add(e.data.Pos.Sub(src.Position()).Normalize().Mul(impact)) } // Fuse returns the leftover time until PassiveBehaviourConfig.Expire is called, @@ -80,7 +86,7 @@ func (p *PassiveBehaviour) Tick(e *Ent, tx *world.Tx) *Movement { m := p.mc.TickMovement(e, e.data.Pos, e.data.Vel, e.data.Rot, tx) e.data.Pos, e.data.Vel = m.pos, m.vel - p.fallDistance = math.Max(p.fallDistance-m.dvel[1], 0) + p.fallDistance = math.Max(p.fallDistance-m.dpos[1], 0) p.fuse = p.conf.ExistenceDuration - e.Age() diff --git a/server/entity/projectile.go b/server/entity/projectile.go index 8c8a247071..14e67ae86f 100644 --- a/server/entity/projectile.go +++ b/server/entity/projectile.go @@ -98,16 +98,24 @@ func (conf ProjectileBehaviourConfig) New() *ProjectileBehaviour { if conf.ParticleCount == 0 && conf.Particle != nil { conf.ParticleCount = 1 } - return &ProjectileBehaviour{conf: conf, collided: conf.CollisionPosition != cube.Pos{}, collisionPos: conf.CollisionPosition, mc: &MovementComputer{ - Gravity: conf.Gravity, - Drag: conf.Drag, - DragBeforeGravity: true, - }} + return &ProjectileBehaviour{ + BaseBehaviour: NewBaseBehaviour(), + conf: conf, + collided: conf.CollisionPosition != cube.Pos{}, + collisionPos: conf.CollisionPosition, + mc: &MovementComputer{ + Gravity: conf.Gravity, + Drag: conf.Drag, + DragBeforeGravity: true, + }, + } } // ProjectileBehaviour implements the behaviour of projectiles. Its specifics // may be configured using ProjectileBehaviourConfig. type ProjectileBehaviour struct { + BaseBehaviour + conf ProjectileBehaviourConfig mc *MovementComputer ageCollided int @@ -117,6 +125,7 @@ type ProjectileBehaviour struct { collided bool collidedEntities []*world.EntityHandle + portalTravel bool } // Owner returns the owner of the projectile. @@ -126,8 +135,8 @@ func (lt *ProjectileBehaviour) Owner() *world.EntityHandle { // Explode adds velocity to a projectile to blast it away from the explosion's // source. -func (lt *ProjectileBehaviour) Explode(e *Ent, src mgl64.Vec3, impact float64, _ block.ExplosionConfig) { - e.data.Vel = e.Velocity().Add(e.Position().Sub(src).Normalize().Mul(impact)) +func (lt *ProjectileBehaviour) Explode(e *Ent, src world.ExplosionSource, impact float64) { + e.data.Vel = e.Velocity().Add(e.Position().Sub(src.Position()).Normalize().Mul(impact)) } // Potion returns the potion.Potion that is applied to an entity if hit by the @@ -142,6 +151,16 @@ func (lt *ProjectileBehaviour) Critical() bool { return lt.conf.Critical && !lt.collided } +// HandlePortalTravel records that this projectile has travelled between dimensions through a portal. +func (lt *ProjectileBehaviour) HandlePortalTravel(world.Dimension, world.Dimension) { + lt.portalTravel = true +} + +// PortalTravel reports whether this projectile has travelled between dimensions through a portal. +func (lt *ProjectileBehaviour) PortalTravel() bool { + return lt.portalTravel +} + // Tick runs the tick-based behaviour of a ProjectileBehaviour and returns the // Movement within the tick. Tick handles the movement, collision and hitting // of a projectile. @@ -159,7 +178,7 @@ func (lt *ProjectileBehaviour) Tick(e *Ent, tx *world.Tx) *Movement { } vel := e.Velocity() m, result := lt.tickMovement(e, tx) - e.data.Pos, e.data.Vel = m.pos, m.vel + e.data.Pos, e.data.Vel, e.data.Rot = m.pos, m.vel, m.rot lt.collisionPos, lt.collided, lt.ageCollided = cube.Pos{}, false, 0 @@ -176,11 +195,11 @@ func (lt *ProjectileBehaviour) Tick(e *Ent, tx *world.Tx) *Movement { switch r := result.(type) { case trace.EntityResult: - if l, ok := r.Entity().(Living); ok { - if lt.conf.Damage >= 0 { - lt.hitEntity(l, e, vel) - } - lt.collidedEntities = append(lt.collidedEntities, l.H()) + if lt.conf.Damage >= 0 { + lt.hitEntity(r.Entity(), e, vel) + } + if DamageableEntity(r.Entity()) { + lt.collidedEntities = append(lt.collidedEntities, r.Entity().H()) } case trace.BlockResult: bpos := r.BlockPosition() @@ -234,7 +253,9 @@ func (lt *ProjectileBehaviour) tryPickup(e *Ent, tx *world.Tx) { if !ok { continue } - if _, ok := collector.Collect(lt.conf.PickupItem); !ok { + if n, ok := collector.Collect(lt.conf.PickupItem); !ok || n == 0 { + // The collector could not hold the item, so the projectile must stay where it is rather than being + // destroyed with nobody having received it. continue } @@ -243,6 +264,8 @@ func (lt *ProjectileBehaviour) tryPickup(e *Ent, tx *world.Tx) { for _, viewer := range tx.Viewers(e.Position()) { viewer.ViewEntityAction(e, PickedUpAction{Collector: collector}) } + // Only one collector may pick the projectile up: every further one would receive a copy of it. + return } } @@ -261,6 +284,7 @@ func (lt *ProjectileBehaviour) hitBlockSurviving(e *Ent, r trace.BlockResult, m lt.collisionPos, lt.collided = r.BlockPosition(), true for _, v := range tx.Viewers(m.pos) { + v.ViewEntityTeleport(e, m.pos) v.ViewEntityAction(e, ArrowShakeAction{Duration: time.Millisecond * 350}) v.ViewEntityState(e) } @@ -268,10 +292,9 @@ func (lt *ProjectileBehaviour) hitBlockSurviving(e *Ent, r trace.BlockResult, m } } -// hitEntity is called when a projectile hits a Living. It deals damage to the -// entity and knocks it back. Additionally, it applies any potion effects and -// fire if applicable. -func (lt *ProjectileBehaviour) hitEntity(l Living, e *Ent, vel mgl64.Vec3) { +// hitEntity is called when a projectile hits an entity. It deals damage to the +// entity if possible, and applies Living-specific effects such as knockback. +func (lt *ProjectileBehaviour) hitEntity(victim world.Entity, e *Ent, vel mgl64.Vec3) { owner, _ := lt.conf.Owner.Entity(e.tx) src := ProjectileDamageSource{Projectile: e, Owner: owner} dmg := math.Ceil(lt.conf.Damage * vel.Len()) @@ -279,7 +302,11 @@ func (lt *ProjectileBehaviour) hitEntity(l Living, e *Ent, vel mgl64.Vec3) { dmg += rand.Float64() * dmg / 2 } // TODO: Piercing arrows should bypass shield blocking when shields are implemented. - if _, vulnerable := l.Hurt(dmg, src); vulnerable { + if _, vulnerable, ok := HurtEntity(victim, dmg, src); ok && vulnerable { + l, ok := victim.(Living) + if !ok { + return + } l.KnockBack(l.Position().Sub(vel), 0.45+lt.conf.KnockBackForceAddend, 0.3608+lt.conf.KnockBackHeightAddend) for _, eff := range lt.conf.Potion.Effects() { @@ -337,7 +364,7 @@ func (lt *ProjectileBehaviour) tickMovement(e *Ent, tx *world.Tx) (*Movement, tr } // ignores returns a function to ignore entities in trace.Perform that are -// either a spectator, not living, the entity itself, its owner in the first +// either a spectator, not damageable, the entity itself, its owner in the first // 5 ticks, or an entity it already collided with. func (lt *ProjectileBehaviour) ignores(e *Ent) trace.EntityFilter { return func(seq iter.Seq[world.Entity]) iter.Seq[world.Entity] { @@ -346,10 +373,10 @@ func (lt *ProjectileBehaviour) ignores(e *Ent) trace.EntityFilter { g, ok := other.(interface{ GameMode() world.GameMode }) spectator := ok && !g.GameMode().HasCollision() itself := e.H() == other.H() - _, living := other.(Living) + damageable := DamageableEntity(other) owner := e.data.Age < time.Second/4 && lt.conf.Owner == other.H() collidedEntity := slices.Contains(lt.collidedEntities, other.H()) - if spectator || itself || !living || owner || collidedEntity { + if spectator || itself || !damageable || owner || collidedEntity { continue } if !yield(other) { diff --git a/server/entity/register.go b/server/entity/register.go index 7155ec9864..b4f4440143 100644 --- a/server/entity/register.go +++ b/server/entity/register.go @@ -14,6 +14,7 @@ var DefaultRegistry = conf.New([]world.EntityType{ ArrowType, BottleOfEnchantingType, EggType, + EndCrystalType, EnderPearlType, ExperienceOrbType, FallingBlockType, @@ -30,6 +31,7 @@ var DefaultRegistry = conf.New([]world.EntityType{ var conf = world.EntityRegistryConfig{ TNT: NewTNT, Egg: NewEgg, + EndCrystal: NewEndCrystal, Snowball: NewSnowball, BottleOfEnchanting: NewBottleOfEnchanting, EnderPearl: NewEnderPearl, diff --git a/server/entity/splashable.go b/server/entity/splashable.go index c9d1810fad..a3625c7236 100644 --- a/server/entity/splashable.go +++ b/server/entity/splashable.go @@ -1,6 +1,7 @@ package entity import ( + "github.com/df-mc/dragonfly/server/block" "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/block/cube/trace" "github.com/df-mc/dragonfly/server/entity/effect" @@ -67,12 +68,13 @@ func potionSplash(durMul float64, pot potion.Potion, linger bool) func(e *Ent, t switch result := res.(type) { case trace.BlockResult: blockPos := result.BlockPosition().Side(result.Face()) - if tx.Block(blockPos) == fire() { + if _, ok := tx.Block(blockPos).(block.Fire); ok { tx.SetBlock(blockPos, nil, nil) } for _, f := range cube.HorizontalFaces() { - if h := blockPos.Side(f); tx.Block(h) == fire() { + h := blockPos.Side(f) + if _, ok := tx.Block(h).(block.Fire); ok { tx.SetBlock(h, nil, nil) } diff --git a/server/entity/stationary.go b/server/entity/stationary.go index 3aaecd18c5..e399f3184a 100644 --- a/server/entity/stationary.go +++ b/server/entity/stationary.go @@ -31,13 +31,15 @@ func (conf StationaryBehaviourConfig) New() *StationaryBehaviour { if conf.ExistenceDuration == 0 { conf.ExistenceDuration = math.MaxInt64 } - return &StationaryBehaviour{conf: conf} + return &StationaryBehaviour{BaseBehaviour: NewBaseBehaviour(), conf: conf} } // StationaryBehaviour implements the behaviour of an entity that is unable to // move, such as a text entity or an area effect cloud. Applying velocity to // such entities will not move them. type StationaryBehaviour struct { + BaseBehaviour + conf StationaryBehaviourConfig close bool } diff --git a/server/entity/tnt.go b/server/entity/tnt.go index 99c639e8e4..2b8519f5ea 100644 --- a/server/entity/tnt.go +++ b/server/entity/tnt.go @@ -31,7 +31,10 @@ var tntConf = PassiveBehaviourConfig{ // explodeTNT creates an explosion at the position of e. func explodeTNT(e *Ent, tx *world.Tx) { - block.ExplosionConfig{ItemDropChance: 1}.Explode(tx, e.Position()) + block.ExplosionConfig{ItemDropChance: 1}.Explode(tx, world.EntityExplosionSource{ + Entity: e, + ExplosionSize: 4, + }) } // TNTType is a world.EntityType implementation for TNT. @@ -51,10 +54,10 @@ func (tntType) BBox(world.Entity) cube.BBox { func (t tntType) DecodeNBT(m map[string]any, data *world.EntityData) { conf := tntConf - conf.ExistenceDuration = nbtconv.TickDuration[uint8](m, "Fuse") + conf.ExistenceDuration = nbtconv.TickDuration[int16](m, "Fuse") data.Data = conf.New() } func (tntType) EncodeNBT(data *world.EntityData) map[string]any { - return map[string]any{"Fuse": uint8(data.Data.(*PassiveBehaviour).Fuse().Milliseconds() / 50)} + return map[string]any{"Fuse": int16(min(data.Data.(*PassiveBehaviour).Fuse()/(time.Second/20), math.MaxInt16))} } diff --git a/server/entity/travel.go b/server/entity/travel.go new file mode 100644 index 0000000000..07760ae888 --- /dev/null +++ b/server/entity/travel.go @@ -0,0 +1,323 @@ +package entity + +import ( + "context" + "sync" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/portal" + "github.com/go-gl/mathgl/mgl64" +) + +// PortalTravelComputer handles portal-triggered interdimensional travel for an entity. +type PortalTravelComputer struct { + // Instantaneous returns true if the entity should skip the portal wait timer when moving from the source to the + // target dimension. Players use this for game modes with instant portal travel and for End travel. + Instantaneous func(source, target world.Dimension) bool + // Teleport teleports the entity to the final portal position. If nil, Traveller.Teleport is used. + Teleport func(e Traveller, pos mgl64.Vec3) + // SpawnPoint returns the position the entity arrives at when returning from the End; if nil the world spawn is used. + SpawnPoint func(tx *world.Tx) mgl64.Vec3 + // Player specifies if the entity is a player. Players arrive one block lower on the End platform than other + // entities. + Player bool + // CreatePortal specifies if the entity may create a portal at the destination when none is found. Only players + // create portals; other entities only travel through portals that are already linked. + CreatePortal bool + // Cooldown is how long the entity must wait after a travel attempt before it may travel again. Non-player + // entities use 15 seconds (300 ticks). + Cooldown time.Duration + + mu sync.Mutex + start time.Time + cooldownUntil time.Time + inside bool + awaitingTravel bool + travelling bool + timedOut bool + pending *world.World +} + +// NewPortalTravelComputer creates a PortalTravelComputer for instant portal travel. +func NewPortalTravelComputer() *PortalTravelComputer { + return &PortalTravelComputer{Instantaneous: func(world.Dimension, world.Dimension) bool { return true }, Cooldown: time.Second * 15} +} + +// portalSearchRadius is the radius around the scaled arrival position searched for an existing linked portal. +// Bedrock Edition searches 128 blocks in both dimensions. +const portalSearchRadius = 128 + +// portalTravelComputerProvider is implemented by behaviours of entities that can travel through portals. +// Behaviours without a computer never travel. This matches vanilla, where some entities, such as falling blocks, +// cannot use portals. +type portalTravelComputerProvider interface { + PortalTravelComputer() *PortalTravelComputer +} + +// Traveller represents a world.Entity that can travel between dimensions. +type Traveller interface { + world.Entity + // Teleport teleports the entity to the position given. + Teleport(pos mgl64.Vec3) +} + +type portalTravelHandler interface { + HandlePortalTravel(source, destination world.Dimension) +} + +// EnterPortal handles an entity touching a portal block. It teleports the entity to the other dimension after four +// seconds or instantly if instantaneous is true. +func (t *PortalTravelComputer) EnterPortal(e Traveller, tx *world.Tx, target world.Dimension) { + if destination := t.enterPortal(tx, target); destination != nil { + t.travelQueued(e, tx, destination) + } +} + +// queuePortalTravel records portal travel to be completed by a terminal Ent tick step. +func (t *PortalTravelComputer) queuePortalTravel(tx *world.Tx, target world.Dimension) { + if destination := t.enterPortal(tx, target); destination != nil { + t.mu.Lock() + t.pending = destination + t.mu.Unlock() + } +} + +// enterPortal updates portal contact state and returns the destination world if travel should start. +func (t *PortalTravelComputer) enterPortal(tx *world.Tx, target world.Dimension) *world.World { + source := tx.World() + destination := source.PortalDestination(target) + if destination == source { + return nil + } + + t.mu.Lock() + t.inside = true + if t.timedOut { + // Timed out, we can't travel through portals. + t.mu.Unlock() + return nil + } + if time.Now().Before(t.cooldownUntil) { + t.mu.Unlock() + return nil + } + travelNow := t.instantaneous(source.Dimension(), target) || (t.awaitingTravel && time.Since(t.start) >= time.Second*4) + if !travelNow && !t.awaitingTravel { + t.start, t.awaitingTravel = time.Now(), true + } + t.mu.Unlock() + + if travelNow { + return destination + } + return nil +} + +func (t *PortalTravelComputer) instantaneous(source, target world.Dimension) bool { + return t.Instantaneous != nil && t.Instantaneous(source, target) +} + +// hasPendingPortalTravel reports whether portal travel was queued during this tick. +func (t *PortalTravelComputer) hasPendingPortalTravel() bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.pending != nil +} + +// finishPendingPortalTravel consumes queued portal travel and starts the terminal transfer. +func (t *PortalTravelComputer) finishPendingPortalTravel(e Traveller, tx *world.Tx) bool { + t.mu.Lock() + destination := t.pending + t.pending = nil + t.mu.Unlock() + + if destination == nil { + return false + } + t.travel(e, tx, destination) + return true +} + +// StopPortalContact resets the portal timer if the entity was not inside a portal this tick. +func (t *PortalTravelComputer) StopPortalContact() { + t.mu.Lock() + defer t.mu.Unlock() + if t.inside { + t.inside = false + return + } + if t.travelling || t.pending != nil { + return + } + t.timedOut, t.awaitingTravel = false, false +} + +// travel removes the entity from the current world and queues it for the given Nether or Overworld world. +func (t *PortalTravelComputer) travel(e Traveller, tx *world.Tx, destination *world.World) { + source := tx.World() + if destination == nil || destination == source { + return + } + + sourceDim, destinationDim := source.Dimension(), destination.Dimension() + origin := e.Position() + pos := translatePortalPosition(cube.PosFromVec3(origin), sourceDim, destinationDim) + + t.mu.Lock() + t.travelling, t.timedOut, t.awaitingTravel = true, true, false + t.mu.Unlock() + + handle := tx.RemoveEntity(e) + if handle == nil { + t.mu.Lock() + t.travelling, t.timedOut = false, false + t.mu.Unlock() + return + } + + go t.transfer(handle, source, destination, origin, pos, sourceDim, destinationDim) +} + +// travelQueued moves the entity after the current transaction finishes. This is used by callers such as players that +// may touch a portal from the middle of a tick and continue running afterwards. +func (t *PortalTravelComputer) travelQueued(e Traveller, tx *world.Tx, destination *world.World) { + source := tx.World() + if destination == nil || destination == source { + return + } + + sourceDim, destinationDim := source.Dimension(), destination.Dimension() + origin := e.Position() + pos := translatePortalPosition(cube.PosFromVec3(origin), sourceDim, destinationDim) + + t.mu.Lock() + t.travelling, t.timedOut, t.awaitingTravel = true, true, false + t.mu.Unlock() + + h := e.H() + tx.Defer(func(tx *world.Tx) { + // Re-open the entity in the deferred transaction: The wrapper passed to travelQueued belongs to the + // transaction that is about to finish. + e, ok := h.Entity(tx) + if !ok { + t.mu.Lock() + t.travelling, t.timedOut = false, false + t.mu.Unlock() + return + } + handle := tx.RemoveEntity(e) + if handle == nil { + t.mu.Lock() + t.travelling, t.timedOut = false, false + t.mu.Unlock() + return + } + go t.transfer(handle, source, destination, origin, pos, sourceDim, destinationDim) + }) +} + +// transfer adds the removed entity to the destination world at the arrival position. If no destination portal was +// found and the entity may not create one, the entity is returned to its origin in the source world instead. +func (t *PortalTravelComputer) transfer(handle *world.EntityHandle, source, destination *world.World, origin mgl64.Vec3, pos cube.Pos, sourceDim, destinationDim world.Dimension) { + travelled, err := world.Call(context.Background(), destination, func(tx *world.Tx) (bool, error) { + spawn, ok := t.destinationSpawn(tx, sourceDim, pos) + if !ok { + return false, nil + } + if e, ok := tx.AddEntityAt(handle, spawn).(Traveller); ok { + t.finishTravel(e, spawn, sourceDim, destinationDim) + } + return true, nil + }) + if err != nil { + travelled = false + } + if !travelled { + _, err = world.Call(context.Background(), source, func(tx *world.Tx) (struct{}, error) { + tx.AddEntityAt(handle, origin) + return struct{}{}, nil + }) + if err != nil { + _ = handle.Close() + } + } + + t.mu.Lock() + t.travelling = false + t.cooldownUntil = time.Now().Add(t.Cooldown) + if !travelled { + // The entity is back inside the source portal: clear the arrival latch so it may retry once the + // cooldown expires, for example after a linked portal is built. + t.timedOut = false + } + t.mu.Unlock() +} + +// destinationSpawn returns the position the entity should be placed at in the destination world. False is returned +// if no linked nether portal was found and none could be created. +func (t *PortalTravelComputer) destinationSpawn(tx *world.Tx, sourceDim world.Dimension, pos cube.Pos) (mgl64.Vec3, bool) { + if tx.World().Dimension() == world.End { + portal.GenerateEndSpawnPlatform(tx) + return portal.EndSpawnPosition(t.Player), true + } + if sourceDim == world.End && tx.World().Dimension() == world.Overworld { + // Returning from the End leads to the configured spawn point rather than a portal. + if t.SpawnPoint != nil { + return t.SpawnPoint(tx), true + } + return tx.World().Spawn().Vec3Middle(), true + } + if !t.CreatePortal { + n, ok := portal.FindNetherPortal(tx, pos, portalSearchRadius) + if !ok { + return mgl64.Vec3{}, false + } + return n.Spawn().Vec3Middle(), true + } + if n, ok := portal.FindOrCreateNetherPortal(tx, pos, portalSearchRadius); ok { + return n.Spawn().Vec3Middle(), true + } + return mgl64.Vec3{}, false +} + +// finishTravel runs the post-transfer portal hook and places the traveller at +// the destination spawn position. +func (t *PortalTravelComputer) finishTravel(e Traveller, pos mgl64.Vec3, source, destination world.Dimension) { + handlePortalTravel(e, source, destination) + if t.Teleport != nil { + t.Teleport(e, pos) + return + } + e.Teleport(pos) +} + +// handlePortalTravel dispatches portal travel hooks to Ent behaviours and +// non-Ent travellers that implement portalTravelHandler. +func handlePortalTravel(e Traveller, source, destination world.Dimension) { + if ent, ok := e.(*Ent); ok { + if h, ok := ent.Behaviour().(portalTravelHandler); ok { + h.HandlePortalTravel(source, destination) + } + return + } + if h, ok := e.(portalTravelHandler); ok { + h.HandlePortalTravel(source, destination) + } +} + +// translatePortalPosition maps a position in the source dimension to the equivalent position in the target dimension. +// Overworld coordinates are divided by 8 when crossing to the Nether and Nether coordinates are multiplied by 8 when +// crossing to the Overworld; the Y coordinate is clamped to the target dimension's vertical range. +func translatePortalPosition(pos cube.Pos, source, target world.Dimension) cube.Pos { + switch source { + case world.Overworld: + pos[0], pos[2] = pos[0]>>3, pos[2]>>3 + case world.Nether: + pos[0], pos[2] = pos[0]*8, pos[2]*8 + } + r := target.Range() + pos[1] = min(max(pos[1], r.Min()), r.Max()) + return pos +} diff --git a/server/entity/travel_test.go b/server/entity/travel_test.go new file mode 100644 index 0000000000..950a69d68a --- /dev/null +++ b/server/entity/travel_test.go @@ -0,0 +1,594 @@ +package entity + +import ( + "context" + "testing" + "time" + + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/portal" + "github.com/go-gl/mathgl/mgl64" +) + +func TestPortalTravelComputerStopPortalContact(t *testing.T) { + t.Run("keeps timer after portal contact", func(t *testing.T) { + tc := &PortalTravelComputer{inside: true, awaitingTravel: true, start: time.Now()} + tc.StopPortalContact() + if !tc.awaitingTravel { + t.Fatal("StopPortalContact() reset travel timer after portal contact") + } + if tc.inside { + t.Fatal("StopPortalContact() did not clear portal contact for the next tick") + } + }) + + t.Run("resets timer without portal contact", func(t *testing.T) { + tc := &PortalTravelComputer{awaitingTravel: true, start: time.Now()} + tc.StopPortalContact() + if tc.awaitingTravel { + t.Fatal("StopPortalContact() kept travel timer without portal contact") + } + }) +} + +func TestEntProjectileTravelsThroughPortal(t *testing.T) { + var overworld, nether *world.World + overworld = world.Config{PortalDestination: func(dim world.Dimension) *world.World { + if dim == world.Nether { + return nether + } + return nil + }}.New() + nether = world.Config{Dim: world.Nether, PortalDestination: func(dim world.Dimension) *world.World { + if dim == world.Nether { + return overworld + } + return nil + }}.New() + t.Cleanup(func() { + _ = overworld.Close() + _ = nether.Close() + }) + + spawnRecorder := &entitySpawnRecorder{} + nether.Handle(spawnRecorder) + + sourcePos := mgl64.Vec3{80.5, 64, 80.5} + targetPortal := cube.Pos{10, 64, 10} + mustDo(t, nether, func(tx *world.Tx) { + buildActivePortal(tx, targetPortal) + }) + + handle := world.EntitySpawnOpts{Position: sourcePos}.New(EnderPearlType, enderPearlConf) + mustDo(t, overworld, func(tx *world.Tx) { + e := tx.AddEntity(handle) + (block.Portal{Axis: cube.Z}).EntityInside(cube.PosFromVec3(sourcePos), tx, e) + if _, ok := handle.Entity(tx); !ok { + t.Fatal("non-terminal portal contact removed entity before the source transaction finished") + } + }) + + waitForEntityWorld(t, handle, nether) + if entityInWorld(handle, overworld) { + t.Fatal("entity remained in the source world after portal travel") + } + if !spawnRecorder.called { + t.Fatal("destination world did not fire an entity spawn event") + } + if got, want := spawnRecorder.pos, targetPortal.Vec3Middle(); !got.ApproxEqual(want) { + t.Fatalf("destination spawn event position = %v, want %v", got, want) + } + + mustDo(t, nether, func(tx *world.Tx) { + e, ok := handle.Entity(tx) + if !ok { + t.Fatal("entity was not added to the Nether") + } + if got, want := cube.PosFromVec3(e.Position()), targetPortal; got != want { + t.Fatalf("entity position after portal travel = %v, want %v", got, want) + } + ent, ok := e.(*Ent) + if !ok { + t.Fatalf("entity after portal travel has type %T, want *Ent", e) + } + projectile, ok := ent.Behaviour().(*ProjectileBehaviour) + if !ok { + t.Fatalf("entity behaviour after portal travel has type %T, want *ProjectileBehaviour", ent.Behaviour()) + } + if !projectile.PortalTravel() { + t.Fatal("projectile portal travel state was not preserved") + } + }) +} + +func TestEntTravelsThroughPortalOnTick(t *testing.T) { + var overworld, nether *world.World + overworld = world.Config{PortalDestination: func(dim world.Dimension) *world.World { + if dim == world.Nether { + return nether + } + return nil + }}.New() + nether = world.Config{Dim: world.Nether, PortalDestination: func(dim world.Dimension) *world.World { + if dim == world.Nether { + return overworld + } + return nil + }}.New() + t.Cleanup(func() { + _ = overworld.Close() + _ = nether.Close() + }) + + sourcePortal, targetPortal := cube.Pos{80, 64, 80}, cube.Pos{10, 64, 10} + mustDo(t, overworld, func(tx *world.Tx) { + buildActivePortal(tx, sourcePortal) + }) + mustDo(t, nether, func(tx *world.Tx) { + buildActivePortal(tx, targetPortal) + }) + + handle := world.EntitySpawnOpts{Position: sourcePortal.Vec3Middle().Sub(mgl64.Vec3{1})}.New(testMovingEntType{}, testMoveConfig{delta: mgl64.Vec3{1}}) + mustDo(t, overworld, func(tx *world.Tx) { + e := tx.AddEntity(handle) + ticker, ok := e.(world.TickerEntity) + if !ok { + t.Fatalf("entity has type %T, want world.TickerEntity", e) + } + ticker.Tick(tx, 1) + }) + + waitForEntityWorld(t, handle, nether) + if entityInWorld(handle, overworld) { + t.Fatal("entity remained in the source world after tick-driven portal travel") + } + mustDo(t, nether, func(tx *world.Tx) { + e, ok := handle.Entity(tx) + if !ok { + t.Fatal("entity was not added to the Nether") + } + if got, want := cube.PosFromVec3(e.Position()), targetPortal; got != want { + t.Fatalf("entity position after tick-driven portal travel = %v, want %v", got, want) + } + if got := e.(*Ent).Age(); got != 0 { + t.Fatalf("entity age after terminal portal travel tick = %v, want 0", got) + } + }) +} + +func TestEntTravelsThroughEndPortal(t *testing.T) { + var overworld, end *world.World + overworld = world.Config{PortalDestination: func(dim world.Dimension) *world.World { + if dim == world.End { + return end + } + return nil + }}.New() + end = world.Config{Dim: world.End, PortalDestination: func(dim world.Dimension) *world.World { + if dim == world.End { + return overworld + } + return nil + }}.New() + t.Cleanup(func() { + _ = overworld.Close() + _ = end.Close() + }) + + sourcePortal := cube.Pos{50, 64, 50} + mustDo(t, overworld, func(tx *world.Tx) { + tx.SetBlock(sourcePortal, block.EndPortal{}, nil) + }) + + handle := world.EntitySpawnOpts{Position: sourcePortal.Vec3Middle().Sub(mgl64.Vec3{1})}.New(testMovingEntType{}, testMoveConfig{delta: mgl64.Vec3{1}}) + mustDo(t, overworld, func(tx *world.Tx) { + e := tx.AddEntity(handle) + ticker, ok := e.(world.TickerEntity) + if !ok { + t.Fatalf("entity has type %T, want world.TickerEntity", e) + } + ticker.Tick(tx, 1) + }) + + waitForEntityWorld(t, handle, end) + if entityInWorld(handle, overworld) { + t.Fatal("entity remained in the source world after End portal travel") + } + mustDo(t, end, func(tx *world.Tx) { + e, ok := handle.Entity(tx) + if !ok { + t.Fatal("entity was not added to the End") + } + want := mgl64.Vec3{100.5, 50, 0.5} + if got := e.Position(); !got.ApproxEqual(want) { + t.Fatalf("entity position after End travel = %v, want %v", got, want) + } + // Spawn platform: 5x5 obsidian at y=48 around x=100, z=0. + for dx := -2; dx <= 2; dx++ { + for dz := -2; dz <= 2; dz++ { + p := cube.Pos{100 + dx, 48, dz} + if _, ok := tx.Block(p).(block.Obsidian); !ok { + t.Fatalf("obsidian platform missing at %v: got %T", p, tx.Block(p)) + } + } + } + }) +} + +func TestEndReturnSpawnSelection(t *testing.T) { + t.Run("overworld uses configured spawn point", func(t *testing.T) { + w := world.New() + t.Cleanup(func() { _ = w.Close() }) + want := mgl64.Vec3{12.5, 70, -3.5} + tc := &PortalTravelComputer{SpawnPoint: func(*world.Tx) mgl64.Vec3 { return want }} + + mustDo(t, w, func(tx *world.Tx) { + got, ok := tc.destinationSpawn(tx, world.End, cube.Pos{}) + if !ok || !got.ApproxEqual(want) { + t.Fatalf("destinationSpawn() = %v, %v, want %v, true", got, ok, want) + } + }) + }) + + t.Run("overworld falls back to world spawn", func(t *testing.T) { + w := world.New() + t.Cleanup(func() { _ = w.Close() }) + tc := &PortalTravelComputer{} + + mustDo(t, w, func(tx *world.Tx) { + want := tx.World().Spawn().Vec3Middle() + got, ok := tc.destinationSpawn(tx, world.End, cube.Pos{}) + if !ok || !got.ApproxEqual(want) { + t.Fatalf("destinationSpawn() = %v, %v, want %v, true", got, ok, want) + } + }) + }) + + t.Run("nether searches for a portal", func(t *testing.T) { + w := world.Config{Dim: world.Nether}.New() + t.Cleanup(func() { _ = w.Close() }) + tc := &PortalTravelComputer{} + + mustDo(t, w, func(tx *world.Tx) { + if _, ok := tc.destinationSpawn(tx, world.End, cube.Pos{}); ok { + t.Fatal("destinationSpawn() ok = true without a linked Nether portal, want false") + } + }) + }) +} + +func TestTranslatePortalPosition(t *testing.T) { + tests := []struct { + name string + pos, want cube.Pos + source, target world.Dimension + }{ + {name: "overworld to nether", pos: cube.Pos{80, 64, 81}, want: cube.Pos{10, 64, 10}, source: world.Overworld, target: world.Nether}, + {name: "negative coordinates floor towards negative infinity", pos: cube.Pos{-15, 64, -1}, want: cube.Pos{-2, 64, -1}, source: world.Overworld, target: world.Nether}, + {name: "nether to overworld", pos: cube.Pos{10, 64, -3}, want: cube.Pos{80, 64, -24}, source: world.Nether, target: world.Overworld}, + {name: "y clamped to nether range", pos: cube.Pos{0, 319, 0}, want: cube.Pos{0, 127, 0}, source: world.Overworld, target: world.Nether}, + {name: "y clamped to overworld range", pos: cube.Pos{0, -80, 0}, want: cube.Pos{0, -64, 0}, source: world.Nether, target: world.Overworld}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := translatePortalPosition(tt.pos, tt.source, tt.target); got != tt.want { + t.Fatalf("translatePortalPosition(%v, %v, %v) = %v, want %v", tt.pos, tt.source, tt.target, got, tt.want) + } + }) + } +} + +func TestPortalTravelComputerDelayedTravel(t *testing.T) { + overworld, nether := portalWorlds(t) + _ = nether + + tc := &PortalTravelComputer{} + mustDo(t, overworld, func(tx *world.Tx) { + if destination := tc.enterPortal(tx, world.Nether); destination != nil { + t.Fatal("enterPortal() started travel before the portal timer finished") + } + if !tc.awaitingTravel { + t.Fatal("enterPortal() did not start the portal timer") + } + + // Backdate the timer to simulate the entity having stood in the portal for four seconds. + tc.start = time.Now().Add(-time.Second * 4) + if destination := tc.enterPortal(tx, world.Nether); destination != nether { + t.Fatalf("enterPortal() destination after portal timer = %v, want the Nether", destination) + } + }) +} + +func TestPortalTravelComputerCooldown(t *testing.T) { + overworld, nether := portalWorlds(t) + _ = nether + + tc := NewPortalTravelComputer() + mustDo(t, overworld, func(tx *world.Tx) { + tc.cooldownUntil = time.Now().Add(time.Hour) + if destination := tc.enterPortal(tx, world.Nether); destination != nil { + t.Fatal("enterPortal() started travel during the portal cooldown") + } + + tc.cooldownUntil = time.Now().Add(-time.Second) + if destination := tc.enterPortal(tx, world.Nether); destination != nether { + t.Fatal("enterPortal() did not start travel after the portal cooldown expired") + } + }) +} + +func TestEntPortalTravelWithoutDestinationPortal(t *testing.T) { + overworld, nether := portalWorlds(t) + + spawnRecorder := &entitySpawnRecorder{} + nether.Handle(spawnRecorder) + + sourcePos := mgl64.Vec3{80.5, 64, 80.5} + handle := world.EntitySpawnOpts{Position: sourcePos}.New(EnderPearlType, enderPearlConf) + var tc *PortalTravelComputer + mustDo(t, overworld, func(tx *world.Tx) { + e := tx.AddEntity(handle) + tc = e.(*Ent).Behaviour().(*ProjectileBehaviour).PortalTravelComputer() + (block.Portal{Axis: cube.Z}).EntityInside(cube.PosFromVec3(sourcePos), tx, e) + }) + + // The cooldown is stamped once the travel attempt finishes, after the entity was returned to the source world. + deadline := time.Now().Add(2 * time.Second) + for { + tc.mu.Lock() + done := !tc.cooldownUntil.IsZero() + tc.mu.Unlock() + if done { + break + } + if time.Now().After(deadline) { + t.Fatal("timed out waiting for the travel attempt to finish") + } + time.Sleep(10 * time.Millisecond) + } + + if !entityInWorld(handle, overworld) { + t.Fatal("entity did not return to the source world after failing to find a destination portal") + } + if spawnRecorder.called { + t.Fatal("entity was spawned in the destination world without a linked portal") + } + mustDo(t, overworld, func(tx *world.Tx) { + e, _ := handle.Entity(tx) + if got := e.Position(); !got.ApproxEqual(sourcePos) { + t.Fatalf("entity position after failed portal travel = %v, want %v", got, sourcePos) + } + }) + mustDo(t, nether, func(tx *world.Tx) { + if _, ok := portal.FindNetherPortal(tx, cube.Pos{10, 64, 10}, 16); ok { + t.Fatal("a portal was created in the destination world by a non-player entity") + } + }) +} + +func TestPortalTravelClosesHandleWhenBothWorldsClose(t *testing.T) { + source := world.Config{Synchronous: true}.New() + destination := world.Config{Dim: world.Nether, Synchronous: true}.New() + + origin := mgl64.Vec3{80.5, 64, 80.5} + handle := world.EntitySpawnOpts{Position: origin}.New(EnderPearlType, enderPearlConf) + mustDo(t, source, func(tx *world.Tx) { + e := tx.AddEntity(handle) + if removed := tx.RemoveEntity(e); removed != handle { + t.Fatal("RemoveEntity() did not return the entity handle") + } + }) + if err := destination.Close(); err != nil { + t.Fatalf("close destination world: %v", err) + } + if err := source.Close(); err != nil { + t.Fatalf("close source world: %v", err) + } + + tc := NewPortalTravelComputer() + tc.transfer(handle, source, destination, origin, cube.Pos{10, 64, 10}, world.Overworld, world.Nether) + + if !handle.Closed() { + t.Fatal("entity handle remained worldless after destination and recovery worlds closed") + } +} + +func TestPortalTravelRethrowsDestinationPanic(t *testing.T) { + source := world.Config{Synchronous: true}.New() + destination := world.Config{Dim: world.Nether, Synchronous: true}.New() + t.Cleanup(func() { + _ = source.Close() + _ = destination.Close() + }) + destination.Handle(panicSpawnHandler{}) + + origin := mgl64.Vec3{80.5, 64, 80.5} + handle := world.EntitySpawnOpts{Position: origin}.New(testMovingEntType{}, testPortalCreatorConfig{}) + mustDo(t, source, func(tx *world.Tx) { + e := tx.AddEntity(handle) + if removed := tx.RemoveEntity(e); removed != handle { + t.Fatal("RemoveEntity() did not return the entity handle") + } + }) + + defer func() { + if recovered := recover(); recovered != "spawn panic" { + t.Fatalf("transfer panic = %v, want spawn panic", recovered) + } + }() + tc := NewPortalTravelComputer() + tc.CreatePortal = true + tc.transfer(handle, source, destination, origin, cube.Pos{10, 64, 10}, world.Overworld, world.Nether) +} + +func TestFallingBlockDoesNotTravelThroughPortal(t *testing.T) { + overworld, nether := portalWorlds(t) + + spawnRecorder := &entitySpawnRecorder{} + nether.Handle(spawnRecorder) + + targetPortal := cube.Pos{10, 64, 10} + mustDo(t, nether, func(tx *world.Tx) { + buildActivePortal(tx, targetPortal) + }) + + sourcePos := mgl64.Vec3{80.5, 64, 80.5} + handle := NewFallingBlock(world.EntitySpawnOpts{Position: sourcePos}, block.Sand{}) + mustDo(t, overworld, func(tx *world.Tx) { + e := tx.AddEntity(handle) + (block.Portal{Axis: cube.Z}).EntityInside(cube.PosFromVec3(sourcePos), tx, e) + }) + + // Portal travel finishes asynchronously, so give it time to wrongly happen before asserting nothing moved. + time.Sleep(100 * time.Millisecond) + if !entityInWorld(handle, overworld) { + t.Fatal("falling block left the source world through a portal") + } + if spawnRecorder.called { + t.Fatal("falling block was spawned in the destination world") + } +} + +func TestEntPortalTravelCreatesPortal(t *testing.T) { + overworld, nether := portalWorlds(t) + + sourcePos := mgl64.Vec3{80.5, 64, 80.5} + handle := world.EntitySpawnOpts{Position: sourcePos}.New(testMovingEntType{}, testPortalCreatorConfig{}) + mustDo(t, overworld, func(tx *world.Tx) { + e := tx.AddEntity(handle) + (block.Portal{Axis: cube.Z}).EntityInside(cube.PosFromVec3(sourcePos), tx, e) + }) + + waitForEntityWorld(t, handle, nether) + mustDo(t, nether, func(tx *world.Tx) { + if _, ok := portal.FindNetherPortal(tx, cube.Pos{10, 64, 10}, 16); !ok { + t.Fatal("no portal was created in the destination world for a portal-creating entity") + } + }) +} + +// portalWorlds returns an Overworld and Nether world linked to each other through portals. +func portalWorlds(t *testing.T) (overworld, nether *world.World) { + t.Helper() + overworld = world.Config{PortalDestination: func(dim world.Dimension) *world.World { + if dim == world.Nether { + return nether + } + return nil + }}.New() + nether = world.Config{Dim: world.Nether, PortalDestination: func(dim world.Dimension) *world.World { + if dim == world.Nether { + return overworld + } + return nil + }}.New() + t.Cleanup(func() { + _ = overworld.Close() + _ = nether.Close() + }) + return overworld, nether +} + +func mustDo(t *testing.T, w *world.World, f func(tx *world.Tx)) { + t.Helper() + if err := w.Do(f).Wait(context.Background()); err != nil { + t.Fatalf("world task failed: %v", err) + } +} + +// testPortalCreatorConfig configures a test entity that may create destination portals, like a player. +type testPortalCreatorConfig struct{} + +func (testPortalCreatorConfig) Apply(data *world.EntityData) { + data.Data = &testMoveBehaviour{BaseBehaviour: BaseBehaviour{portalTravel: &PortalTravelComputer{ + Instantaneous: func(world.Dimension, world.Dimension) bool { return true }, + CreatePortal: true, + }}} +} + +func waitForEntityWorld(t *testing.T, handle *world.EntityHandle, w *world.World) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if entityInWorld(handle, w) { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("timed out waiting for entity to change worlds") +} + +func entityInWorld(handle *world.EntityHandle, w *world.World) bool { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + inWorld, err := world.CallEntity(ctx, handle, func(tx *world.Tx, _ world.Entity) (bool, error) { + return tx.World() == w, nil + }) + return err == nil && inWorld +} + +func buildActivePortal(tx *world.Tx, origin cube.Pos) { + for x := range 2 { + p := origin.Add(cube.Pos{0, 0, x}) + tx.SetBlock(p.Side(cube.FaceDown), block.Obsidian{}, nil) + tx.SetBlock(p.Add(cube.Pos{0, 3}), block.Obsidian{}, nil) + } + for y := range 3 { + p := origin.Add(cube.Pos{0, y}) + tx.SetBlock(p.Side(cube.FaceNorth), block.Obsidian{}, nil) + tx.SetBlock(p.Add(cube.Pos{0, 0, 2}), block.Obsidian{}, nil) + for x := range 2 { + tx.SetBlock(p.Add(cube.Pos{0, 0, x}), block.Portal{Axis: cube.Z}, nil) + } + } +} + +type entitySpawnRecorder struct { + world.NopHandler + + called bool + pos mgl64.Vec3 +} + +type panicSpawnHandler struct{ world.NopHandler } + +func (panicSpawnHandler) HandleEntitySpawn(*world.Tx, world.Entity) { panic("spawn panic") } + +func (r *entitySpawnRecorder) HandleEntitySpawn(_ *world.Tx, e world.Entity) { + r.called = true + r.pos = e.Position() +} + +type testMoveConfig struct { + delta mgl64.Vec3 +} + +func (c testMoveConfig) Apply(data *world.EntityData) { + data.Data = &testMoveBehaviour{BaseBehaviour: NewBaseBehaviour(), delta: c.delta} +} + +type testMoveBehaviour struct { + BaseBehaviour + + delta mgl64.Vec3 +} + +func (b *testMoveBehaviour) Tick(e *Ent, _ *world.Tx) *Movement { + e.data.Pos = e.data.Pos.Add(b.delta) + return nil +} + +type testMovingEntType struct{} + +func (testMovingEntType) Open(tx *world.Tx, handle *world.EntityHandle, data *world.EntityData) world.Entity { + return &Ent{tx: tx, handle: handle, data: data} +} + +func (testMovingEntType) EncodeEntity() string { return "minecraft:test_moving_ent" } +func (testMovingEntType) BBox(world.Entity) cube.BBox { + return cube.Box(-0.125, 0, -0.125, 0.125, 0.25, 0.125) +} +func (testMovingEntType) DecodeNBT(map[string]any, *world.EntityData) {} +func (testMovingEntType) EncodeNBT(*world.EntityData) map[string]any { return nil } diff --git a/server/internal/blockinternal/components.go b/server/internal/blockinternal/components.go index 86a7557d2a..ab9121ddc1 100644 --- a/server/internal/blockinternal/components.go +++ b/server/internal/blockinternal/components.go @@ -4,6 +4,7 @@ import ( "github.com/df-mc/dragonfly/server/block" "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/block/customblock" + "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" ) @@ -13,18 +14,21 @@ func Components(identifier string, b world.CustomBlock, blockID int32) map[strin components := componentsFromProperties(b.Properties()) builder := NewComponentBuilder(identifier, components, blockID) if emitter, ok := b.(block.LightEmitter); ok { - builder.AddComponent("minecraft:block_light_emission", map[string]any{ - "emission": float32(emitter.LightEmissionLevel() / 15), + builder.AddComponent("minecraft:light_emission", map[string]any{ + "emission": int32(emitter.LightEmissionLevel()), }) } if diffuser, ok := b.(block.LightDiffuser); ok { - builder.AddComponent("minecraft:block_light_filter", map[string]any{ + builder.AddComponent("minecraft:light_dampening", map[string]any{ "lightLevel": int32(diffuser.LightDiffusionLevel()), }) } - if breakable, ok := b.(block.Breakable); ok { - info := breakable.BreakInfo() - builder.AddComponent("minecraft:destructible_by_mining", map[string]any{"value": float32(info.Hardness)}) + if _, ok := b.(block.Breakable); ok { + // The component's value is the seconds the client takes to destroy the block. + // Bedrock has no per-tool speed component, so the bare-handed + // duration is the only one a static definition can carry. + seconds := block.BreakDuration(b, item.Stack{}, block.BreakContext{}).Seconds() + builder.AddComponent("minecraft:destructible_by_mining", map[string]any{"value": float32(seconds)}) } if frictional, ok := b.(block.Frictional); ok { builder.AddComponent("minecraft:friction", map[string]any{"value": float32(frictional.Friction())}) @@ -63,7 +67,7 @@ func componentsFromProperties(props customblock.Properties) map[string]any { if props.Geometry != "" { components["minecraft:geometry"] = map[string]any{"identifier": props.Geometry} } else if props.Cube { - components["minecraft:unit_cube"] = map[string]any{} + components["minecraft:geometry"] = map[string]any{"identifier": "minecraft:geometry.full_block"} } if props.MapColour != "" { components["minecraft:map_color"] = map[string]any{"value": props.MapColour} diff --git a/server/internal/mcrandom/mix_stafford13.go b/server/internal/mcrandom/mix_stafford13.go new file mode 100644 index 0000000000..a8a074036f --- /dev/null +++ b/server/internal/mcrandom/mix_stafford13.go @@ -0,0 +1,9 @@ +package mcrandom + +// MixStafford13 implements the Stafford 13 mixing function, a bijective mixing function +// suitable for use in pseudorandom number generators. +func MixStafford13(seed uint64) uint64 { + seed = (seed ^ (seed >> 30)) * 0xBF58476D1CE4E5B9 + seed = (seed ^ (seed >> 27)) * 0x94D049BB133111EB + return seed ^ (seed >> 31) +} diff --git a/server/internal/mcrandom/xoroshiro128plusplus.go b/server/internal/mcrandom/xoroshiro128plusplus.go new file mode 100644 index 0000000000..b965321b7f --- /dev/null +++ b/server/internal/mcrandom/xoroshiro128plusplus.go @@ -0,0 +1,25 @@ +package mcrandom + +import "math/bits" + +// Xoroshiro128PlusPlus is a member of the Xor-Shift-Rotate family of generators. Memory +// footprint is 128 bits and the period is (2^128)-1. +type Xoroshiro128PlusPlus struct { + seed0, seed1 uint64 +} + +// NewXoroshiro128PlusPlus ... +func NewXoroshiro128PlusPlus(seed0, seed1 uint64) *Xoroshiro128PlusPlus { + return &Xoroshiro128PlusPlus{seed0, seed1} +} + +// Next ... +func (x *Xoroshiro128PlusPlus) Next() uint64 { + s0 := x.seed0 + s1 := x.seed1 + result := bits.RotateLeft64(s0+s1, 17) + s0 + s1 ^= s0 + x.seed0 = bits.RotateLeft64(s0, 49) ^ s1 ^ (s1 << 21) + x.seed1 = bits.RotateLeft64(s1, 28) + return result +} diff --git a/server/internal/nbtconv/item.go b/server/internal/nbtconv/item.go index 1443ddc16d..bb60f7aedb 100644 --- a/server/internal/nbtconv/item.go +++ b/server/internal/nbtconv/item.go @@ -1,6 +1,7 @@ package nbtconv import ( + "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/item/inventory" ) @@ -8,7 +9,7 @@ import ( func InvFromNBT(inv *inventory.Inventory, items []any) { for _, itemData := range items { data, _ := itemData.(map[string]any) - it := Item(data, nil) + it := item.ReadNBT(data, nil) if it.Empty() { continue } @@ -17,13 +18,13 @@ func InvFromNBT(inv *inventory.Inventory, items []any) { } // InvToNBT encodes an inventory to a data slice which may be encoded as NBT. -func InvToNBT(inv *inventory.Inventory) []map[string]any { - var items []map[string]any +func InvToNBT(inv *inventory.Inventory) []any { + var items []any for index, i := range inv.Slots() { if i.Empty() { continue } - data := WriteItem(i, true) + data := item.WriteNBT(i, true) data["Slot"] = byte(index) items = append(items, data) } diff --git a/server/internal/nbtconv/read.go b/server/internal/nbtconv/read.go index 7eae85f7ac..e439c88144 100644 --- a/server/internal/nbtconv/read.go +++ b/server/internal/nbtconv/read.go @@ -1,12 +1,9 @@ package nbtconv import ( - "bytes" - "encoding/gob" "time" "github.com/df-mc/dragonfly/server/block/cube" - "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" "golang.org/x/exp/constraints" @@ -139,38 +136,6 @@ func PosToInt32Slice(x cube.Pos) []int32 { return []int32{int32(x[0]), int32(x[1]), int32(x[2])} } -// MapItem converts an item's name, count, damage (and properties when it is a block) in a map obtained by decoding NBT -// to a world.Item. -func MapItem(x map[string]any, k string) item.Stack { - if m, ok := x[k].(map[string]any); ok { - return Item(m, nil) - } - return item.Stack{} -} - -// Item decodes the data of an item into an item stack. -func Item(data map[string]any, s *item.Stack) item.Stack { - disk, tag := s == nil, data - if disk { - t, ok := data["tag"].(map[string]any) - if !ok { - t = map[string]any{} - } - tag = t - - a := readItemStack(data, tag) - s = &a - } - - readAnvilCost(tag, s) - readDamage(tag, s, disk) - readDisplay(tag, s) - readDragonflyData(tag, s) - readEnchantments(tag, s) - readUnbreakable(tag, s) - return *s -} - // Block decodes the data of a block into a world.Block. func Block(m map[string]any, k string) world.Block { if mk, ok := m[k].(map[string]any); ok { @@ -181,103 +146,3 @@ func Block(m map[string]any, k string) world.Block { } return nil } - -// readItemStack reads an item.Stack from the NBT in the map passed. -func readItemStack(m, t map[string]any) item.Stack { - var it world.Item - if blockItem, ok := Block(m, "Block").(world.Item); ok { - it = blockItem - } - if v, ok := world.ItemByName(String(m, "Name"), Int16(m, "Damage")); ok { - it = v - } - if it == nil { - return item.Stack{} - } - if n, ok := it.(world.NBTer); ok { - it = n.DecodeNBT(t).(world.Item) - } - return item.NewStack(it, int(Uint8(m, "Count"))) -} - -// readDamage reads the damage value stored in the NBT with the Damage tag and saves it to the item.Stack passed. -func readDamage(m map[string]any, s *item.Stack, disk bool) { - if disk { - *s = s.Damage(int(Int16(m, "Damage"))) - return - } - *s = s.Damage(int(Int32(m, "Damage"))) -} - -// readAnvilCost ... -func readAnvilCost(m map[string]any, s *item.Stack) { - *s = s.WithAnvilCost(int(Int32(m, "RepairCost"))) -} - -// readEnchantments reads the enchantments stored in the ench tag of the NBT passed and stores it into an item.Stack. -func readEnchantments(m map[string]any, s *item.Stack) { - enchantments, ok := m["ench"].([]map[string]any) - if !ok { - for _, e := range Slice(m, "ench") { - if v, ok := e.(map[string]any); ok { - enchantments = append(enchantments, v) - } - } - } - for _, ench := range enchantments { - if t, ok := item.EnchantmentByID(int(Int16(ench, "id"))); ok { - *s = s.WithForcedEnchantments(item.NewEnchantment(t, int(Int16(ench, "lvl")))) - } - } -} - -// readDisplay reads the display data present in the display field in the NBT. It includes a custom name of the item -// and the lore. -func readDisplay(m map[string]any, s *item.Stack) { - if display, ok := m["display"].(map[string]any); ok { - if name, ok := display["Name"].(string); ok { - // Only add the custom name if actually set. - *s = s.WithCustomName(name) - } - if lore, ok := display["Lore"].([]string); ok { - *s = s.WithLore(lore...) - } else if lore, ok := display["Lore"].([]any); ok { - loreLines := make([]string, 0, len(lore)) - for _, l := range lore { - loreLines = append(loreLines, l.(string)) - } - *s = s.WithLore(loreLines...) - } - } -} - -// readDragonflyData reads data written to the dragonflyData field in the NBT of an item and adds it to the item.Stack -// passed. -func readDragonflyData(m map[string]any, s *item.Stack) { - if customData, ok := m["dragonflyData"]; ok { - d, ok := customData.([]byte) - if !ok { - if itf, ok := customData.([]any); ok { - for _, v := range itf { - b, _ := v.(byte) - d = append(d, b) - } - } - } - var values []mapValue - if err := gob.NewDecoder(bytes.NewBuffer(d)).Decode(&values); err != nil { - panic("error decoding item user data: " + err.Error()) - } - for _, val := range values { - *s = s.WithValue(val.K, val.V) - } - } -} - -// readUnbreakable reads the unbreakable value stored in the NBT with the Unbreakable tag and saves it to the item.Stack -// passed. -func readUnbreakable(m map[string]any, s *item.Stack) { - if Bool(m, "Unbreakable") { - *s = s.AsUnbreakable() - } -} diff --git a/server/internal/nbtconv/write.go b/server/internal/nbtconv/write.go index 508e70b68d..a19afbb07f 100644 --- a/server/internal/nbtconv/write.go +++ b/server/internal/nbtconv/write.go @@ -1,43 +1,10 @@ package nbtconv import ( - "bytes" - "encoding/gob" - "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/world" "github.com/df-mc/dragonfly/server/world/chunk" - "sort" ) -// WriteItem encodes an item stack into a map that can be encoded using NBT. -func WriteItem(s item.Stack, disk bool) map[string]any { - tag := make(map[string]any) - if s.Empty() { - return tag - } - if nbt, ok := s.Item().(world.NBTer); ok { - for k, v := range nbt.EncodeNBT() { - tag[k] = v - } - } - writeAnvilCost(tag, s) - writeDamage(tag, s, disk) - writeDisplay(tag, s) - writeDragonflyData(tag, s) - writeEnchantments(tag, s) - writeUnbreakable(tag, s) - - data := make(map[string]any) - if disk { - writeItemStack(data, tag, s) - } else { - for k, v := range tag { - data[k] = v - } - } - return data -} - // WriteBlock encodes a world.Block into a map that can be encoded using NBT. func WriteBlock(b world.Block) map[string]any { name, properties := b.EncodeBlock() @@ -47,113 +14,3 @@ func WriteBlock(b world.Block) map[string]any { "version": chunk.CurrentBlockVersion, } } - -// writeItemStack writes the name, metadata value, count and NBT of an item to a map ready for NBT encoding. -func writeItemStack(m, t map[string]any, s item.Stack) { - m["Name"], m["Damage"] = s.Item().EncodeItem() - if b, ok := s.Item().(world.Block); ok { - v := map[string]any{} - writeBlock(v, b) - m["Block"] = v - } - m["Count"] = byte(s.Count()) - if len(t) > 0 { - m["tag"] = t - } -} - -// writeBlock writes the name, properties and version of a block to a map ready for NBT encoding. -func writeBlock(m map[string]any, b world.Block) { - m["name"], m["states"] = b.EncodeBlock() - m["version"] = chunk.CurrentBlockVersion -} - -// writeDragonflyData writes additional data associated with an item.Stack to a map for NBT encoding. -func writeDragonflyData(m map[string]any, s item.Stack) { - if v := s.Values(); len(v) != 0 { - buf := new(bytes.Buffer) - if err := gob.NewEncoder(buf).Encode(mapToSlice(v)); err != nil { - panic("error encoding item user data: " + err.Error()) - } - m["dragonflyData"] = buf.Bytes() - } -} - -// mapToSlice converts a map to a slice of the type mapValue and orders the slice by the keys in the map to ensure a -// deterministic order. -func mapToSlice(m map[string]any) []mapValue { - values := make([]mapValue, 0, len(m)) - for k, v := range m { - values = append(values, mapValue{K: k, V: v}) - } - sort.Slice(values, func(i, j int) bool { - return values[i].K < values[j].K - }) - return values -} - -// mapValue represents a value in a map. It is used to convert maps to a slice and order the slice before encoding to -// NBT to ensure a deterministic output. -type mapValue struct { - K string - V any -} - -// writeEnchantments writes the enchantments of an item to a map for NBT encoding. -func writeEnchantments(m map[string]any, s item.Stack) { - if len(s.Enchantments()) != 0 { - var enchantments []map[string]any - for _, e := range s.Enchantments() { - if eType, ok := item.EnchantmentID(e.Type()); ok { - enchantments = append(enchantments, map[string]any{ - "id": int16(eType), - "lvl": int16(e.Level()), - }) - } - } - m["ench"] = enchantments - } -} - -// writeDisplay writes the display name and lore of an item to a map for NBT encoding. -func writeDisplay(m map[string]any, s item.Stack) { - name, lore := s.CustomName(), s.Lore() - v := map[string]any{} - if name != "" { - v["Name"] = name - } - if len(lore) != 0 { - v["Lore"] = lore - } - if len(v) != 0 { - m["display"] = v - } -} - -// writeDamage writes the damage to an item.Stack (either an int16 for disk or int32 for network) to a map for NBT -// encoding. -func writeDamage(m map[string]any, s item.Stack, disk bool) { - if v, ok := m["Damage"]; !ok || v.(int16) == 0 { - if _, ok := s.Item().(item.Durable); ok { - if disk { - m["Damage"] = int16(s.MaxDurability() - s.Durability()) - } else { - m["Damage"] = int32(s.MaxDurability() - s.Durability()) - } - } - } -} - -// writeAnvilCost ... -func writeAnvilCost(m map[string]any, s item.Stack) { - if cost := s.AnvilCost(); cost > 0 { - m["RepairCost"] = int32(cost) - } -} - -// writeUnbreakable writes the unbreakable tag to an item stack if it is unbreakable. -func writeUnbreakable(m map[string]any, s item.Stack) { - if s.Unbreakable() { - m["Unbreakable"] = byte(1) - } -} diff --git a/server/item/armour.go b/server/item/armour.go index 935c453ac0..17f44a5392 100644 --- a/server/item/armour.go +++ b/server/item/armour.go @@ -88,7 +88,7 @@ func (ArmourTierGold) Name() string { return "golden" } // ArmourTierChain is the ArmourTier of chain armour. type ArmourTierChain struct{} -func (ArmourTierChain) BaseDurability() float64 { return 166 } +func (ArmourTierChain) BaseDurability() float64 { return 165 } func (ArmourTierChain) Toughness() float64 { return 0 } func (ArmourTierChain) KnockBackResistance() float64 { return 0 } func (ArmourTierChain) EnchantmentValue() int { return 12 } @@ -115,7 +115,7 @@ func (ArmourTierDiamond) Name() string { return "diamond" } // ArmourTierNetherite is the ArmourTier of netherite armour. type ArmourTierNetherite struct{} -func (ArmourTierNetherite) BaseDurability() float64 { return 408 } +func (ArmourTierNetherite) BaseDurability() float64 { return 407 } func (ArmourTierNetherite) Toughness() float64 { return 3 } func (ArmourTierNetherite) KnockBackResistance() float64 { return 0.1 } func (ArmourTierNetherite) EnchantmentValue() int { return 15 } diff --git a/server/item/creative/creative_items.nbt b/server/item/creative/creative_items.nbt index ebb532f65b..d8319469be 100644 Binary files a/server/item/creative/creative_items.nbt and b/server/item/creative/creative_items.nbt differ diff --git a/server/item/crossbow.go b/server/item/crossbow.go index ed90384f43..d642087754 100644 --- a/server/item/crossbow.go +++ b/server/item/crossbow.go @@ -2,7 +2,6 @@ package item import ( "time" - _ "unsafe" "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/world" @@ -158,7 +157,7 @@ func (c Crossbow) ReleaseCharge(releaser Releaser, tx *world.Tx, ctx *UseContext // CanCharge ... func (c Crossbow) CanCharge(releaser Releaser, _ *world.Tx, ctx *UseContext) bool { _, found := c.findProjectile(releaser, ctx) - return found && !c.Item.Empty() + return found && c.Item.Empty() } // shoot fires the crossbow's loaded projectiles. @@ -187,7 +186,7 @@ func (c Crossbow) shoot(releaser Releaser, tx *world.Tx, offsetAngle float64, ar } // applyDamage applies damage on a UseContext based on the projectile loaded -// in the crossboww. +// in the crossbow. func (c Crossbow) applyDamage(ctx *UseContext) { if _, ok := c.Item.Item().(Firework); ok { ctx.DamageItem(3) @@ -226,24 +225,14 @@ func (Crossbow) EncodeItem() (name string, meta int16) { // DecodeNBT ... func (c Crossbow) DecodeNBT(data map[string]any) any { - c.Item = mapItem(data, "chargedItem") + c.Item = MapNBT(data, "chargedItem") return c } // EncodeNBT ... func (c Crossbow) EncodeNBT() map[string]any { if !c.Item.Empty() { - return map[string]any{"chargedItem": writeItem(c.Item, true)} + return map[string]any{"chargedItem": WriteNBT(c.Item, true)} } return nil } - -// noinspection ALL -// -//go:linkname writeItem github.com/df-mc/dragonfly/server/internal/nbtconv.WriteItem -func writeItem(s Stack, disk bool) map[string]any - -// noinspection ALL -// -//go:linkname mapItem github.com/df-mc/dragonfly/server/internal/nbtconv.MapItem -func mapItem(x map[string]any, k string) Stack diff --git a/server/item/enchantment/respiration.go b/server/item/enchantment/respiration.go index fb42dd833c..bc966bf933 100644 --- a/server/item/enchantment/respiration.go +++ b/server/item/enchantment/respiration.go @@ -34,7 +34,7 @@ func (respiration) Rarity() item.EnchantmentRarity { // Chance returns the chance of the enchantment blocking the air supply from ticking. func (respiration) Chance(level int) float64 { - return 1.0 / float64(level+1) + return float64(level) / float64(level+1) } // CompatibleWithEnchantment ... diff --git a/server/item/end_crystal.go b/server/item/end_crystal.go new file mode 100644 index 0000000000..e21a5614e1 --- /dev/null +++ b/server/item/end_crystal.go @@ -0,0 +1,53 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// EndCrystal is an item that can be placed on obsidian or bedrock to spawn an End crystal entity. +type EndCrystal struct{} + +// endCrystalSupport represents a block that supports an End crystal. +type endCrystalSupport interface { + SupportsEndCrystal() bool +} + +// UseOnBlock places an End crystal on top of the clicked block if it is +// obsidian or bedrock, the two blocks above it are air and no other entities +// intersect the space the crystal is placed in. The face clicked is ignored. +func (e EndCrystal) UseOnBlock(pos cube.Pos, _ cube.Face, _ mgl64.Vec3, tx *world.Tx, _ User, ctx *UseContext) bool { + support, ok := tx.Block(pos).(endCrystalSupport) + if !ok || !support.SupportsEndCrystal() { + return false + } + + above, twoAbove := pos.Side(cube.FaceUp), pos.Side(cube.FaceUp).Side(cube.FaceUp) + if above.OutOfBounds(tx.Range()) || twoAbove.OutOfBounds(tx.Range()) { + return false + } + if tx.Block(above) != air() || tx.Block(twoAbove) != air() { + return false + } + + // Vanilla anchors the entity check at the clicked block, spanning it and the + // air block above. Any intersecting entity blocks placement, including the + // placing player. + box := cube.Box(0, 0, 0, 1, 2, 1).Translate(pos.Vec3()) + for entity := range tx.EntitiesWithin(box.Grow(2)) { + if entity.H().Type().BBox(entity).Translate(entity.Position()).IntersectsWith(box) { + return false + } + } + + opts := world.EntitySpawnOpts{Position: pos.Side(cube.FaceUp).Vec3Middle()} + tx.AddEntity(tx.World().EntityRegistry().Config().EndCrystal(opts)) + ctx.SubtractFromCount(1) + return true +} + +// EncodeItem ... +func (EndCrystal) EncodeItem() (name string, meta int16) { + return "minecraft:end_crystal", 0 +} diff --git a/server/item/ender_eye.go b/server/item/ender_eye.go new file mode 100644 index 0000000000..e50eec298c --- /dev/null +++ b/server/item/ender_eye.go @@ -0,0 +1,47 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/portal" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" +) + +// EnderEye is the item used to fill End portal frames. The stronghold-locating throw is not implemented. +type EnderEye struct{} + +// EncodeItem ... +func (EnderEye) EncodeItem() (name string, meta int16) { + return "minecraft:ender_eye", 0 +} + +// MaxCount ... +func (EnderEye) MaxCount() int { + return 64 +} + +// endPortalFrame is implemented by block.EndPortalFrame, which cannot be imported here directly. +type endPortalFrame interface { + InsertEndPortalEye() (world.Block, bool) +} + +// UseOnBlock fills the targeted End portal frame with an Eye of Ender, activating the portal if this completes the +// twelve-frame ring. +func (EnderEye) UseOnBlock(pos cube.Pos, _ cube.Face, _ mgl64.Vec3, tx *world.Tx, _ User, ctx *UseContext) bool { + f, ok := tx.Block(pos).(endPortalFrame) + if !ok { + return false + } + updated, inserted := f.InsertEndPortalEye() + if !inserted { + return false + } + tx.SetBlock(pos, updated, nil) + tx.PlaySound(pos.Vec3Centre(), sound.EnderEyePlaced{}) + if portal.ActivateEndPortal(tx, pos) { + tx.PlaySound(pos.Vec3Centre(), sound.EndPortalCreated{}) + } + ctx.SubtractFromCount(1) + return true +} diff --git a/server/item/fire_charge.go b/server/item/fire_charge.go index b74653b019..7199650c40 100644 --- a/server/item/fire_charge.go +++ b/server/item/fire_charge.go @@ -3,6 +3,7 @@ package item import ( "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/portal" "github.com/df-mc/dragonfly/server/world/sound" "github.com/go-gl/mathgl/mgl64" "math/rand/v2" @@ -27,6 +28,9 @@ func (f FireCharge) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *w } else if s := pos.Side(face); tx.Block(s) == air() { ctx.SubtractFromCount(1) tx.PlaySound(s.Vec3Centre(), sound.FireCharge{}) + if portal.ActivateNetherPortal(tx, s) { + return true + } flame := fire() tx.SetBlock(s, flame, nil) diff --git a/server/item/flint_and_steel.go b/server/item/flint_and_steel.go index db8674601e..a0b2b94162 100644 --- a/server/item/flint_and_steel.go +++ b/server/item/flint_and_steel.go @@ -6,6 +6,7 @@ import ( "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/portal" "github.com/df-mc/dragonfly/server/world/sound" "github.com/go-gl/mathgl/mgl64" ) @@ -35,10 +36,14 @@ type ignitable interface { // UseOnBlock ... func (f FlintAndSteel) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user User, ctx *UseContext) bool { ctx.DamageItem(1) - if l, ok := tx.Block(pos).(ignitable); ok && l.Ignite(pos, tx, user) { - return true - } else if s := pos.Side(face); tx.Block(s) == air() { + if l, ok := tx.Block(pos).(ignitable); ok { + return l.Ignite(pos, tx, user) + } + if s := pos.Side(face); tx.Block(s) == air() { tx.PlaySound(s.Vec3Centre(), sound.Ignite{}) + if portal.ActivateNetherPortal(tx, s) { + return true + } flame := fire() tx.SetBlock(s, flame, nil) diff --git a/server/item/goat_horn.go b/server/item/goat_horn.go index 2a6e1759cc..41af9b694e 100644 --- a/server/item/goat_horn.go +++ b/server/item/goat_horn.go @@ -28,9 +28,7 @@ func (GoatHorn) Cooldown() time.Duration { // Use ... func (g GoatHorn) Use(tx *world.Tx, user User, _ *UseContext) bool { tx.PlaySound(user.Position(), sound.GoatHorn{Horn: g.Type}) - time.AfterFunc(time.Second, func() { - user.H().ExecWorld(g.releaseItem) - }) + user.H().DoAfter(time.Second, g.releaseItem) return true } diff --git a/server/item/leggings.go b/server/item/leggings.go index eea46a4249..c766f71bbf 100644 --- a/server/item/leggings.go +++ b/server/item/leggings.go @@ -65,7 +65,7 @@ func (l Leggings) Leggings() bool { // DurabilityInfo ... func (l Leggings) DurabilityInfo() DurabilityInfo { return DurabilityInfo{ - MaxDurability: int(l.Tier.BaseDurability() + l.Tier.BaseDurability()/2.5), + MaxDurability: int(l.Tier.BaseDurability() + l.Tier.BaseDurability()/2.75), BrokenItem: simpleItem(Stack{}), } } diff --git a/server/item/nbt.go b/server/item/nbt.go new file mode 100644 index 0000000000..2fd6c3cf4a --- /dev/null +++ b/server/item/nbt.go @@ -0,0 +1,337 @@ +package item + +import ( + "bytes" + "encoding/gob" + "sort" + + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/chunk" +) + +// WriteNBT encodes a Stack into a map that can be encoded using NBT. disk +// selects the disk format over the network one, which differ in how an item's +// damage is written and in whether the stack is wrapped with its name and count. +func WriteNBT(s Stack, disk bool) map[string]any { + tag := make(map[string]any) + if s.Empty() { + return tag + } + if nbt, ok := s.Item().(world.NBTer); ok { + for k, v := range nbt.EncodeNBT() { + tag[k] = v + } + } + writeAnvilCost(tag, s) + writeDamage(tag, s, disk) + writeDisplay(tag, s) + writeDragonflyData(tag, s) + writeEnchantments(tag, s) + writeUnbreakable(tag, s) + + data := make(map[string]any) + if disk { + writeItemStack(data, tag, s) + } else { + for k, v := range tag { + data[k] = v + } + } + return data +} + +// ReadNBT decodes the data of an item into an item stack. A nil s selects the +// disk format, in which the item's name and count are read from data itself; +// otherwise the tags are applied to the stack passed. +func ReadNBT(data map[string]any, s *Stack) Stack { + disk, tag := s == nil, data + if disk { + t, ok := data["tag"].(map[string]any) + if !ok { + t = map[string]any{} + } + tag = t + + a := readItemStack(data, tag) + s = &a + } + + readAnvilCost(tag, s) + readDamage(tag, s, disk) + readDisplay(tag, s) + readDragonflyData(tag, s) + readEnchantments(tag, s) + readUnbreakable(tag, s) + return *s +} + +// MapNBT converts an item's name, count, damage (and properties when it is a +// block) in a map obtained by decoding NBT at key k to a Stack. +func MapNBT(x map[string]any, k string) Stack { + if m, ok := x[k].(map[string]any); ok { + return ReadNBT(m, nil) + } + return Stack{} +} + +// writeItemStack writes the name, metadata value, count and NBT of an item to a map ready for NBT encoding. +func writeItemStack(m, t map[string]any, s Stack) { + m["Name"], m["Damage"] = s.Item().EncodeItem() + if b, ok := s.Item().(world.Block); ok { + v := map[string]any{} + writeBlock(v, b) + m["Block"] = v + } + m["Count"] = byte(s.Count()) + if len(t) > 0 { + m["tag"] = t + } +} + +// writeBlock writes the name, properties and version of a block to a map ready for NBT encoding. +func writeBlock(m map[string]any, b world.Block) { + m["name"], m["states"] = b.EncodeBlock() + m["version"] = chunk.CurrentBlockVersion +} + +// writeDragonflyData writes additional data associated with a Stack to a map for NBT encoding. +func writeDragonflyData(m map[string]any, s Stack) { + if v := s.Values(); len(v) != 0 { + buf := new(bytes.Buffer) + if err := gob.NewEncoder(buf).Encode(mapToSlice(v)); err != nil { + panic("error encoding item user data: " + err.Error()) + } + m["dragonflyData"] = buf.Bytes() + } +} + +// mapToSlice converts a map to a slice of the type mapValue and orders the slice by the keys in the map to ensure a +// deterministic order. +func mapToSlice(m map[string]any) []mapValue { + values := make([]mapValue, 0, len(m)) + for k, v := range m { + values = append(values, mapValue{K: k, V: v}) + } + sort.Slice(values, func(i, j int) bool { + return values[i].K < values[j].K + }) + return values +} + +// mapValue represents a value in a map. It is used to convert maps to a slice and order the slice before encoding to +// NBT to ensure a deterministic output. +// +// Its name and field names are part of the on-disk format: gob identifies the +// type by name, so worlds written before this moved package still decode. +type mapValue struct { + K string + V any +} + +// writeEnchantments writes the enchantments of an item to a map for NBT encoding. +func writeEnchantments(m map[string]any, s Stack) { + if len(s.Enchantments()) != 0 { + var enchantments []map[string]any + for _, e := range s.Enchantments() { + if eType, ok := EnchantmentID(e.Type()); ok { + enchantments = append(enchantments, map[string]any{ + "id": int16(eType), + "lvl": int16(e.Level()), + }) + } + } + m["ench"] = enchantments + } +} + +// writeDisplay writes the display name and lore of an item to a map for NBT encoding. +func writeDisplay(m map[string]any, s Stack) { + name, lore := s.CustomName(), s.Lore() + v := map[string]any{} + if name != "" { + v["Name"] = name + } + if len(lore) != 0 { + v["Lore"] = lore + } + if len(v) != 0 { + m["display"] = v + } +} + +// writeDamage writes the damage to a Stack (either an int16 for disk or int32 for network) to a map for NBT +// encoding. +func writeDamage(m map[string]any, s Stack, disk bool) { + if v, ok := m["Damage"]; !ok || v.(int16) == 0 { + if _, ok := s.Item().(Durable); ok { + if disk { + m["Damage"] = int16(s.MaxDurability() - s.Durability()) + } else { + m["Damage"] = int32(s.MaxDurability() - s.Durability()) + } + } + } +} + +// writeAnvilCost ... +func writeAnvilCost(m map[string]any, s Stack) { + if cost := s.AnvilCost(); cost > 0 { + m["RepairCost"] = int32(cost) + } +} + +// writeUnbreakable writes the unbreakable tag to an item stack if it is unbreakable. +func writeUnbreakable(m map[string]any, s Stack) { + if s.Unbreakable() { + m["Unbreakable"] = byte(1) + } +} + +// readItemStack reads a Stack from the NBT in the map passed. +func readItemStack(m, t map[string]any) Stack { + var it world.Item + if blockItem, ok := nbtBlock(m, "Block").(world.Item); ok { + it = blockItem + } + if v, ok := world.ItemByName(nbtString(m, "Name"), nbtInt16(m, "Damage")); ok { + it = v + } + if it == nil { + return Stack{} + } + if n, ok := it.(world.NBTer); ok { + it = n.DecodeNBT(t).(world.Item) + } + return NewStack(it, int(nbtUint8(m, "Count"))) +} + +// readDamage reads the damage value stored in the NBT with the Damage tag and saves it to the Stack passed. +func readDamage(m map[string]any, s *Stack, disk bool) { + if disk { + *s = s.Damage(int(nbtInt16(m, "Damage"))) + return + } + *s = s.Damage(int(nbtInt32(m, "Damage"))) +} + +// readAnvilCost ... +func readAnvilCost(m map[string]any, s *Stack) { + *s = s.WithAnvilCost(int(nbtInt32(m, "RepairCost"))) +} + +// readEnchantments reads the enchantments stored in the ench tag of the NBT passed and stores it into a Stack. +func readEnchantments(m map[string]any, s *Stack) { + enchantments, ok := m["ench"].([]map[string]any) + if !ok { + for _, e := range nbtSlice(m, "ench") { + if v, ok := e.(map[string]any); ok { + enchantments = append(enchantments, v) + } + } + } + for _, ench := range enchantments { + if t, ok := EnchantmentByID(int(nbtInt16(ench, "id"))); ok { + *s = s.WithForcedEnchantments(NewEnchantment(t, int(nbtInt16(ench, "lvl")))) + } + } +} + +// readDisplay reads the display data present in the display field in the NBT. It includes a custom name of the item +// and the lore. +func readDisplay(m map[string]any, s *Stack) { + if display, ok := m["display"].(map[string]any); ok { + if name, ok := display["Name"].(string); ok { + // Only add the custom name if actually set. + *s = s.WithCustomName(name) + } + if lore, ok := display["Lore"].([]string); ok { + *s = s.WithLore(lore...) + } else if lore, ok := display["Lore"].([]any); ok { + loreLines := make([]string, 0, len(lore)) + for _, l := range lore { + loreLines = append(loreLines, l.(string)) + } + *s = s.WithLore(loreLines...) + } + } +} + +// readDragonflyData reads data written to the dragonflyData field in the NBT of an item and adds it to the Stack +// passed. +func readDragonflyData(m map[string]any, s *Stack) { + if customData, ok := m["dragonflyData"]; ok { + d, ok := customData.([]byte) + if !ok { + if itf, ok := customData.([]any); ok { + for _, v := range itf { + b, _ := v.(byte) + d = append(d, b) + } + } + } + var values []mapValue + if err := gob.NewDecoder(bytes.NewBuffer(d)).Decode(&values); err != nil { + panic("error decoding item user data: " + err.Error()) + } + for _, val := range values { + *s = s.WithValue(val.K, val.V) + } + } +} + +// readUnbreakable reads the unbreakable value stored in the NBT with the Unbreakable tag and saves it to the Stack +// passed. +func readUnbreakable(m map[string]any, s *Stack) { + if nbtBool(m, "Unbreakable") { + *s = s.AsUnbreakable() + } +} + +// The readers below mirror the exported helpers in server/internal/nbtconv. +// They are duplicated rather than imported because nbtconv depends on this +// package: the item codec has to live here for the dependency to point the +// right way, and these are the only helpers it needs. + +// nbtBool reads a uint8 value from a map at key k and returns true if it equals 1. +func nbtBool(m map[string]any, k string) bool { return nbtUint8(m, k) == 1 } + +// nbtUint8 reads a uint8 value from a map at key k. +func nbtUint8(m map[string]any, k string) uint8 { + v, _ := m[k].(uint8) + return v +} + +// nbtString reads a string value from a map at key k. +func nbtString(m map[string]any, k string) string { + v, _ := m[k].(string) + return v +} + +// nbtInt16 reads an int16 value from a map at key k. +func nbtInt16(m map[string]any, k string) int16 { + v, _ := m[k].(int16) + return v +} + +// nbtInt32 reads an int32 value from a map at key k. +func nbtInt32(m map[string]any, k string) int32 { + v, _ := m[k].(int32) + return v +} + +// nbtSlice reads a []any value from a map at key k. +func nbtSlice(m map[string]any, k string) []any { + v, _ := m[k].([]any) + return v +} + +// nbtBlock decodes the data of a block in a map at key k into a world.Block. +func nbtBlock(m map[string]any, k string) world.Block { + if mk, ok := m[k].(map[string]any); ok { + name, _ := mk["name"].(string) + properties, _ := mk["states"].(map[string]any) + b, _ := world.BlockByName(name, properties) + return b + } + return nil +} diff --git a/server/item/optional_colour.go b/server/item/optional_colour.go new file mode 100644 index 0000000000..5374b40a8e --- /dev/null +++ b/server/item/optional_colour.go @@ -0,0 +1,47 @@ +package item + +// OptionalColour represents a Colour that may be absent. It is used by items +// and blocks that have one variant per Colour plus an additional uncoloured +// variant (e.g. shulker boxes, where the uncoloured form is encoded as +// minecraft:undyed_shulker_box). A zero OptionalColour denotes the absent +// case; values 1..16 map to the 16 dye Colours. +type OptionalColour uint8 + +// colours is a slice of all Colours. +var colours = Colours() + +// NewOptionalColour returns a new OptionalColour from a Colour. +func NewOptionalColour(c Colour) OptionalColour { + return OptionalColour(c.Uint8() + 1) +} + +// Colour returns the Colour of the OptionalColour, and a bool indicating +// whether the Colour is present. +func (oc OptionalColour) Colour() (Colour, bool) { + if oc == 0 { + return Colour{}, false + } + return colours[(oc - 1)], true +} + +// Uint8 returns the OptionalColour as a uint8. +func (oc OptionalColour) Uint8() uint8 { + return uint8(oc) +} + +// Prepend prepends the Colour to the string if the Colour is present. +func (oc OptionalColour) Prepend(str string) string { + if oc != 0 { + return colours[(oc-1)].String() + "_" + str + } + return str +} + +// OptionalColours returns a slice of all OptionalColours, including the absent case. +func OptionalColours() []OptionalColour { + optionalColours := make([]OptionalColour, 17) + for i, c := range colours { + optionalColours[i+1] = NewOptionalColour(c) + } + return optionalColours +} diff --git a/server/item/recipe/crafting_data.nbt b/server/item/recipe/crafting_data.nbt index 350fc0c124..5ee024e956 100644 Binary files a/server/item/recipe/crafting_data.nbt and b/server/item/recipe/crafting_data.nbt differ diff --git a/server/item/recipe/item.go b/server/item/recipe/item.go index d4f2e264cd..805cdd3bfb 100644 --- a/server/item/recipe/item.go +++ b/server/item/recipe/item.go @@ -7,7 +7,7 @@ import ( "github.com/df-mc/dragonfly/server/world" ) -// Item represents an item that can be used as either the input or output of an item. These do not +// Item represents an item that can be used as either the input or output of a recipe. It does not // necessarily resolve to an actual item, but can be just as simple as a tag etc. type Item interface { // Count returns the amount of items that is present on the stack. The count is guaranteed never to be @@ -17,25 +17,29 @@ type Item interface { Empty() bool } -// inputItem is a type representing an input item, with a helper function to convert it to an Item. +// blockState is the exact encoded state of a block, included with input and output items that are blocks. +type blockState struct { + Name string `nbt:"name"` + Properties map[string]any `nbt:"states"` + Version int32 `nbt:"version"` +} + +// inputItem is an input item as present in the recipe data, convertible to an [Item]. type inputItem struct { // Name is the name of the item being inputted. Name string `nbt:"name"` - // Meta is the meta of the item. This can change the item almost completely, or act as durability. + // Meta may change the item almost completely, or act as durability. A value of math.MaxInt16 means any + // meta matches. Meta int32 `nbt:"meta"` // Count is the amount of the item. Count int32 `nbt:"count"` - // State is included if the output is a block. If it's not included, the meta can be discarded and the output item can be incorrect. - State struct { - Name string `nbt:"name"` - Properties map[string]interface{} `nbt:"states"` - Version int32 `nbt:"version"` - } `nbt:"block"` - // Tag is included if the input item is defined by a tag instead of a specific item. + // State is included if the input is a block. + State blockState `nbt:"block"` + // Tag is set if the input is defined by an item tag instead of a specific item. Tag string `nbt:"tag"` } -// Item converts an input item to a recipe item. +// Item converts an input item to a recipe [Item]. func (i inputItem) Item() (Item, bool) { if i.Tag != "" { return NewItemTag(i.Tag, int(i.Count)), true @@ -53,10 +57,10 @@ func (i inputItem) Item() (Item, bool) { return st, true } -// inputItems is a type representing a list of input items, with a helper function to convert it to an Item. +// inputItems is a list of input items, where each is convertible to an [Item]. type inputItems []inputItem -// Items converts input items to recipe items. +// Items converts each input item to an [Item]. func (d inputItems) Items() ([]Item, bool) { s := make([]Item, 0, len(d)) for _, i := range d { @@ -69,41 +73,49 @@ func (d inputItems) Items() ([]Item, bool) { return s, true } -// outputItem is an output item. +// outputItem is an output item as present in the recipe data, convertible to an [item.Stack]. type outputItem struct { // Name is the name of the item being output. Name string `nbt:"name"` - // Meta is the meta of the item. This can change the item almost completely, or act as durability. + // Meta may change the item almost completely, or act as durability. Meta int32 `nbt:"meta"` // Count is the amount of the item. Count int16 `nbt:"count"` - // State is included if the output is a block. If it's not included, the meta can be discarded and the output item can be incorrect. - State struct { - Name string `nbt:"name"` - Properties map[string]interface{} `nbt:"states"` - Version int32 `nbt:"version"` - } `nbt:"block"` - // NBTData contains extra NBTData which may modify the item in other, more discreet ways. - NBTData map[string]interface{} `nbt:"data"` + // State holds the exact block state of the output if it is a block. + State blockState `nbt:"block"` + // NBTData contains extra NBT which may modify the item in other, more discreet ways. + NBTData map[string]any `nbt:"data"` } -// Stack converts an output item to an item stack. +// Stack converts an output item to an [item.Stack]. func (o outputItem) Stack() (item.Stack, bool) { - it, ok := world.ItemByName(o.Name, int16(o.Meta)) + it, ok := o.item() if !ok { return item.Stack{}, false } - if n, ok := it.(world.NBTer); ok { + if n, ok := it.(world.NBTer); ok && len(o.NBTData) > 0 { it = n.DecodeNBT(o.NBTData).(world.Item) } return item.NewStack(it, int(o.Count)), true } -// outputItems is an array of output items. +// item resolves the [world.Item] of an output item. +func (o outputItem) item() (world.Item, bool) { + if o.State.Name != "" { + if b, ok := world.BlockByName(o.State.Name, o.State.Properties); ok { + if it, ok := b.(world.Item); ok { + return it, true + } + } + } + return world.ItemByName(o.Name, int16(o.Meta)) +} + +// outputItems is a list of output items, where each is convertible to an [item.Stack]. type outputItems []outputItem -// Stacks converts output items to item stacks. +// Stacks converts each output item to an [item.Stack]. func (d outputItems) Stacks() ([]item.Stack, bool) { s := make([]item.Stack, 0, len(d)) for _, o := range d { diff --git a/server/item/recipe/potion_data.nbt b/server/item/recipe/potion_data.nbt index 3b9f707eb7..465959b198 100644 Binary files a/server/item/recipe/potion_data.nbt and b/server/item/recipe/potion_data.nbt differ diff --git a/server/item/recipe/recipe.go b/server/item/recipe/recipe.go index 63d5b3fab6..053d99cde9 100644 --- a/server/item/recipe/recipe.go +++ b/server/item/recipe/recipe.go @@ -3,6 +3,7 @@ package recipe import ( "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/world" + "github.com/google/uuid" ) // Recipe is implemented by all recipe types. @@ -44,6 +45,44 @@ func NewShapeless(input []Item, output item.Stack, block string) Shapeless { }} } +// UserDataShapeless is a shapeless recipe crafting items that must retain their user data, such as their +// contents or colour, when crafted. Vanilla uses these recipes for dyeing items such as shulker boxes, +// bundles and harnesses. +type UserDataShapeless struct { + recipe +} + +// NewUserDataShapeless creates a new user data shapeless recipe and returns it. The recipe can only be +// crafted on the block passed in the parameters. If the block given is a crafting table, the recipe can +// also be crafted in the 2x2 crafting grid in the player's inventory. +func NewUserDataShapeless(input []Item, output item.Stack, block string) UserDataShapeless { + return UserDataShapeless{recipe: recipe{ + input: input, + output: []item.Stack{output}, + block: block, + }} +} + +// Multi is a special recipe with behaviour hardcoded in the vanilla client, such as map cloning/extending, +// book cloning, banner duplication and firework crafting. It has no inputs or outputs: It is identified only +// by its UUID, and sending it enables the behaviour of that recipe client-side. +type Multi struct { + recipe + uuid uuid.UUID +} + +// NewMulti creates a new multi recipe from the UUID passed and returns it. The UUID must be one of the UUIDs +// hardcoded in the vanilla client for the recipe to have any effect. +func NewMulti(id uuid.UUID) Multi { + return Multi{uuid: id, recipe: recipe{block: "crafting_table"}} +} + +// UUID returns the UUID of the recipe, which identifies the hardcoded behaviour of the recipe in the vanilla +// client. +func (m Multi) UUID() uuid.UUID { + return m.uuid +} + // SmithingTransform represents a recipe only craftable on a smithing table. type SmithingTransform struct { recipe diff --git a/server/item/recipe/vanilla.go b/server/item/recipe/vanilla.go index 0b1af9f3df..a088833dee 100644 --- a/server/item/recipe/vanilla.go +++ b/server/item/recipe/vanilla.go @@ -5,6 +5,7 @@ import ( "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/world" + "github.com/google/uuid" "github.com/sandertv/gophertunnel/minecraft/nbt" ) @@ -57,13 +58,38 @@ type potionContainerChangeRecipe struct { //lint:ignore U1000 Function is used through compiler directives. func registerVanilla() { var craftingRecipes struct { - Shaped []shapedRecipe `nbt:"shaped"` - Shapeless []shapelessRecipe `nbt:"shapeless"` + Shaped []shapedRecipe `nbt:"shaped"` + Shapeless []shapelessRecipe `nbt:"shapeless"` + UserDataShapeless []shapelessRecipe `nbt:"shulker_box"` + Multi []string `nbt:"multi"` } if err := nbt.Unmarshal(vanillaCraftingData, &craftingRecipes); err != nil { panic(err) } + for _, id := range craftingRecipes.Multi { + u, err := uuid.Parse(id) + if err != nil { + continue + } + Register(NewMulti(u)) + } + + for _, s := range craftingRecipes.UserDataShapeless { + input, ok := s.Input.Items() + output, okTwo := s.Output.Stacks() + if !ok || !okTwo { + // This can be expected to happen, as some recipes contain blocks or items that aren't currently implemented. + continue + } + Register(UserDataShapeless{recipe{ + input: input, + output: output, + block: s.Block, + priority: uint32(s.Priority), + }}) + } + for _, s := range craftingRecipes.Shapeless { input, ok := s.Input.Items() output, okTwo := s.Output.Stacks() diff --git a/server/item/register.go b/server/item/register.go index 786af85753..07b7b075d4 100644 --- a/server/item/register.go +++ b/server/item/register.go @@ -52,6 +52,8 @@ func init() { world.RegisterItem(Emerald{}) world.RegisterItem(EnchantedApple{}) world.RegisterItem(EnchantedBook{}) + world.RegisterItem(EndCrystal{}) + world.RegisterItem(EnderEye{}) world.RegisterItem(EnderPearl{}) world.RegisterItem(Feather{}) world.RegisterItem(FermentedSpiderEye{}) diff --git a/server/listener.go b/server/listener.go index 8967ab7c7c..5286f3aa27 100644 --- a/server/listener.go +++ b/server/listener.go @@ -31,6 +31,7 @@ func (uc UserConfig) listenerFunc(conf Config) (Listener, error) { ResourcePacks: conf.Resources, TexturePacksRequired: conf.ResourcesRequired, Compression: conf.Compression, + Allow: conf.Allower.Allow, } if conf.Log.Enabled(context.Background(), slog.LevelDebug) { cfg.ErrorLog = conf.Log.With("net origin", "gophertunnel") diff --git a/server/player/conf.go b/server/player/conf.go index 194156c0d9..8f2ee9af27 100644 --- a/server/player/conf.go +++ b/server/player/conf.go @@ -1,6 +1,10 @@ package player import ( + "math/rand/v2" + "time" + + "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/entity/effect" @@ -11,8 +15,6 @@ import ( "github.com/go-gl/mathgl/mgl64" "github.com/google/uuid" "golang.org/x/text/language" - "math/rand/v2" - "time" ) // Config holds options that a Player can be created with. @@ -82,9 +84,33 @@ func (cfg Config) Apply(data *world.EntityData) { maxAirSupplyTicks: conf.MaxAirSupply, breathing: true, nameTag: conf.Name, + alwaysShowNameTag: true, fireTicks: conf.FireTicks, fallDistance: conf.FallDistance, } + playerUUID := conf.UUID + pdata.portalTravel = &entity.PortalTravelComputer{ + Instantaneous: func(_, target world.Dimension) bool { + // End travel is always instant regardless of game mode; End portals target the End in either direction. + return pdata.gameMode.InstantPortalTravel() || target == world.End + }, + Teleport: func(e entity.Traveller, pos mgl64.Vec3) { + e.(*Player).forceTeleport(pos) + }, + SpawnPoint: func(tx *world.Tx) mgl64.Vec3 { + // Use the player's spawn only while its bed still exists and is unobstructed, like respawning. + pos := tx.World().PlayerSpawn(playerUUID) + if b, ok := tx.Block(pos).(block.Bed); ok && b.CanRespawnOn() { + if safe, ok := b.SafeSpawn(pos, tx); ok { + return safe.Vec3Middle() + } + } + return tx.World().Spawn().Vec3Middle() + }, + Player: true, + // Only players create a portal at the destination when no linked portal exists. + CreatePortal: true, + } pdata.hunger.foodLevel, pdata.hunger.foodTick, pdata.hunger.exhaustionLevel, pdata.hunger.saturationLevel = conf.Food, conf.FoodTick, conf.Exhaustion, conf.Saturation pdata.experience.Add(conf.Experience) data.Data = pdata diff --git a/server/player/context.go b/server/player/context.go new file mode 100644 index 0000000000..5a388aaad4 --- /dev/null +++ b/server/player/context.go @@ -0,0 +1,52 @@ +package player + +import ( + "github.com/df-mc/dragonfly/server/world" +) + +// Context is the context passed to player event callbacks. It embeds the +// world Context, so world operations and Cancel are available directly, and +// adds the Player the event concerns. It is valid only during the callback. +type Context struct { + *world.Context + p *Player +} + +// NewEventContext returns a fresh event context for p. +// tx and p must come from the same active owner callback. +func NewEventContext(tx *world.Tx, p *Player) *Context { + if tx == nil || p == nil || p.tx != tx { + panic("player: transaction and player do not belong to the same callback") + } + _ = tx.World() // Fail immediately if tx has already finished. + return &Context{Context: tx.Event(), p: p} +} + +// Player returns the player the event concerns, valid only during the +// callback. +func (ctx *Context) Player() *Player { return ctx.p } + +// Defer schedules f to run on the owner after the current callback completes, +// with the player re-resolved for that moment. The task fails with +// world.ErrEntityClosed if the player's handle closed, or with +// world.ErrEntityNotInWorld if the player left this transaction's world. +func (ctx *Context) Defer(f func(ctx *Context)) *world.Task { + return ctx.DeferErr(func(ctx *Context) error { + f(ctx) + return nil + }) +} + +// DeferErr schedules f like Defer and records its returned error on the Task. +func (ctx *Context) DeferErr(f func(ctx *Context) error) *world.Task { + h := ctx.p.H() + return ctx.Context.DeferErr(func(tx *world.Tx) error { + if e, ok := h.Entity(tx); ok { + return f(NewEventContext(tx, e.(*Player))) + } + if h.Closed() { + return world.ErrEntityClosed + } + return world.ErrEntityNotInWorld + }) +} diff --git a/server/player/handler.go b/server/player/handler.go index ada7a65461..17ecb573bf 100644 --- a/server/player/handler.go +++ b/server/player/handler.go @@ -6,7 +6,6 @@ import ( "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/cmd" - "github.com/df-mc/dragonfly/server/event" "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/player/skin" "github.com/df-mc/dragonfly/server/session" @@ -14,8 +13,6 @@ import ( "github.com/go-gl/mathgl/mgl64" ) -type Context = event.Context[*Player] - // Handler handles events that are called by a player. Implementations of Handler may be used to listen to // specific events such as when a player chats or moves. type Handler interface { @@ -53,6 +50,10 @@ type Handler interface { // the original cause of the immunity frame. In this case, the damage is // reduced but the player is still knocked back. HandleHurt(ctx *Context, damage *float64, immune bool, attackImmunity *time.Duration, src world.DamageSource) + // HandleSetOnFire handles the player being set on fire by any source. + // The fire duration passed is after fire protection modifiers and may be changed + // by assigning to *duration. + HandleSetOnFire(ctx *Context, duration *time.Duration) // HandleDeath handles the player dying to a particular damage cause. HandleDeath(p *Player, src world.DamageSource, keepInv *bool) // HandleRespawn handles the respawning of the player in the world. The spawn position passed may be @@ -194,6 +195,7 @@ func (NopHandler) HandleAttackEntity(*Context, world.Entity, *float64, *float64, func (NopHandler) HandleExperienceGain(*Context, *int) {} func (NopHandler) HandlePunchAir(*Context) {} func (NopHandler) HandleHurt(*Context, *float64, bool, *time.Duration, world.DamageSource) {} +func (NopHandler) HandleSetOnFire(*Context, *time.Duration) {} func (NopHandler) HandleHeal(*Context, *float64, world.HealingSource) {} func (NopHandler) HandleFoodLoss(*Context, int, *int) {} func (NopHandler) HandleDeath(*Player, world.DamageSource, *bool) {} diff --git a/server/player/input/lock.go b/server/player/input/lock.go new file mode 100644 index 0000000000..c877f115f6 --- /dev/null +++ b/server/player/input/lock.go @@ -0,0 +1,79 @@ +package input + +import "github.com/sandertv/gophertunnel/minecraft/protocol/packet" + +// Lock represents a client input lock that can be applied to a player to disable specific inputs such as +// camera rotation, movement, jumping, sneaking or mounting/dismounting entities. +type Lock struct { + lock +} + +type lock uint32 + +// Camera is the lock that disables all camera movement. +func Camera() Lock { + return Lock{lock(packet.ClientInputLockCamera)} +} + +// Movement is the lock that disables all player movement, including jumping and sneaking. +func Movement() Lock { + return Lock{lock(packet.ClientInputLockMovement)} +} + +// LateralMovement is the lock that disables all player movement excluding jumping and sneaking. +func LateralMovement() Lock { + return Lock{lock(packet.ClientInputLockLateralMovement)} +} + +// Sneak is the lock that disables the player from sneaking. +func Sneak() Lock { + return Lock{lock(packet.ClientInputLockSneak)} +} + +// Jump is the lock that disables the player from jumping. +func Jump() Lock { + return Lock{lock(packet.ClientInputLockJump)} +} + +// Mount is the lock that prevents the player from mounting entities. +func Mount() Lock { + return Lock{lock(packet.ClientInputLockMount)} +} + +// Dismount is the lock that prevents the player from dismounting entities. +func Dismount() Lock { + return Lock{lock(packet.ClientInputLockDismount)} +} + +// MoveForward is the lock that disables forward movement. +func MoveForward() Lock { + return Lock{lock(packet.ClientInputLockMoveForward)} +} + +// MoveBackward is the lock that disables backward movement. +func MoveBackward() Lock { + return Lock{lock(packet.ClientInputLockMoveBackward)} +} + +// MoveLeft is the lock that disables left strafe movement. +func MoveLeft() Lock { + return Lock{lock(packet.ClientInputLockMoveLeft)} +} + +// MoveRight is the lock that disables right strafe movement. +func MoveRight() Lock { + return Lock{lock(packet.ClientInputLockMoveRight)} +} + +// Uint32 returns the lock as a uint32. +func (l lock) Uint32() uint32 { + return uint32(l) +} + +// All returns all the input locks that are available to be applied to a player. +func All() []Lock { + return []Lock{ + Camera(), Movement(), LateralMovement(), Sneak(), Jump(), Mount(), Dismount(), + MoveForward(), MoveBackward(), MoveLeft(), MoveRight(), + } +} diff --git a/server/player/input/restricter.go b/server/player/input/restricter.go new file mode 100644 index 0000000000..be5e9b8477 --- /dev/null +++ b/server/player/input/restricter.go @@ -0,0 +1,13 @@ +package input + +// Restricter represents an interface that can manage input locks for a player. +type Restricter interface { + // LockInput applies an input lock to the player, disabling the specified input. + LockInput(l Lock) + // UnlockInput removes an input lock from the player, re-enabling the specified input. + UnlockInput(l Lock) + // ClearInputLocks removes all input locks from the player, re-enabling all inputs. + ClearInputLocks() + // InputLocked checks if a specific input lock is currently applied to the player. + InputLocked(l Lock) bool +} diff --git a/server/player/player.go b/server/player/player.go index 375d89b58a..4637be2e5d 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1,6 +1,7 @@ package player import ( + "errors" "fmt" "math" "math/rand/v2" @@ -10,9 +11,6 @@ import ( "sync" "time" - "github.com/df-mc/dragonfly/server/player/debug" - "github.com/df-mc/dragonfly/server/player/hud" - "github.com/df-mc/dragonfly/server/block" "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/block/model" @@ -25,8 +23,11 @@ import ( "github.com/df-mc/dragonfly/server/item/inventory" "github.com/df-mc/dragonfly/server/player/bossbar" "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/input" "github.com/df-mc/dragonfly/server/player/scoreboard" "github.com/df-mc/dragonfly/server/player/skin" "github.com/df-mc/dragonfly/server/player/title" @@ -43,6 +44,7 @@ type playerData struct { xuid string locale language.Tag nameTag, scoreTag string + alwaysShowNameTag bool absorptionHealth float64 scale float64 @@ -91,7 +93,8 @@ type playerData struct { enchantSeed int64 - mc *entity.MovementComputer + mc *entity.MovementComputer + portalTravel *entity.PortalTravelComputer collidedVertically, collidedHorizontally bool @@ -196,7 +199,7 @@ func (p *Player) Skin() skin.Skin { // SetSkin changes the skin of the player. This skin will be visible to other players that the player // is shown to. func (p *Player) SetSkin(skin skin.Skin) { - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleSkinChange(ctx, &skin); ctx.Cancelled() { p.session().ViewSkin(p) return @@ -323,7 +326,7 @@ func (p *Player) RemoveBossBar() { // player and is formatted following the rules of fmt.Sprintln. func (p *Player) Chat(msg ...any) { message := format(msg) - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleChat(ctx, &message); ctx.Cancelled() { return } @@ -351,7 +354,7 @@ func (p *Player) ExecuteCommand(commandLine string) { p.SendCommandOutput(o) return } - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleCommandExecution(ctx, command, args[1:]); ctx.Cancelled() { return } @@ -366,7 +369,7 @@ func (p *Player) Transfer(address string) error { return err } - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleTransfer(ctx, addr); ctx.Cancelled() { return nil } @@ -441,6 +444,19 @@ func (p *Player) NameTag() string { return p.nameTag } +// SetAlwaysShowNameTag changes whether the name tag of the player is shown at all distances instead of only +// when the player is looked at from up close. By default, the name tag is always shown. +func (p *Player) SetAlwaysShowNameTag(alwaysShow bool) { + p.alwaysShowNameTag = alwaysShow + p.updateState() +} + +// AlwaysShowNameTag returns whether the name tag of the player is shown at all distances. It can be changed +// using SetAlwaysShowNameTag. +func (p *Player) AlwaysShowNameTag() bool { + return p.alwaysShowNameTag +} + // SetScoreTag changes the score tag displayed over the player in-game. The score tag is displayed under the player's // name tag. func (p *Player) SetScoreTag(a ...any) { @@ -524,26 +540,30 @@ func (p *Player) addHealth(health float64) { // the entity healed by having a full food bar. If the health added to the // original health exceeds the entity's max health, Heal will not add the full // amount. If the health passed is negative, Heal will not do anything. -func (p *Player) Heal(health float64, source world.HealingSource) { +// Heal returns the amount of health regenerated. +func (p *Player) Heal(health float64, source world.HealingSource) float64 { if p.Dead() || health < 0 || !p.GameMode().AllowsTakingDamage() { - return + return 0 } - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleHeal(ctx, &health, source); ctx.Cancelled() { - return + return 0 } + oldHealth := p.Health() p.addHealth(health) + return p.Health() - oldHealth } // updateFallState is called to update the entities falling state. func (p *Player) updateFallState(distanceThisTick float64) { switch { case p.OnGround(): - if p.fallDistance > 0 { + p.fallDistance -= distanceThisTick + if p.fallDistance > 3 { p.fall(p.fallDistance) - p.ResetFallDistance() } - case distanceThisTick < p.fallDistance: + p.ResetFallDistance() + case distanceThisTick < 0 && distanceThisTick < p.fallDistance: p.fallDistance -= distanceThisTick default: p.ResetFallDistance() @@ -552,15 +572,8 @@ func (p *Player) updateFallState(distanceThisTick float64) { // fall is called when a falling entity hits the ground. func (p *Player) fall(distance float64) { - pos := cube.PosFromVec3(p.Position()) - b := p.tx.Block(pos) - - if len(b.Model().BBox(pos, p.tx)) == 0 { - pos = pos.Sub(cube.Pos{0, 1}) - b = p.tx.Block(pos) - } - if h, ok := b.(block.EntityLander); ok { - h.EntityLand(pos, p.tx, p, &distance) + if pos, lander, ok := p.landedOn(); ok { + lander.EntityLand(pos, p.tx, p, &distance) } dmg := distance - 3 if boost, ok := p.Effect(effect.JumpBoost); ok { @@ -572,6 +585,35 @@ func (p *Player) fall(distance float64) { p.Hurt(math.Ceil(dmg), entity.FallDamageSource{}) } +// landedOn returns the first block.EntityLander the Player came to rest on, along with its position. +func (p *Player) landedOn() (cube.Pos, block.EntityLander, bool) { + low, high := p.blocksUnder() + for x := low[0]; x <= high[0]; x++ { + for z := low[2]; z <= high[2]; z++ { + pos := cube.Pos{x, low[1], z} + if lander, ok := p.tx.Block(pos).(block.EntityLander); ok { + return pos, lander, true + } + } + } + return cube.Pos{}, nil, false +} + +// blocksUnder returns the corners of the range of block positions directly below the Player. Every block in that range +// is one the Player stands on: the Player is narrower than a block, so it may rest on the edge of one with its centre +// over the block beside it, and looking only below its centre would miss the block it is actually standing on. +func (p *Player) blocksUnder() (low, high cube.Pos) { + box := Type.BBox(p).Translate(p.Position()) + // The Y is taken from the box itself, while the horizontal range is taken from a slightly smaller box so that a + // Player resting exactly on the boundary between two blocks does not reach into the column beside the one it + // stands on. + y := int(math.Floor(box.Min()[1] - 0.0001)) + horizontal := box.Grow(-0.0001) + low, high = cube.PosFromVec3(horizontal.Min()), cube.PosFromVec3(horizontal.Max()) + low[1], high[1] = y, y + return low, high +} + // Hurt hurts the player for a given amount of damage. The source passed // represents the cause of the damage, for example entity.AttackDamageSource if // the player is attacked by another entity. If the final damage exceeds the @@ -595,7 +637,7 @@ func (p *Player) Hurt(dmg float64, src world.DamageSource) (float64, bool) { } immunity := time.Second / 2 - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleHurt(ctx, &damageLeft, immune, &immunity, src); ctx.Cancelled() { return 0, false } @@ -694,9 +736,10 @@ func (p *Player) FinalDamageFrom(dmg float64, src world.DamageSource) float64 { } // Explode ... -func (p *Player) Explode(explosionPos mgl64.Vec3, impact float64, c block.ExplosionConfig) { +func (p *Player) Explode(src world.ExplosionSource, impact float64) { + explosionPos := src.Position() diff := p.Position().Sub(explosionPos) - p.Hurt(math.Floor((impact*impact+impact)*3.5*c.Size*2+1), entity.ExplosionDamageSource{}) + p.Hurt(math.Floor((impact*impact+impact)*3.5*src.Size()*2+1), entity.ExplosionDamageSource{Source: src}) p.knockBack(explosionPos, impact, diff[1]/diff.Len()*impact) } @@ -820,7 +863,7 @@ func (p *Player) Exhaust(points float64) { // Temporarily set the food level back so that it hasn't yet changed once the event is handled. p.hunger.SetFood(before) - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleFoodLoss(ctx, before, &after); ctx.Cancelled() { // Reset the exhaustion level if the event was cancelled. Because if // we cancel this, and at some point we stop cancelling it, the @@ -877,14 +920,13 @@ func (p *Player) kill(src world.DamageSource) { // Wait a little before removing the entity. The client displays a death // animation while the player is dying. - time.AfterFunc(time.Millisecond*1100, func() { - p.H().ExecWorld(finishDying) + DoAfter(p.handle, time.Millisecond*1100, func(_ *world.Tx, p *Player) { + finishDying(p) }) } // finishDying completes the death of a player, removing it from the world. -func finishDying(_ *world.Tx, e world.Entity) { - p := e.(*Player) +func finishDying(p *Player) { if p.session() == session.Nop { _ = p.Close() return @@ -928,7 +970,7 @@ func (p *Player) MoveItemsToInventory() { if n, err := p.inv.AddItem(i); err != nil { // We couldn't add the item to the main inventory (probably because // it was full), so we drop it instead. - p.Drop(i.Grow(i.Count() - n)) + p.Drop(i.Grow(-n)) } } } @@ -944,6 +986,10 @@ func (p *Player) Respawn() *world.EntityHandle { return p.handle } +// respawn heals the player and moves it to its spawn position. f, if +// non-nil, runs with the player once it is back in a world — normally the +// respawn destination, otherwise the world it died in — so a quit callback +// from close always completes the player's teardown. func (p *Player) respawn(f func(p *Player)) { if !p.Dead() || p.session() == session.Nop { return @@ -964,8 +1010,20 @@ func (p *Player) respawn(f func(p *Player)) { p.Handler().HandleRespawn(p, &pos, &w) + sess := p.session() + src := p.tx.World() handle := p.tx.RemoveEntity(p) - w.Exec(func(tx *world.Tx) { + // restore re-adds the player through tx and finishes with f or the normal + // quit path; the fallback branches below share it. + restore := func(tx *world.Tx) { + np := tx.AddEntity(handle).(*Player) + if f != nil { + f(np) + return + } + np.quit("respawn failed") + } + task := w.Do(func(tx *world.Tx) { np := tx.AddEntity(handle).(*Player) np.Teleport(pos) np.session().SendRespawn(pos, p) @@ -974,6 +1032,32 @@ func (p *Player) respawn(f func(p *Player)) { f(np) } }) + if errors.Is(task.Err(), world.ErrWorldClosed) { + // The destination refused synchronously: re-add through the still-open + // source context. This also keeps synchronous worlds fully inline. + restore(p.tx) + return + } + task.OnDone(func(err error) { + // Only ErrWorldClosed means the entity was never re-added. A callback + // panic is left alone: the entity is live. + if !errors.Is(err, world.ErrWorldClosed) { + return + } + // Fall back to the source world so the normal quit path still runs. + src.Do(restore).OnDone(func(err error) { + if err == nil || errors.Is(err, world.ErrTaskPanicked) { + return + } + // The source world is gone too; the handle is orphaned. Close the + // session without a world so the stop handler still runs, then + // free the connection. + _ = handle.Close() + sess.Disconnect("respawn failed") + sess.Close(nil, p) + sess.CloseConnection() + }) + }) } // spawnLocation designates a players safe spawn location. @@ -1003,7 +1087,7 @@ func (p *Player) StartSprinting() { if !p.hunger.canSprint() && p.GameMode().AllowsTakingDamage() || p.crawling || p.sprinting { return } - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleToggleSprint(ctx, true); ctx.Cancelled() { return } @@ -1023,7 +1107,7 @@ func (p *Player) StopSprinting() { if !p.sprinting { return } - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleToggleSprint(ctx, false); ctx.Cancelled() { return } @@ -1039,7 +1123,7 @@ func (p *Player) StartSneaking() { if p.sneaking { return } - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleToggleSneak(ctx, true); ctx.Cancelled() { return } @@ -1061,7 +1145,7 @@ func (p *Player) StopSneaking() { if !p.sneaking { return } - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleToggleSneak(ctx, false); ctx.Cancelled() { return } @@ -1221,7 +1305,7 @@ func (p *Player) Sleep(pos cube.Pos) { return } - ctx, sendReminder := event.C(p), true + ctx, sendReminder := NewEventContext(p.tx, p), true if p.Handler().HandleSleep(ctx, &sendReminder); ctx.Cancelled() { return } @@ -1348,6 +1432,14 @@ func (p *Player) SetOnFire(duration time.Duration) { if level := p.Armour().HighestEnchantmentLevel(enchantment.FireProtection); level > 0 { ticks -= int64(math.Floor(float64(ticks) * float64(level) * 0.15)) } + + duration = time.Duration(ticks) * time.Second / 20 + ctx := NewEventContext(p.tx, p) + if p.Handler().HandleSetOnFire(ctx, &duration); ctx.Cancelled() { + return + } + + ticks = int64(duration.Seconds() * 20) p.fireTicks = ticks p.updateState() } @@ -1400,7 +1492,7 @@ func (p *Player) SetHeldSlot(to int) error { return nil } - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) p.Handler().HandleHeldSlotChange(ctx, from, to) if ctx.Cancelled() { // The slot change was cancelled, resend held slot. @@ -1487,7 +1579,7 @@ func (p *Player) SetCooldown(item world.Item, cooldown time.Duration) { // This generally happens for items such as throwable items like snowballs. func (p *Player) UseItem() { i, _ := p.HeldItems() - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.HasCooldown(i.Item()) { return } @@ -1565,7 +1657,7 @@ func (p *Player) UseItem() { } // Reset the duration for the next item to be consumed. p.usingSince = time.Now() - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleItemConsume(ctx, i); ctx.Cancelled() { return } @@ -1589,7 +1681,7 @@ func (p *Player) ReleaseItem() { useCtx, dur := p.useContext(), p.useDuration() i, _ := p.HeldItems() - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleItemRelease(ctx, i, dur); ctx.Cancelled() { return } @@ -1672,7 +1764,7 @@ func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec p.resendNearbyBlocks(pos, face) return } - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleItemUseOnBlock(ctx, pos, face, clickPos); ctx.Cancelled() { p.resendNearbyBlocks(pos, face) return @@ -1683,11 +1775,10 @@ func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec // If a player is sneaking, it will not activate the block clicked, unless it is not holding any // items, in which case the block will be activated as usual. if !p.Sneaking() || i.Empty() { - p.SwingArm() - // The block was activated: Blocks such as doors must always have precedence over the item being // used. if useCtx := p.useContext(); act.Activate(pos, face, p.tx, p, useCtx) { + p.SwingArm() p.SetHeldItems(p.subtractItem(p.damageItem(i, useCtx.Damage), useCtx.CountSub), left) p.addNewItem(useCtx) return @@ -1731,7 +1822,7 @@ func (p *Player) UseItemOnEntity(e world.Entity) bool { if !p.canReach(e.Position()) { return false } - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleItemUseOnEntity(ctx, e); ctx.Cancelled() { return false } @@ -1779,14 +1870,26 @@ func (p *Player) AttackEntity(e world.Entity) bool { height += inc } - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleAttackEntity(ctx, e, &force, &height, &critical); ctx.Cancelled() { return false } p.SwingArm() if !isLiving { - return false + if !entity.DamageableEntity(e) { + return false + } + i, left := p.HeldItems() + if durable, ok := i.Item().(item.Durable); ok { + p.SetHeldItems(p.damageItem(i, durable.DurabilityInfo().AttackDurability), left) + } + n, vulnerable, _ := entity.HurtEntity(e, i.AttackDamage(), entity.AttackDamageSource{Attacker: p}) + p.tx.PlaySound(entity.EyePosition(e), sound.Attack{Damage: !mgl64.FloatEqual(n, 0)}) + if vulnerable { + p.Exhaust(0.1) + } + return true } dmg := i.AttackDamage() @@ -1847,7 +1950,7 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { return } if _, ok := p.tx.Block(pos.Side(face)).(block.Fire); ok { - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleFireExtinguish(ctx, pos); ctx.Cancelled() { // Resend the block because on client side that was extinguished p.resendNearbyBlocks(pos, face) @@ -1868,7 +1971,7 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { // can resend the block to the client when it tries to break the block regardless. p.breakingPos = pos - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleStartBreak(ctx, pos); ctx.Cancelled() { return } @@ -1892,25 +1995,29 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { // held, if the player is on the ground/underwater and if the player has any effects. func (p *Player) breakTime(pos cube.Pos) time.Duration { held, _ := p.HeldItems() - breakTime := block.BreakDuration(p.tx.Block(pos), held) - if !p.OnGround() { - breakTime *= 5 + return block.BreakDuration(p.tx.Block(pos), held, p.breakContext()) +} + +// breakContext returns the block.BreakContext describing the status effects and environment currently +// affecting how quickly the player breaks blocks. +func (p *Player) breakContext() block.BreakContext { + _, aquaAffinity := p.Armour().Helmet().Enchantment(enchantment.AquaAffinity) + ctx := block.BreakContext{ + Underwater: p.insideOfWater(), + AquaAffinity: aquaAffinity, + Airborne: !p.OnGround(), + Flying: p.Flying(), } - if _, ok := p.Armour().Helmet().Enchantment(enchantment.AquaAffinity); p.insideOfWater() && !ok { - breakTime *= 5 + if e, ok := p.Effect(effect.Haste); ok { + ctx.HasteLevel = e.Level() } - for _, e := range p.Effects() { - lvl := e.Level() - switch e.Type() { - case effect.Haste: - breakTime = time.Duration(float64(breakTime) * effect.Haste.Multiplier(lvl)) - case effect.MiningFatigue: - breakTime = time.Duration(float64(breakTime) * effect.MiningFatigue.Multiplier(lvl)) - case effect.ConduitPower: - breakTime = time.Duration(float64(breakTime) * effect.ConduitPower.Multiplier(lvl)) - } + if e, ok := p.Effect(effect.ConduitPower); ok { + ctx.ConduitPowerLevel = e.Level() + } + if e, ok := p.Effect(effect.MiningFatigue); ok { + ctx.MiningFatigueLevel = e.Level() } - return breakTime + return ctx } // FinishBreaking makes the player finish breaking the block it is currently breaking, or returns immediately @@ -1995,7 +2102,7 @@ func (p *Player) placeBlock(pos cube.Pos, b world.Block, ignoreBBox bool) bool { return false } - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleBlockPlace(ctx, pos, b); ctx.Cancelled() { p.resendNearbyBlocks(pos, cube.Faces()...) return false @@ -2062,7 +2169,7 @@ func (p *Player) BreakBlock(pos cube.Pos) { } } - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleBlockBreak(ctx, pos, &drops, &xp); ctx.Cancelled() { p.resendNearbyBlocks(pos) return @@ -2088,7 +2195,9 @@ func (p *Player) BreakBlock(pos cube.Pos) { } p.Exhaust(0.005) - if block.BreaksInstantly(b, held) { + // Only blocks that naturally break instantly (zero hardness) cost no durability; a block made to break + // within one tick by a fast tool or status effects still consumes durability. + if block.BreaksInstantly(b) { return } if durable, ok := held.Item().(item.Durable); ok { @@ -2137,7 +2246,7 @@ func (p *Player) PickBlock(pos cube.Pos) { return } - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleBlockPick(ctx, pos, b); ctx.Cancelled() { return } @@ -2169,16 +2278,21 @@ func (p *Player) PickBlock(pos cube.Pos) { // Teleport teleports the player to a target position in the world. Unlike Move, it immediately changes the // position of the player, rather than showing an animation. func (p *Player) Teleport(pos mgl64.Vec3) { - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleTeleport(ctx, pos); ctx.Cancelled() { return } + p.forceTeleport(pos) +} + +// forceTeleport teleports the player without calling the Handler. +// It also wakes up the player from sleep. +func (p *Player) forceTeleport(pos mgl64.Vec3) { p.Wake() p.teleport(pos) } -// teleport teleports the player to a target position in the world. It does not call the Handler of the -// player. +// teleport teleports the player to a target position in the world without updating non-positional state. func (p *Player) teleport(pos mgl64.Vec3) { for _, v := range p.viewers() { v.ViewEntityTeleport(p, pos) @@ -2193,6 +2307,8 @@ func (p *Player) teleport(pos mgl64.Vec3) { // Move also rotates the player, adding deltaYaw and deltaPitch to the respective values. func (p *Player) Move(deltaPos mgl64.Vec3, deltaYaw, deltaPitch float64) { if p.Dead() || (deltaPos.ApproxEqual(mgl64.Vec3{}) && mgl64.FloatEqual(deltaYaw, 0) && mgl64.FloatEqual(deltaPitch, 0)) { + p.onGround = true + p.updateFallState(deltaPos.Y()) return } if p.immobile { @@ -2207,7 +2323,7 @@ func (p *Player) Move(deltaPos mgl64.Vec3, deltaYaw, deltaPitch float64) { pos = p.Position() res, resRot = pos.Add(deltaPos), p.Rotation().Add(cube.Rotation{deltaYaw, deltaPitch}) ) - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleMove(ctx, res, resRot); ctx.Cancelled() { if p.session() != session.Nop && pos.ApproxEqual(p.Position()) { // The position of the player was changed and the event cancelled. This means we still need to notify the @@ -2251,7 +2367,7 @@ func (p *Player) Move(deltaPos mgl64.Vec3, deltaYaw, deltaPitch float64) { } p.onGround = p.checkOnGround(deltaPos) - p.updateFallState(deltaPos[1]) + p.updateFallState(deltaPos.Y()) if p.Swimming() { p.Exhaust(0.01 * horizontalVel.Len()) @@ -2260,6 +2376,26 @@ func (p *Player) Move(deltaPos mgl64.Vec3, deltaYaw, deltaPitch float64) { } } +// Displace moves the player by a server-authoritative relative delta, clipped against block collision boxes. +func (p *Player) Displace(deltaPos mgl64.Vec3) { + if p.Dead() || deltaPos.ApproxEqual(mgl64.Vec3{}) { + return + } + pos := p.Position() + deltaPos, velocity := p.mc.CheckCollision(p.tx, p, pos, deltaPos) + if deltaPos.ApproxEqual(mgl64.Vec3{}) { + return + } + res := pos.Add(deltaPos) + for _, v := range p.viewers() { + v.ViewEntityDisplacement(p, res, p.Rotation(), p.OnGround()) + } + p.data.Pos, p.data.Vel = res, velocity + p.checkBlockCollisions(deltaPos) + p.onGround = p.checkOnGround(deltaPos) + p.updateFallState(deltaPos[1]) +} + // Position returns the current position of the player. It may be changed as the player moves or is moved // around the world. func (p *Player) Position() mgl64.Vec3 { @@ -2296,7 +2432,7 @@ func (p *Player) Collect(s item.Stack) (int, bool) { if p.Dead() || !p.GameMode().AllowsInteraction() { return 0, false } - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleItemPickup(ctx, &s); ctx.Cancelled() { return 0, false } @@ -2328,7 +2464,7 @@ func (p *Player) ResetEnchantmentSeed() { // AddExperience adds experience to the player. func (p *Player) AddExperience(amount int) int { - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleExperienceGain(ctx, &amount); ctx.Cancelled() { return 0 } @@ -2449,7 +2585,7 @@ func (p *Player) mendItems(xp int) int { // The number of items that was dropped in the end is returned. It is generally the count of the stack passed // or 0 if dropping the item.Stack was cancelled. func (p *Player) Drop(s item.Stack) int { - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleItemDrop(ctx, s); ctx.Cancelled() { return 0 } @@ -2584,6 +2720,17 @@ func (p *Player) Tick(tx *world.Tx, current int64) { } else { p.data.Vel = mgl64.Vec3{} } + + p.portalTravel.StopPortalContact() +} + +// TravelThroughPortal handles the player touching a portal block. +func (p *Player) TravelThroughPortal(tx *world.Tx, target world.Dimension) { + if !p.GameMode().HasCollision() { + // Game modes that pass through blocks, such as spectator, are not affected by portals. + return + } + p.portalTravel.EnterPortal(p, tx, target) } // ViewLayer returns the ViewLayer attached to the player's session. @@ -2601,6 +2748,16 @@ func (p *Player) ViewPublicNameTag(entity world.Entity) { p.session().ViewPublicNameTag(entity) } +// ViewAlwaysShowNameTag overrides whether the entity's name tag is shown at all distances for this player. +func (p *Player) ViewAlwaysShowNameTag(entity world.Entity, alwaysShow bool) { + p.session().ViewAlwaysShowNameTag(entity, alwaysShow) +} + +// ViewPublicAlwaysShowNameTag removes the always-show name tag override of the entity for this player. +func (p *Player) ViewPublicAlwaysShowNameTag(entity world.Entity) { + p.session().ViewPublicAlwaysShowNameTag(entity) +} + // ViewScoreTag overrides the public score tag of the entity for this player. func (p *Player) ViewScoreTag(entity world.Entity, scoreTag string) { p.session().ViewScoreTag(entity, scoreTag) @@ -2624,7 +2781,7 @@ func (p *Player) RemoveViewLayer(entity world.Entity) { // tickAirSupply tick's the player's air supply, consuming it when underwater, and replenishing it when out of water. func (p *Player) tickAirSupply() { if !p.canBreathe() { - if r, ok := p.Armour().Helmet().Enchantment(enchantment.Respiration); ok && rand.Float64() <= enchantment.Respiration.Chance(r.Level()) { + if r, ok := p.Armour().Helmet().Enchantment(enchantment.Respiration); ok && rand.Float64() < enchantment.Respiration.Chance(r.Level()) { // respiration grants a chance to avoid drowning damage every tick. return } @@ -2654,7 +2811,7 @@ func (p *Player) tickFood() { } if p.hunger.foodTick == 1 { if p.hunger.canRegenerate() { - p.regenerate(false) + p.regenerate(!p.tx.World().Difficulty().FoodRegenerates()) } else if p.hunger.starving() { p.starve() } @@ -2675,8 +2832,8 @@ func (p *Player) regenerate(exhaust bool) { if p.Health() == p.MaxHealth() { return } - p.Heal(1, entity.FoodHealingSource{}) - if exhaust { + regenerated := p.Heal(1, entity.FoodHealingSource{QuickRegeneration: exhaust}) + if exhaust && regenerated > 0 { p.Exhaust(6) } } @@ -2755,6 +2912,9 @@ func (p *Player) insideOfSolid() bool { // Transparent. return false } + if immune, ok := b.(block.NonSuffocating); ok && immune.PreventsSuffocation() { + return false + } for _, blockBox := range b.Model().BBox(pos, p.tx) { if blockBox.Translate(pos.Vec3()).IntersectsWith(box) { return true @@ -2849,13 +3009,10 @@ func (p *Player) checkEntitySteppers() { if !p.OnGround() { return } - box := Type.BBox(p).Translate(p.Position()).Grow(-0.0001) - low, high := cube.PosFromVec3(box.Min()), cube.PosFromVec3(box.Max()) - y := int(math.Floor(box.Min()[1] - 0.0001)) - + low, high := p.blocksUnder() for x := low[0]; x <= high[0]; x++ { for z := low[2]; z <= high[2]; z++ { - pos := cube.Pos{x, y, z} + pos := cube.Pos{x, low[1], z} if stepper, ok := p.tx.Block(pos).(block.EntityStepper); ok { stepper.EntityStepOn(pos, p.tx, p) return @@ -2957,7 +3114,7 @@ func (p *Player) EditSign(pos cube.Pos, frontText, backText string) error { return nil } - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if frontText != sign.Front.Text { if p.Handler().HandleSignEdit(ctx, pos, true, sign.Front.Text, frontText); ctx.Cancelled() { p.resendNearbyBlock(pos) @@ -2985,7 +3142,7 @@ func (p *Player) TurnLecternPage(pos cube.Pos, page int) error { return fmt.Errorf("edit lectern: no lectern at position %v", pos) } - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleLecternPageTurn(ctx, pos, lectern.Page, &page); ctx.Cancelled() { return nil } @@ -3027,7 +3184,7 @@ func (p *Player) PunchAir() { if p.Dead() { return } - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandlePunchAir(ctx); ctx.Cancelled() { return } @@ -3077,6 +3234,32 @@ func (p *Player) RemoveAllDebugShapes() { p.session().RemoveAllDebugShapes() } +// LockInput applies an input lock to the player, disabling the specified input and immediately sending the +// updated lock state to the client. +func (p *Player) LockInput(l input.Lock) { + p.session().LockInput(l) + p.session().SendInputLocks() +} + +// UnlockInput removes an input lock from the player, re-enabling the specified input and immediately sending +// the updated lock state to the client. +func (p *Player) UnlockInput(l input.Lock) { + p.session().UnlockInput(l) + p.session().SendInputLocks() +} + +// ClearInputLocks removes all input locks from the player, re-enabling all inputs and immediately sending the +// updated lock state to the client. +func (p *Player) ClearInputLocks() { + p.session().ClearInputLocks() + p.session().SendInputLocks() +} + +// InputLocked checks if a specific input lock is currently applied to the player. +func (p *Player) InputLocked(l input.Lock) bool { + return p.session().InputLocked(l) +} + // damageItem damages the item stack passed with the damage passed and returns the new stack. If the item // broke, a breaking sound is played. // If the player is not survival, the original stack is returned. @@ -3084,7 +3267,7 @@ func (p *Player) damageItem(s item.Stack, d int) item.Stack { if p.GameMode().CreativeInventory() || d == 0 || s.MaxDurability() == -1 { return s } - ctx := event.C(p) + ctx := NewEventContext(p.tx, p) if p.Handler().HandleItemDamage(ctx, s, &d); ctx.Cancelled() || d <= 0 { return s } @@ -3119,7 +3302,7 @@ func (p *Player) addNewItem(ctx *item.UseContext) { n, err := p.Inventory().AddItem(ctx.NewItem) if err != nil { // Not all items could be added to the inventory, so drop the rest. - p.Drop(ctx.NewItem.Grow(ctx.NewItem.Count() - n)) + p.Drop(ctx.NewItem.Grow(-n)) } if p.Dead() { p.dropItems() @@ -3137,6 +3320,9 @@ func (p *Player) canReach(pos mgl64.Vec3) bool { // Disconnect closes the player and removes it from the world. // Disconnect, unlike Close, allows a custom message to be passed to show to the player when it is // disconnected. The message is formatted following the rules of fmt.Sprintln without a newline at the end. +// The player is removed from its current world before Disconnect returns and +// must not be used afterwards. If the player is dead, it first respawns: the +// remaining teardown completes on the owner of the world it respawns into. func (p *Player) Disconnect(msg ...any) { p.once.Do(func() { p.close(format(msg)) @@ -3146,6 +3332,9 @@ func (p *Player) Disconnect(msg ...any) { // Close closes the player and removes it from the world. // Close disconnects the player with a 'Connection closed.' message. Disconnect should be used to disconnect a // player with a custom message. +// The player is removed from its current world before Close returns and must +// not be used afterwards. If the player is dead, it first respawns: the +// remaining teardown completes on the owner of the world it respawns into. func (p *Player) Close() error { p.once.Do(func() { p.close("Connection closed.") @@ -3173,6 +3362,10 @@ func (p *Player) quit(msg string) { if s := p.s; s != nil { s.Disconnect(msg) + // Close the session on this owner directly: teardown must not depend + // on the session goroutine, which cannot schedule work anymore once + // the world starts closing. + s.Close(p.tx, p) s.CloseConnection() return } diff --git a/server/player/playerdb/inventory.go b/server/player/playerdb/inventory.go index 81344b42bd..529ed71638 100644 --- a/server/player/playerdb/inventory.go +++ b/server/player/playerdb/inventory.go @@ -3,7 +3,6 @@ package playerdb import ( "bytes" - "github.com/df-mc/dragonfly/server/internal/nbtconv" "github.com/df-mc/dragonfly/server/item" "github.com/sandertv/gophertunnel/minecraft/nbt" ) @@ -70,13 +69,13 @@ func decodeItems(encoded []jsonSlot, items []item.Stack) { } } -func encodeItem(item item.Stack) []byte { - if item.Empty() { +func encodeItem(stack item.Stack) []byte { + if stack.Empty() { return nil } var b bytes.Buffer - itemNBT := nbtconv.WriteItem(item, true) + itemNBT := item.WriteNBT(stack, true) encoder := nbt.NewEncoderWithEncoding(&b, nbt.LittleEndian) err := encoder.Encode(itemNBT) if err != nil { @@ -92,5 +91,5 @@ func decodeItem(data []byte) item.Stack { if err != nil { return item.Stack{} } - return nbtconv.Item(itemNBT, nil) + return item.ReadNBT(itemNBT, nil) } diff --git a/server/player/playerdb/json.go b/server/player/playerdb/json.go index c453da047c..dd4b39fa7e 100644 --- a/server/player/playerdb/json.go +++ b/server/player/playerdb/json.go @@ -92,7 +92,7 @@ func (p *Provider) toJson(d player.Config, w *world.World) jsonData { MainHandSlot: uint32(d.HeldSlot), }), EnderChestInventory: encodeItems(d.EnderChestInventory.Slots()), - Dimension: uint8(dim), + Dimension: int32(dim), } } @@ -115,7 +115,7 @@ type jsonData struct { Effects []jsonEffect FireTicks int64 FallDistance float64 - Dimension uint8 + Dimension int32 } type jsonInventoryData struct { diff --git a/server/player/ref.go b/server/player/ref.go new file mode 100644 index 0000000000..097dca74bf --- /dev/null +++ b/server/player/ref.go @@ -0,0 +1,49 @@ +package player + +import ( + "context" + "time" + + "github.com/df-mc/dragonfly/server/world" +) + +// Ref is a stable reference to a player that outlives callbacks. The *Player +// value is only handed to scheduled owner callbacks, where it is safe to use. +type Ref = world.EntityRef[*Player] + +// NewRef creates a typed player reference from an entity handle. +func NewRef(h *world.EntityHandle) Ref { return world.NewEntityRef[*Player](h) } + +// Do schedules f to run with the player identified by h on its current world owner. +func Do(h *world.EntityHandle, f func(tx *world.Tx, p *Player)) *world.Task { + return NewRef(h).Do(f) +} + +// DoAfter schedules f to run with the player identified by h after delay. +func DoAfter(h *world.EntityHandle, delay time.Duration, f func(tx *world.Tx, p *Player)) *world.Task { + return NewRef(h).DoAfter(delay, f) +} + +// Call runs f with the player identified by h on its current world owner and +// waits for its typed result. If f panics, Call re-panics with the original +// value on the waiting goroutine. +func Call[T any](ctx context.Context, h *world.EntityHandle, f func(tx *world.Tx, p *Player) (T, error)) (T, error) { + return world.CallRef(ctx, NewRef(h), f) +} + +// Do schedules f to run with the player on its current world owner. Use it to +// re-enter the player from code that outlived a callback. +func (p *Player) Do(f func(tx *world.Tx, p *Player)) *world.Task { + if p == nil { + return world.NewFinishedTask(world.ErrEntityClosed) + } + return Do(p.handle, f) +} + +// DoAfter schedules f on the player's current world owner after delay. +func (p *Player) DoAfter(delay time.Duration, f func(tx *world.Tx, p *Player)) *world.Task { + if p == nil { + return world.NewFinishedTask(world.ErrEntityClosed) + } + return DoAfter(p.handle, delay, f) +} diff --git a/server/server.go b/server/server.go index 3430642eff..efa600629c 100644 --- a/server/server.go +++ b/server/server.go @@ -47,8 +47,9 @@ type Server struct { world, nether, end *world.World - customBlocks []protocol.BlockEntry - customItems []protocol.ItemEntry + customBlocks []protocol.BlockEntry + customItems []protocol.ItemEntry + customDimensions []protocol.DimensionDefinition listeners []Listener incoming chan incoming @@ -115,9 +116,12 @@ func (srv *Server) Listen() { // Accept accepts incoming players into the server, returning an iterator that // yields players that join the server while blocking otherwise. The iterator -// returned ends when the Server is closed using a call to Close. Players -// returned are only valid within the block of the for loop used to iterate over -// them: +// returned ends when the Server is closed using a call to Close. The loop body +// runs on the player's world owner: blocking there stalls that world, and +// calling world.Call, world.CallEntity, world.CallRef, or Task.Wait for the +// same owner deadlocks. Players returned are only valid within the block of the +// for loop used to iterate over them. Use p.H() with player.Do for work that +// outlives the loop body: // // for p := range srv.Accept() { // // p is valid here @@ -137,12 +141,24 @@ func (srv *Server) Accept() iter.Seq[*player.Player] { srv.p[inc.p.handle.UUID()] = inc.p srv.pmu.Unlock() - ret := false - <-inc.w.Exec(func(tx *world.Tx) { + ret, err := world.Call(context.Background(), inc.w, func(tx *world.Tx) (bool, error) { p := tx.AddEntity(inc.p.handle).(*player.Player) inc.s.Spawn(p, tx) - ret = !yield(p) + return !yield(p), nil }) + if err != nil { + srv.pmu.Lock() + delete(srv.p, inc.p.handle.UUID()) + srv.pmu.Unlock() + srv.pwg.Done() + // Join failed before spawn: the entity was never added, so close + // the orphaned handle and fully tear the session down (Disconnect + // only writes a packet; CloseConnection frees the conn/goroutines). + _ = inc.p.handle.Close() + inc.s.Disconnect("join failed") + inc.s.CloseConnection() + continue + } if ret { return } @@ -191,8 +207,14 @@ func (srv *Server) PlayerCount() int { // Players returns an iterator that yields players currently online. If Players // is called from within a transaction, the respective transaction should be -// passed. Passing nil is otherwise valid. Players returned are only valid -// within the block of the for loop used to iterate over them: +// passed. Passing nil is otherwise valid. Each loop body runs on the yielded +// player's world owner, so blocking stalls that world and calling world.Call, +// world.CallEntity, world.CallRef, or Task.Wait for the same owner deadlocks. +// Players in other worlds are yielded by blocking on those owners sequentially; +// mirrored handlers in two worlds can therefore deadlock each other. For +// fan-out, collect Player.H values and schedule each with player.Do instead. +// Players returned are only valid within the block of the for loop used to +// iterate over them: // // for p := range srv.Players(nil) { // // p is valid here @@ -204,7 +226,8 @@ func (srv *Server) PlayerCount() int { // // Collecting all values from the iterator using a function such as // slices.Collect immediately invalidates the players because their transactions -// will be finished. +// will be finished. Use Player.H(), player.NewRef, or player.Do when a +// player must be referenced after the iterator callback returns. func (srv *Server) Players(tx *world.Tx) iter.Seq[*player.Player] { srv.pmu.RLock() handles := make([]*world.EntityHandle, 0, len(srv.p)) @@ -223,10 +246,12 @@ func (srv *Server) Players(tx *world.Tx) iter.Seq[*player.Player] { continue } } - ret := false - handle.ExecWorld(func(tx *world.Tx, e world.Entity) { - ret = !yield(e.(*player.Player)) + ret, err := player.Call(context.Background(), handle, func(_ *world.Tx, p *player.Player) (bool, error) { + return !yield(p), nil }) + if err != nil { + continue + } if ret { break } @@ -348,11 +373,7 @@ func (srv *Server) listen(l Listener) { wg.Add(1) go func() { defer wg.Done() - if msg, ok := srv.conf.Allower.Allow(c.RemoteAddr(), c.IdentityData(), c.ClientData()); !ok { - _ = c.WritePacket(&packet.Disconnect{HideDisconnectionScreen: msg == "", Message: msg}) - _ = c.Close() - return - } + srv.finaliseConn(ctx, c, l) }() } @@ -363,6 +384,7 @@ func (srv *Server) listen(l Listener) { func (srv *Server) startListening() { srv.makeBlockEntries() srv.makeItemComponents() + srv.makeDimensionData() srv.wg.Add(len(srv.listeners)) for _, l := range srv.listeners { @@ -393,7 +415,7 @@ func (srv *Server) makeItemComponents() { custom := world.CustomItems() srv.customItems = make([]protocol.ItemEntry, len(custom)) - for _, it := range custom { + for i, it := range custom { name, _ := it.EncodeItem() rid, _, _ := world.ItemRuntimeID(it) _, isCustomBlock := it.(world.CustomBlock) @@ -401,12 +423,27 @@ func (srv *Server) makeItemComponents() { if isCustomBlock { entryVersion = protocol.ItemEntryVersionNone } - srv.customItems = append(srv.customItems, protocol.ItemEntry{ + srv.customItems[i] = protocol.ItemEntry{ Name: name, ComponentBased: !isCustomBlock, RuntimeID: int16(rid), Version: entryVersion, Data: iteminternal.Components(it), + } + } +} + +// makeDimensionData initialises the server's custom dimensions list. +func (srv *Server) makeDimensionData() { + dimensions := world.CustomDimensions() + srv.customDimensions = make([]protocol.DimensionDefinition, 0, len(dimensions)) + for _, registration := range dimensions { + r := registration.Dimension.Range() + srv.customDimensions = append(srv.customDimensions, protocol.DimensionDefinition{ + Name: registration.Name, + Range: [2]int32{int32(r.Max() + 1), int32(r.Min())}, + Generator: protocol.GeneratorVoid, + DimensionType: int32(registration.ID), }) } } @@ -485,6 +522,7 @@ func (srv *Server) defaultGameData() minecraft.GameData { PlayerMovementSettings: protocol.PlayerMovementSettings{ ServerAuthoritativeBlockBreaking: true, }, + Dimensions: srv.customDimensions, } } @@ -514,8 +552,12 @@ func (srv *Server) handleSessionClose(tx *world.Tx, c session.Controllable) { return } - if err := srv.conf.PlayerProvider.Save(c.UUID(), c.(*player.Player).Data(), tx.World()); err != nil { - srv.conf.Log.Error("Save player data: " + err.Error()) + if tx != nil { + if err := srv.conf.PlayerProvider.Save(c.UUID(), c.(*player.Player).Data(), tx.World()); err != nil { + srv.conf.Log.Error("Save player data: " + err.Error()) + } + } else { + srv.conf.Log.Error("Save player data: player's worlds closed before teardown; data not saved", "uuid", c.UUID()) } srv.pwg.Done() } @@ -563,6 +605,7 @@ func (srv *Server) createWorld(dim world.Dimension, nether, end **world.World) * ReadOnly: srv.conf.ReadOnlyWorld, SaveInterval: srv.conf.SaveInterval, ChunkUnloadInterval: srv.conf.ChunkUnloadInterval, + ChunkLoadWorkers: srv.conf.ChunkLoadWorkers, Entities: srv.conf.Entities, Blocks: srv.conf.Blocks, PortalDestination: func(dim world.Dimension) *world.World { diff --git a/server/session/chunk.go b/server/session/chunk.go index 150142ae33..ef765bd2dd 100644 --- a/server/session/chunk.go +++ b/server/session/chunk.go @@ -84,15 +84,19 @@ func (s *Session) subChunkEntry(offset protocol.SubChunkOffset, ind int16, col * } else if lower { subMapType, subMap = protocol.HeightMapDataTooLow, nil } + var subMapData protocol.Optional[[]int8] + if subMap != nil { + subMapData = protocol.Option(subMap) + } sub := col.Sub()[ind] if sub.Empty() { return protocol.SubChunkEntry{ Result: protocol.SubChunkResultSuccessAllAir, HeightMapType: subMapType, - HeightMapData: subMap, + HeightMapData: subMapData, RenderHeightMapType: subMapType, - RenderHeightMapData: subMap, + RenderHeightMapData: subMapData, Offset: offset, } } @@ -111,19 +115,19 @@ func (s *Session) subChunkEntry(offset protocol.SubChunkOffset, ind int16, col * entry := protocol.SubChunkEntry{ Result: protocol.SubChunkResultSuccess, - RawPayload: append(serialisedSubChunk, blockEntityBuf.Bytes()...), + RawPayload: protocol.Option(append(serialisedSubChunk, blockEntityBuf.Bytes()...)), HeightMapType: subMapType, - HeightMapData: subMap, + HeightMapData: subMapData, RenderHeightMapType: subMapType, - RenderHeightMapData: subMap, + RenderHeightMapData: subMapData, 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() + entry.BlobHash = protocol.Option(hash) + entry.RawPayload = protocol.Option(blockEntityBuf.Bytes()) } } return entry @@ -142,13 +146,13 @@ func (s *Session) sendBlobHashes(pos world.ChunkPos, dim world.Dimension, c *chu 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, + Dimension: s.dimensionID(dim), + SubChunkCount: 0, + Position: protocol.ChunkPos(pos), + SubChunkLimit: protocol.Option(int32(c.HighestFilledSubChunk())), + BlobHashes: []uint64{hash}, + RawPayload: []byte{0}, + CacheEnabled: true, }) return } @@ -203,11 +207,11 @@ func (s *Session) sendBlobHashes(pos world.ChunkPos, dim world.Dimension, c *chu 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), + Dimension: s.dimensionID(dim), + SubChunkCount: 0, + Position: protocol.ChunkPos(pos), + SubChunkLimit: protocol.Option(int32(c.HighestFilledSubChunk())), + RawPayload: append(chunk.EncodeBiomes(c, chunk.NetworkEncoding), 0), }) return } diff --git a/server/session/command.go b/server/session/command.go index b18920f711..0752286ba6 100644 --- a/server/session/command.go +++ b/server/session/command.go @@ -213,7 +213,7 @@ func valueToParamType(i cmd.ParamInfo, source cmd.Source) (t uint32, enum comman Options: enum.Options(source), } } - return protocol.CommandArgTypeValue, enum + return protocol.CommandArgTypeRValue, enum } // resendCommands resends all commands that a Session has access to if the map of runnable commands passed does not diff --git a/server/session/controllable.go b/server/session/controllable.go index 8a924bf60d..d5386931d2 100644 --- a/server/session/controllable.go +++ b/server/session/controllable.go @@ -11,6 +11,7 @@ import ( "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/input" "github.com/df-mc/dragonfly/server/player/skin" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" @@ -31,6 +32,7 @@ type Controllable interface { chat.Subscriber hud.Renderer debug.Renderer + input.Restricter Locale() language.Tag diff --git a/server/session/enchantment_texts.go b/server/session/enchantment_texts.go index ed1267f2b8..93d40d83b7 100644 --- a/server/session/enchantment_texts.go +++ b/server/session/enchantment_texts.go @@ -4,4 +4,4 @@ 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", "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"} +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", "emirhan olgn", "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", "josscoder", "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", "theboss9345", "thicksunny", "thunder33345", "tripple awap", "tristanmorgan", "trix new", "twisted asylum mc", "unickorn", "unknown ore", "uramnoil", "wqrro", "x natsuri", "x superr", "x toast-dev", "x4caa", "xd-pro"} diff --git a/server/session/entity_metadata.go b/server/session/entity_metadata.go index 2cdfb2cfe0..cc69b09902 100644 --- a/server/session/entity_metadata.go +++ b/server/session/entity_metadata.go @@ -93,7 +93,7 @@ func (s *Session) addSpecificMetadata(e any, m protocol.EntityMetadata) { m[protocol.EntityDataKeyValue] = int32(o.Experience()) } if f, ok := e.(firework); ok { - m[protocol.EntityDataKeyDisplayTileRuntimeID] = nbtconv.WriteItem(item.NewStack(f.Firework(), 1), false) + m[protocol.EntityDataKeyDisplayFirework] = item.WriteNBT(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())) } @@ -107,22 +107,22 @@ func (s *Session) addSpecificMetadata(e any, m protocol.EntityMetadata) { 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 nameTag, alwaysShow, ok := nameTagState(e); ok { + writeNameTagMetadata(m, nameTag, alwaysShow) } if sc, ok := e.(scoreTag); ok { m[protocol.EntityDataKeyScore] = sc.ScoreTag() } + if c, ok := e.(endCrystal); ok { + if c.ShowBase() { + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagShowBottom) + } else { + m.UnsetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagShowBottom) + } + if target, ok := c.BeamTarget(); ok { + m[protocol.EntityDataKeyBlockTarget] = protocol.BlockPos{int32(target[0]), int32(target[1]), int32(target[2])} + } + } 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])} @@ -188,6 +188,40 @@ func (s *Session) addSpecificMetadata(e any, m protocol.EntityMetadata) { } } +// nameTagState returns the public name tag of an entity, whether that name tag is shown at all distances +// and whether the entity has a name tag at all. Entities that do not report an always show state show +// their name tag at all distances. +func nameTagState(e any) (string, bool, bool) { + alwaysShow := true + if a, ok := e.(alwaysShowNameTag); ok { + alwaysShow = a.AlwaysShowNameTag() + } + n, ok := e.(named) + if !ok { + return "", alwaysShow, false + } + return n.NameTag(), alwaysShow, true +} + +// writeNameTagMetadata writes a name tag and its related visibility properties to metadata. +func writeNameTagMetadata(m protocol.EntityMetadata, nameTag string, alwaysShow bool) { + show := nameTag != "" + always := show && alwaysShow + + m[protocol.EntityDataKeyName] = nameTag + m[protocol.EntityDataKeyAlwaysShowNameTag] = boolByte(always) + if show { + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagShowName) + } else { + m.UnsetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagShowName) + } + if always { + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagAlwaysShowName) + } else { + m.UnsetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagAlwaysShowName) + } +} + type sneaker interface { Sneaking() bool } @@ -238,10 +272,19 @@ type named interface { NameTag() string } +type alwaysShowNameTag interface { + AlwaysShowNameTag() bool +} + type scoreTag interface { ScoreTag() string } +type endCrystal interface { + ShowBase() bool + BeamTarget() (cube.Pos, bool) +} + type splash interface { Potion() potion.Potion } diff --git a/server/session/handler_anvil.go b/server/session/handler_anvil.go index 143d4c974c..908608046c 100644 --- a/server/session/handler_anvil.go +++ b/server/session/handler_anvil.go @@ -31,7 +31,7 @@ func (h *ItemStackRequestHandler) handleCraftRecipeOptional(a *protocol.CraftRec if !ok { return fmt.Errorf("no anvil container opened") } - if len(filterStrings) < int(a.FilterStringIndex) { + 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) } diff --git a/server/session/handler_crafting.go b/server/session/handler_crafting.go index ae90c902fa..5e04bd5260 100644 --- a/server/session/handler_crafting.go +++ b/server/session/handler_crafting.go @@ -72,7 +72,7 @@ func (h *ItemStackRequestHandler) handleAutoCraft(a *protocol.AutoCraftRecipeSta craft, ok := s.recipes[a.RecipeNetworkID] if !ok { // Try dynamic recipes if no static recipe matches - return h.tryDynamicCraft(s, tx, int(a.TimesCrafted)) + return h.tryDynamicCraft(s, tx, int(a.NumberOfCrafts)) } _, shaped := craft.(recipe.Shaped) _, shapeless := craft.(recipe.Shapeless) @@ -83,7 +83,7 @@ func (h *ItemStackRequestHandler) handleAutoCraft(a *protocol.AutoCraftRecipeSta return fmt.Errorf("recipe with network id %v is not a crafting table recipe", a.RecipeNetworkID) } - timesCrafted := int(a.TimesCrafted) + timesCrafted := int(a.NumberOfCrafts) if timesCrafted < 1 { return fmt.Errorf("times crafted must be at least 1") } diff --git a/server/session/handler_inventory_transaction.go b/server/session/handler_inventory_transaction.go index 24b9a191e7..e8cc2eca77 100644 --- a/server/session/handler_inventory_transaction.go +++ b/server/session/handler_inventory_transaction.go @@ -96,13 +96,14 @@ func (h *InventoryTransactionHandler) handleNormalTransaction(pk *packet.Invento expected item.Stack ) for _, action := range pk.Actions { + windowID, hasWindowID := action.WindowID.Value() 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: + case action.SourceType == protocol.InventoryActionSourceContainer && hasWindowID && 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) } @@ -177,8 +178,11 @@ func (h *InventoryTransactionHandler) handleUseItemOnEntityTransaction(data *pro // 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) + if data.ClientPrediction == protocol.ClientPredictionSuccess || data.ActionType == protocol.UseItemActionBreakBlock { + // Suppress echoing the swing animation only when the client has already predicted it locally. + 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. diff --git a/server/session/handler_item_stack_request.go b/server/session/handler_item_stack_request.go index b7d50b7535..5420e33733 100644 --- a/server/session/handler_item_stack_request.go +++ b/server/session/handler_item_stack_request.go @@ -279,7 +279,7 @@ func (h *ItemStackRequestHandler) handleMineBlock(a *protocol.MineBlockStackRequ // output as usual. func (h *ItemStackRequestHandler) handleCreate(a *protocol.CreateStackRequestAction, s *Session, tx *world.Tx) error { slot := int(a.ResultsSlot) - if len(h.pendingResults) < slot { + if slot >= len(h.pendingResults) { return fmt.Errorf("invalid pending result slot: %v", a.ResultsSlot) } diff --git a/server/session/handler_loom.go b/server/session/handler_loom.go index 7ad41921a4..17fb0ee4d4 100644 --- a/server/session/handler_loom.go +++ b/server/session/handler_loom.go @@ -59,7 +59,10 @@ func (h *ItemStackRequestHandler) handleLoomCraft(a *protocol.CraftLoomRecipeSta // The action contains the pattern that the client wanted to apply, so parse the ID and check if it is a valid // pattern. - expectedPattern := block.BannerPatternByID(a.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. @@ -93,5 +96,7 @@ func (h *ItemStackRequestHandler) handleLoomCraft(a *protocol.CraftLoomRecipeSta Container: protocol.FullContainerName{ContainerID: protocol.ContainerLoomDye}, Slot: loomDyeSlot, }, dye.Grow(-timesCrafted), s, tx) - return h.createResults(s, tx, input.WithItem(b)) + // Only timesCrafted banners are consumed above, so only that many may be produced: input.WithItem keeps the count + // of the whole input stack. + return h.createResults(s, tx, input.Grow(timesCrafted-input.Count()).WithItem(b)) } diff --git a/server/session/handler_npc_request.go b/server/session/handler_npc_request.go index a24e21ea98..30e9d33abd 100644 --- a/server/session/handler_npc_request.go +++ b/server/session/handler_npc_request.go @@ -16,6 +16,10 @@ type NPCRequestHandler struct { // 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 { diff --git a/server/session/handler_player_auth_input.go b/server/session/handler_player_auth_input.go index f75fcba2f3..7871ad97d9 100644 --- a/server/session/handler_player_auth_input.go +++ b/server/session/handler_player_auth_input.go @@ -46,20 +46,19 @@ func (h PlayerAuthInputHandler) handleMovement(pk *packet.PlayerAuthInput, s *Se 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 + // The PlayerAuthInput packet is sent every tick, so don't check for teleport if the position and rotation + // were unchanged. + if !mgl64.FloatEqual(deltaPos.Len(), 0) || !mgl64.FloatEqual(deltaYaw, 0) || !mgl64.FloatEqual(deltaPitch, 0) { + 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.teleportPos.Store(nil) } s.moving = true @@ -70,24 +69,36 @@ func (h PlayerAuthInputHandler) handleMovement(pk *packet.PlayerAuthInput, s *Se // 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 { + data, ok := pk.ItemInteractionData.Value() + if !ok { + return fmt.Errorf("item interaction flag set without item interaction data") + } + if err := h.handleUseItemData(data, s, c); err != nil { return err } } if pk.InputData.Load(packet.InputFlagPerformBlockActions) { - if err := h.handleBlockActions(pk.BlockActions, s, c); err != nil { + actions, ok := pk.BlockActions.Value() + if !ok { + return fmt.Errorf("block actions flag set without block actions") + } + if err := h.handleBlockActions(actions, s, c); err != nil { return err } } h.handleInputFlags(pk.InputData, s, c) if pk.InputData.Load(packet.InputFlagPerformItemStackRequest) { + request, ok := pk.ItemStackRequest.Value() + if !ok { + return fmt.Errorf("item stack request flag set without item stack request") + } 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 { + if err := sh.handleRequest(request, 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()) @@ -97,18 +108,19 @@ func (h PlayerAuthInputHandler) handleActions(pk *packet.PlayerAuthInput, s *Ses } // handleInputFlags handles the toggleable input flags set in a PlayerAuthInput packet. -func (h PlayerAuthInputHandler) handleInputFlags(flags protocol.Bitset, s *Session, c Controllable) { +func (h PlayerAuthInputHandler) handleInputFlags(flags protocol.InputFlags, 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 sneaking := flags.Load(packet.InputFlagSneaking); sneaking != c.Sneaking() { + if sneaking { + c.StartSneaking() + } else { + c.StopSneaking() + } } if flags.Load(packet.InputFlagStartSwimming) { c.StartSwimming() diff --git a/server/session/handler_smithing.go b/server/session/handler_smithing.go index e9f26cd888..1759f74560 100644 --- a/server/session/handler_smithing.go +++ b/server/session/handler_smithing.go @@ -71,6 +71,9 @@ func (h *ItemStackRequestHandler) handleSmithing(a *protocol.CraftRecipeStackReq Slot: smithingTemplateSlot, }, template.Grow(-1), s, tx) + // Only one input item is consumed above, so only one may be produced: input.WithItem keeps the count of the whole + // input stack. + result := input.Grow(1 - input.Count()) if _, ok = craft.(recipe.SmithingTrim); ok { var trim item.ArmourTrim if t, ok := template.Item().(item.SmithingTemplate); ok { @@ -85,7 +88,7 @@ func (h *ItemStackRequestHandler) handleSmithing(a *protocol.CraftRecipeStackReq 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, result.WithItem(trimmable.WithTrim(trim))) } - return h.createResults(s, tx, input.WithItem(craft.Output()[0].Item())) + return h.createResults(s, tx, result.WithItem(craft.Output()[0].Item())) } diff --git a/server/session/player.go b/server/session/player.go index 47696f65df..e8bdce05e7 100644 --- a/server/session/player.go +++ b/server/session/player.go @@ -14,7 +14,6 @@ import ( "github.com/df-mc/dragonfly/server/block" "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/creative" "github.com/df-mc/dragonfly/server/item/inventory" @@ -23,6 +22,7 @@ import ( "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/input" "github.com/df-mc/dragonfly/server/player/skin" "github.com/df-mc/dragonfly/server/world" "github.com/df-mc/dragonfly/server/world/sound" @@ -112,9 +112,16 @@ func (s *Session) sendBiomes() { // sendRecipes sends the current crafting recipes to the session. func (s *Session) sendRecipes() { - recipes := make([]protocol.Recipe, 0, len(recipe.Recipes())) - potionRecipes := make([]protocol.PotionRecipe, 0) - potionContainerChange := make([]protocol.PotionContainerChangeRecipe, 0) + var ( + shapedRecipes []protocol.ShapedRecipe + shapelessRecipes []protocol.ShapelessRecipe + userDataShapelessRecipes []protocol.UserDataShapelessRecipe + multiRecipes []protocol.MultiRecipe + smithingTransformRecipes []protocol.SmithingTransformRecipe + smithingTrimRecipes []protocol.SmithingTrimRecipe + potionRecipes []protocol.PotionRecipe + potionContainerChange []protocol.PotionContainerChangeRecipe + ) for index, i := range recipe.Recipes() { networkID := uint32(index) + 1 @@ -122,7 +129,7 @@ func (s *Session) sendRecipes() { switch i := i.(type) { case recipe.Shapeless: - recipes = append(recipes, &protocol.ShapelessRecipe{ + shapelessRecipes = append(shapelessRecipes, protocol.ShapelessRecipe{ RecipeID: uuid.New().String(), Priority: int32(i.Priority()), Input: stacksToIngredientItems(s.br, i.Input()), @@ -130,8 +137,22 @@ func (s *Session) sendRecipes() { Block: i.Block(), RecipeNetworkID: networkID, }) + case recipe.UserDataShapeless: + userDataShapelessRecipes = append(userDataShapelessRecipes, protocol.UserDataShapelessRecipe{ShapelessRecipe: protocol.ShapelessRecipe{ + RecipeID: uuid.New().String(), + Priority: int32(i.Priority()), + Input: stacksToIngredientItems(s.br, i.Input()), + Output: stacksToRecipeStacks(s.br, i.Output()), + Block: i.Block(), + RecipeNetworkID: networkID, + }}) + case recipe.Multi: + multiRecipes = append(multiRecipes, protocol.MultiRecipe{ + UUID: i.UUID(), + RecipeNetworkID: networkID, + }) case recipe.Shaped: - recipes = append(recipes, &protocol.ShapedRecipe{ + shapedRecipes = append(shapedRecipes, protocol.ShapedRecipe{ RecipeID: uuid.New().String(), Priority: int32(i.Priority()), Width: int32(i.Shape().Width()), @@ -139,11 +160,12 @@ func (s *Session) sendRecipes() { Input: stacksToIngredientItems(s.br, i.Input()), Output: stacksToRecipeStacks(s.br, i.Output()), Block: i.Block(), + AssumeSymmetry: true, RecipeNetworkID: networkID, }) case recipe.SmithingTransform: input, output := stacksToIngredientItems(s.br, i.Input()), stacksToRecipeStacks(s.br, i.Output()) - recipes = append(recipes, &protocol.SmithingTransformRecipe{ + smithingTransformRecipes = append(smithingTransformRecipes, protocol.SmithingTransformRecipe{ RecipeID: uuid.New().String(), Base: input[0], Addition: input[1], @@ -154,7 +176,7 @@ func (s *Session) sendRecipes() { }) case recipe.SmithingTrim: input := stacksToIngredientItems(s.br, i.Input()) - recipes = append(recipes, &protocol.SmithingTrimRecipe{ + smithingTrimRecipes = append(smithingTrimRecipes, protocol.SmithingTrimRecipe{ RecipeID: uuid.New().String(), Base: input[0], Addition: input[1], @@ -188,7 +210,17 @@ func (s *Session) sendRecipes() { }) } } - s.writePacket(&packet.CraftingData{Recipes: recipes, PotionRecipes: potionRecipes, PotionContainerChangeRecipes: potionContainerChange, ClearRecipes: true}) + s.writePacket(&packet.CraftingData{ + ShapedRecipes: shapedRecipes, + ShapelessRecipes: shapelessRecipes, + MultiRecipes: multiRecipes, + UserDataShapelessRecipes: userDataShapelessRecipes, + SmithingTransformRecipes: smithingTransformRecipes, + SmithingTrimRecipes: smithingTrimRecipes, + PotionRecipes: potionRecipes, + PotionContainerChangeRecipes: potionContainerChange, + ClearRecipes: true, + }) } // sendArmourTrimData sends the armour trim data. @@ -279,6 +311,10 @@ func (s *Session) invByID(id int32, tx *world.Tx) (*inventory.Inventory, bool) { switch id { case protocol.ContainerLevelEntity: return s.openedWindow.Load(), true + case protocol.ContainerShulkerBox: + if _, shulkerbox := tx.Block(*s.openedPos.Load()).(block.ShulkerBox); shulkerbox { + return s.openedWindow.Load(), true + } case protocol.ContainerBarrel: if _, barrel := tx.Block(*s.openedPos.Load()).(block.Barrel); barrel { return s.openedWindow.Load(), true @@ -905,6 +941,45 @@ func (s *Session) SendDebugShapes(dim world.Dimension) { s.writePacket(&packet.PrimitiveShapes{Shapes: shapes}) } +// LockInput applies an input lock to the player, disabling the specified input. If the lock is already +// applied, this is a no-op. +func (s *Session) LockInput(l input.Lock) { + s.inputLocksMu.Lock() + defer s.inputLocksMu.Unlock() + s.inputLocks |= l.Uint32() +} + +// UnlockInput removes an input lock from the player, re-enabling the specified input. If the lock is not +// currently applied, this is a no-op. +func (s *Session) UnlockInput(l input.Lock) { + s.inputLocksMu.Lock() + defer s.inputLocksMu.Unlock() + s.inputLocks &^= l.Uint32() +} + +// ClearInputLocks removes all input locks from the player, re-enabling all inputs. +func (s *Session) ClearInputLocks() { + s.inputLocksMu.Lock() + defer s.inputLocksMu.Unlock() + s.inputLocks = 0 +} + +// InputLocked checks if a specific input lock is currently applied to the player. +func (s *Session) InputLocked(l input.Lock) bool { + s.inputLocksMu.RLock() + defer s.inputLocksMu.RUnlock() + return s.inputLocks&l.Uint32() != 0 +} + +// SendInputLocks sends the current input lock state to the client. +func (s *Session) SendInputLocks() { + s.inputLocksMu.RLock() + defer s.inputLocksMu.RUnlock() + s.writePacket(&packet.UpdateClientInputLocks{ + Locks: s.inputLocks, + }) +} + // queueDebugShapeUpdate queues a debug shape mutation to be applied the next time debug shapes are sent. func (s *Session) queueDebugShapeUpdate(update debugShapeUpdate) { s.debugShapesMu.Lock() @@ -940,10 +1015,9 @@ func stackFromItem(br world.BlockRegistry, it item.Stack) protocol.ItemStack { NetworkID: rid, MetadataValue: uint32(meta), }, - HasNetworkID: true, Count: uint16(it.Count()), BlockRuntimeID: int32(blockRuntimeID), - NBTData: nbtconv.WriteItem(it, false), + NBTData: item.WriteNBT(it, false), } } @@ -967,7 +1041,7 @@ func stackToItem(br world.BlockRegistry, it protocol.ItemStack) item.Stack { t = nbter.DecodeNBT(it.NBTData).(world.Item) } st := item.NewStack(t, int(it.Count)) - return nbtconv.Item(it.NBTData, &st) + return item.ReadNBT(it.NBTData, &st) } // instanceFromItem converts an item.Stack to its network ItemInstance representation. @@ -998,16 +1072,13 @@ func stacksToIngredientItems(_ world.BlockRegistry, inputs []recipe.Item) []prot items = append(items, protocol.ItemDescriptorCount{Descriptor: &protocol.InvalidItemDescriptor{}}) continue } - rid, meta, ok := world.ItemRuntimeID(i.Item()) - if !ok { - panic("should never happen") - } - if _, ok = i.Value("variants"); ok { + name, meta := i.Item().EncodeItem() + if _, ok := i.Value("variants"); ok { meta = math.MaxInt16 // Used to indicate that the item has multiple selectable variants. } d = &protocol.DefaultItemDescriptor{ - NetworkID: int16(rid), - MetadataValue: meta, + Name: name, + MetadataValue: int32(meta), } case recipe.ItemTag: d = &protocol.ItemTagItemDescriptor{Tag: i.Tag()} @@ -1025,7 +1096,7 @@ func creativeContent(br world.BlockRegistry) ([]protocol.CreativeGroup, []protoc groups := make([]protocol.CreativeGroup, 0, len(creative.Groups())) for _, group := range creative.Groups() { groups = append(groups, protocol.CreativeGroup{ - Category: int32(group.Category.Uint8()), + Category: group.Category.Uint8(), Name: group.Name, Icon: deleteDamage(stackFromItem(br, group.Icon)), }) @@ -1102,7 +1173,7 @@ func protocolToSkin(sk protocol.Skin) (s skin.Skin, err error) { } // shapeAttachedEntityRuntimeID returns the runtime ID of the entity attached to a debug shape. -func (s *Session) shapeAttachedEntityRuntimeID(shape debug.Shape) int64 { +func (s *Session) shapeAttachedEntityRuntimeID(shape debug.Shape) uint64 { var handle *world.EntityHandle switch shape := shape.(type) { case *debug.Arrow: @@ -1129,19 +1200,19 @@ func (s *Session) shapeAttachedEntityRuntimeID(shape debug.Shape) int64 { if handle == nil { return 0 } - return int64(s.handleRuntimeID(handle)) + return s.handleRuntimeID(handle) } // debugShapeToProtocol converts a debug shape to its protocol representation. It also provides defaults // for some fields such as colour, scale and other per-shape properties. -func debugShapeToProtocol(shape debug.Shape, dim world.Dimension, attachedEntityID int64) protocol.PrimitiveShape { +func debugShapeToProtocol(shape debug.Shape, dim world.Dimension, attachedEntityID uint64) protocol.PrimitiveShape { dimID, _ := world.DimensionID(dim) ps := protocol.PrimitiveShape{ NetworkID: uint64(shape.ShapeID()), DimensionID: protocol.Option(int32(dimID)), } if attachedEntityID > 0 { - ps.AttachedToEntityID = protocol.Option(attachedEntityID) + ps.AttachedToEntityID = protocol.Option(int64(attachedEntityID)) } white := color.RGBA{R: 255, G: 255, B: 255, A: 255} switch shape := shape.(type) { @@ -1256,9 +1327,6 @@ func gameTypeFromMode(mode world.GameMode) int32 { if mode.AllowsFlying() && mode.CreativeInventory() { return packet.GameTypeCreative } - if !mode.Visible() && !mode.HasCollision() { - return packet.GameTypeSurvivalSpectator - } return packet.GameTypeSurvival } diff --git a/server/session/session.go b/server/session/session.go index cdd1a69c19..3154c241e2 100644 --- a/server/session/session.go +++ b/server/session/session.go @@ -101,6 +101,9 @@ type Session struct { viewLayer *world.ViewLayer + inputLocksMu sync.RWMutex + inputLocks uint32 + closeBackground chan struct{} br world.BlockRegistry @@ -162,6 +165,9 @@ type Config struct { JoinMessage, QuitMessage chat.Translation + // HandleStop is called once when the Session is closed. The transaction is + // nil if the Controllable could not be restored to any world, such as when + // both its current world and respawn destination closed during teardown. HandleStop func(*world.Tx, Controllable) // BlockRegistry overrides the registry used for network serialization. If nil, world.DefaultBlockRegistry is used. BlockRegistry world.BlockRegistry @@ -286,6 +292,9 @@ func (s *Session) Spawn(c Controllable, tx *world.Tx) { // 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. +// A nil transaction may be passed for a Controllable that is no longer in any +// world; world-bound teardown (container close, chunk loader, entity removal) +// is then skipped. func (s *Session) Close(tx *world.Tx, c Controllable) { s.once.Do(func() { s.close(tx, c) @@ -295,8 +304,10 @@ func (s *Session) Close(tx *world.Tx, c Controllable) { // 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 tx != nil { + c.MoveItemsToInventory() + s.closeCurrentContainer(tx, false) + } if s.viewLayer != nil { _ = s.viewLayer.Close() } @@ -308,7 +319,9 @@ func (s *Session) close(tx *world.Tx, c Controllable) { _ = s.offHand.Close() _ = s.armour.Close() - s.chunkLoader.Close(tx) + if tx != nil { + s.chunkLoader.Close(tx) + } if !s.conf.QuitMessage.Zero() { chat.Global.Writet(s.conf.QuitMessage, s.conn.IdentityData().DisplayName) @@ -317,7 +330,9 @@ func (s *Session) close(tx *world.Tx, c Controllable) { // Note: Be aware of where RemoveEntity is called. This must not be done too // early. - tx.RemoveEntity(c) + if tx != nil { + tx.RemoveEntity(c) + } _ = s.ent.Close() // This should always be called last due to the timing of the removal of @@ -348,6 +363,22 @@ func (s *Session) Latency() time.Duration { return s.conn.Latency() } +// withControllable runs f with the current Controllable on its world owner. +// It is for off-owner session goroutines; callbacks that already have a +// *world.Tx should use it directly instead. +func (s *Session) withControllable(ctx context.Context, f func(tx *world.Tx, c Controllable) error) error { + _, err := world.CallRef(ctx, world.NewEntityRef[Controllable](s.ent), func(tx *world.Tx, c Controllable) (struct{}, error) { + return struct{}{}, f(tx, c) + }) + return err +} + +// sessionOwnerStopped reports whether err means the session's player can no +// longer run owner callbacks, so session goroutines should stop quietly. +func sessionOwnerStopped(err error) bool { + return errors.Is(err, world.ErrEntityClosed) || errors.Is(err, world.ErrWorldClosed) || errors.Is(err, world.ErrTaskCancelled) +} + // ClientData returns the login.ClientData of the underlying *minecraft.Conn. func (s *Session) ClientData() login.ClientData { return s.conn.ClientData() @@ -360,24 +391,33 @@ func (s *Session) handlePackets() { // 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() - }) + if err := s.withControllable(context.Background(), func(_ *world.Tx, c Controllable) error { + _ = c.Close() + return nil + }); err != nil && !sessionOwnerStopped(err) { + s.conf.Log.Debug("close controllable: " + err.Error()) + } // 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)) - }) + if err := s.withControllable(context.Background(), func(tx *world.Tx, c Controllable) error { + s.Close(tx, c) + return nil + }); err != nil && !sessionOwnerStopped(err) { + s.conf.Log.Debug("close session: " + err.Error()) + } }() 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)) + err = s.withControllable(context.Background(), func(tx *world.Tx, c Controllable) error { + return s.handlePacket(pk, tx, c) }) if err != nil { + if sessionOwnerStopped(err) { + return + } s.conf.Log.Debug("process packet: " + err.Error()) return } @@ -396,20 +436,23 @@ func (s *Session) background() { 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) - }) + if err := s.withControllable(context.Background(), func(_ *world.Tx, c Controllable) error { + r = s.sendAvailableCommands(c, softEnums) + enums, enumValues = s.enums(c) + return nil + }); err != nil { + if !sessionOwnerStopped(err) { + s.conf.Log.Debug("prepare command updates: " + err.Error()) + } + return + } 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 err := s.withControllable(context.Background(), func(tx *world.Tx, c Controllable) error { 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. @@ -422,7 +465,13 @@ func (s *Session) background() { } } s.sendChunks(tx, c) - }) + return nil + }); err != nil { + if !sessionOwnerStopped(err) { + s.conf.Log.Debug("update session background: " + err.Error()) + } + return + } case <-s.closeBackground: return } diff --git a/server/session/session_list.go b/server/session/session_list.go index dbd50e0c81..c72e615065 100644 --- a/server/session/session_list.go +++ b/server/session/session_list.go @@ -78,12 +78,13 @@ func (l *sessionList) sendSessionTo(s, to *Session) { to.entityMutex.Unlock() to.writePacket(&packet.PlayerList{ - ActionType: packet.PlayerListActionAdd, Entries: []protocol.PlayerListEntry{{ + ActionType: protocol.PlayerListActionAdd, UUID: s.ent.UUID(), EntityUniqueID: int64(runtimeID), Username: s.conn.IdentityData().DisplayName, XUID: s.conn.IdentityData().XUID, + BuildPlatform: int32(protocol.DeviceUnknown), Skin: skinToProtocol(s.joinSkin), }}, }) @@ -96,8 +97,10 @@ func (l *sessionList) unsendSessionFrom(s, from *Session) { from.entityMutex.Unlock() from.writePacket(&packet.PlayerList{ - ActionType: packet.PlayerListActionRemove, - Entries: []protocol.PlayerListEntry{{UUID: s.ent.UUID()}}, + Entries: []protocol.PlayerListEntry{{ + ActionType: protocol.PlayerListActionRemove, + UUID: s.ent.UUID(), + }}, }) } @@ -127,6 +130,10 @@ func skinToProtocol(s skin.Skin) protocol.Skin { if fullID == "" { fullID = uuid.New().String() } + model := s.Model + if len(model) == 0 { + model = []byte("{}") + } return protocol.Skin{ PlayFabID: s.PlayFabID, SkinID: uuid.New().String(), @@ -137,7 +144,7 @@ func skinToProtocol(s skin.Skin) protocol.Skin { CapeImageWidth: uint32(s.Cape.Bounds().Max.X), CapeImageHeight: uint32(s.Cape.Bounds().Max.Y), CapeData: s.Cape.Pix, - SkinGeometry: s.Model, + SkinGeometry: model, PersonaSkin: s.Persona, CapeID: uuid.New().String(), FullID: fullID, diff --git a/server/session/text.go b/server/session/text.go index d8418df4a8..9a4899a077 100644 --- a/server/session/text.go +++ b/server/session/text.go @@ -95,9 +95,10 @@ func (s *Session) SendScoreboard(sb *scoreboard.Scoreboard) { 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} + pk := &packet.SetScore{} for i := range currentLines { pk.Entries = append(pk.Entries, protocol.ScoreboardEntry{ + IdentityType: protocol.ScoreboardIdentityRemove, EntryID: int64(i), ObjectiveName: currentName, Score: int32(i), @@ -107,7 +108,7 @@ func (s *Session) SendScoreboard(sb *scoreboard.Scoreboard) { s.writePacket(pk) } } - pk := &packet.SetScore{ActionType: packet.ScoreboardActionModify} + pk := &packet.SetScore{} for k, line := range sb.Lines() { if len(line) == 0 { line = "§" + colours[k] diff --git a/server/session/view_layer.go b/server/session/view_layer.go index 632b580129..f9194a9184 100644 --- a/server/session/view_layer.go +++ b/server/session/view_layer.go @@ -24,6 +24,22 @@ func (s *Session) ViewPublicNameTag(entity world.Entity) { s.viewLayer.ViewPublicNameTag(entity) } +// ViewAlwaysShowNameTag overrides whether the entity's name tag is shown at all distances for this session. +func (s *Session) ViewAlwaysShowNameTag(entity world.Entity, alwaysShow bool) { + if s.viewLayer == nil { + return + } + s.viewLayer.ViewAlwaysShowNameTag(entity, alwaysShow) +} + +// ViewPublicAlwaysShowNameTag removes the always-show name tag override from the entity for this session. +func (s *Session) ViewPublicAlwaysShowNameTag(entity world.Entity) { + if s.viewLayer == nil { + return + } + s.viewLayer.ViewPublicAlwaysShowNameTag(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 { diff --git a/server/session/world.go b/server/session/world.go index d624576ded..5b7106a8ba 100644 --- a/server/session/world.go +++ b/server/session/world.go @@ -81,10 +81,12 @@ func (s *Session) ViewEntity(e world.Entity) { case Controllable: _, actualPlayer := sessions.Lookup(v.UUID()) if !actualPlayer { - s.writePacket(&packet.PlayerList{ActionType: packet.PlayerListActionAdd, Entries: []protocol.PlayerListEntry{{ + s.writePacket(&packet.PlayerList{Entries: []protocol.PlayerListEntry{{ + ActionType: protocol.PlayerListActionAdd, UUID: v.UUID(), EntityUniqueID: int64(runtimeID), Username: v.Name(), + BuildPlatform: int32(protocol.DeviceUnknown), Skin: skinToProtocol(v.Skin()), }}}) } @@ -99,6 +101,7 @@ func (s *Session) ViewEntity(e world.Entity) { UUID: v.UUID(), Username: v.Name(), Yaw: float32(yaw), + BuildPlatform: int32(protocol.DeviceUnknown), AbilityData: protocol.AbilityData{ EntityUniqueID: int64(runtimeID), Layers: []protocol.AbilityLayer{{ @@ -108,8 +111,9 @@ func (s *Session) ViewEntity(e world.Entity) { }, }) if !actualPlayer { - s.writePacket(&packet.PlayerList{ActionType: packet.PlayerListActionRemove, Entries: []protocol.PlayerListEntry{{ - UUID: v.UUID(), + s.writePacket(&packet.PlayerList{Entries: []protocol.PlayerListEntry{{ + ActionType: protocol.PlayerListActionRemove, + UUID: v.UUID(), }}}) } else { s.ViewSkin(e) @@ -152,6 +156,7 @@ func (s *Session) ViewEntity(e world.Entity) { Pitch: float32(pitch), Yaw: float32(yaw), HeadYaw: float32(yaw), + BodyYaw: float32(yaw), }) } @@ -196,11 +201,26 @@ func (s *Session) ViewEntityMovement(e world.Entity, pos mgl64.Vec3, rot cube.Ro if (id == selfEntityRuntimeID && s.moving) || s.entityHidden(e) { return } + s.viewEntityAbsoluteMovement(id, e, pos, rot, onGround, false) +} + +// ViewEntityDisplacement ... +func (s *Session) ViewEntityDisplacement(e world.Entity, pos mgl64.Vec3, rot cube.Rotation, onGround bool) { + if s.entityHidden(e) { + return + } + id := s.entityRuntimeID(e) + s.viewEntityAbsoluteMovement(id, e, pos, rot, onGround, true) +} +func (s *Session) viewEntityAbsoluteMovement(id uint64, e world.Entity, pos mgl64.Vec3, rot cube.Rotation, onGround, authoritative bool) { flags := byte(0) if onGround { flags |= packet.MoveFlagOnGround } + if authoritative { + flags |= packet.MoveFlagTeleport + } s.writePacket(&packet.MoveActorAbsolute{ EntityRuntimeID: id, Position: vec64To32(pos.Add(entityOffset(e))), @@ -259,6 +279,9 @@ func (s *Session) ViewEntityTeleport(e world.Entity, position mgl64.Vec3) { Yaw: float32(yaw), HeadYaw: float32(yaw), Mode: packet.MoveModeTeleport, + TeleportData: protocol.Option(protocol.TeleportData{ + TeleportCause: packet.TeleportCauseUnknown, + }), }) return } @@ -312,6 +335,9 @@ func (s *Session) ViewEntityArmour(e world.Entity) { } inv := armoured.Armour() + if inv == nil { + return + } // Show the entity's armour s.writePacket(&packet.MobArmourEquipment{ @@ -657,6 +683,10 @@ func (s *Session) playSound(pos mgl64.Vec3, t world.Sound, disableRelative bool) pk.SoundType = packet.SoundEventExtinguishFire case sound.Ignite: pk.SoundType = packet.SoundEventIgnite + case sound.EnderEyePlaced: + pk.SoundType = packet.SoundEventEnderEyePlaced + case sound.EndPortalCreated: + pk.SoundType = packet.SoundEventEndPortalCreated case sound.Burning: pk.SoundType = packet.SoundEventPlayerHurtOnFire case sound.Drowning: @@ -853,6 +883,11 @@ func (s *Session) playSound(pos mgl64.Vec3, t world.Sound, disableRelative bool) EventType: packet.LevelEventSoundTotemUsed, Position: vec64To32(pos), }) + return + case sound.ShulkerBoxClose: + pk.SoundType = packet.SoundEventShulkerBoxClosed + case sound.ShulkerBoxOpen: + pk.SoundType = packet.SoundEventShulkerBoxOpen case sound.DecoratedPotInserted: s.writePacket(&packet.PlaySound{ SoundName: "block.decorated_pot.insert", @@ -863,6 +898,14 @@ func (s *Session) playSound(pos mgl64.Vec3, t world.Sound, disableRelative bool) return case sound.DecoratedPotInsertFailed: pk.SoundType = packet.SoundEventDecoratedPotInsertFail + case sound.Custom: + s.writePacket(&packet.PlaySound{ + SoundName: so.Name, + Position: vec64To32(pos), + Volume: float32(so.Volume), + Pitch: float32(so.Pitch), + }) + return case sound.LightningExplode: s.writePacket(&packet.PlaySound{ SoundName: "ambient.weather.lightning.impact", @@ -1084,37 +1127,26 @@ func (s *Session) entityMetadata(e world.Entity) protocol.EntityMetadata { if s.viewLayer == nil { return metadata } - if nt, ok := s.viewLayer.NameTag(e); ok { - metadata[protocol.EntityDataKeyName] = nt - if nt != "" { - metadata[protocol.EntityDataKeyAlwaysShowNameTag] = uint8(1) - if !metadata.Flag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagAlwaysShowName) { - metadata.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagAlwaysShowName) - } - if !metadata.Flag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagShowName) { - metadata.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagShowName) - } - } else { - metadata[protocol.EntityDataKeyAlwaysShowNameTag] = uint8(0) - if metadata.Flag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagAlwaysShowName) { - metadata.UnsetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagAlwaysShowName) - } - if metadata.Flag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagShowName) { - metadata.UnsetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagShowName) - } + nt, ntSet := s.viewLayer.NameTag(e) + as, asSet := s.viewLayer.AlwaysShowNameTag(e) + if ntSet || asSet { + nameTag, alwaysShow, _ := nameTagState(e) + if ntSet { + nameTag = nt + } + if asSet { + alwaysShow = as } + writeNameTagMetadata(metadata, nameTag, alwaysShow) } if st, ok := s.viewLayer.ScoreTag(e); ok { metadata[protocol.EntityDataKeyScore] = st } if visibility := s.viewLayer.Visibility(e); visibility.EnforceVisibility() { - invisibleFlag := metadata.Flag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagInvisible) - shouldForceVisible := visibility == world.EnforceVisible() && invisibleFlag - shouldForceInvisible := visibility == world.EnforceInvisible() && !invisibleFlag - if shouldForceVisible { - metadata.UnsetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagInvisible) - } else if shouldForceInvisible { + if visibility == world.EnforceInvisible() { metadata.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagInvisible) + } else { + metadata.UnsetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagInvisible) } } return metadata @@ -1259,6 +1291,10 @@ func (s *Session) ViewBlockAction(pos cube.Pos, a world.BlockAction) { EventType: packet.BlockEventChangeChestState, }) case block.StartCrackAction: + if t.BreakTime <= 0 { + // An instant break has no cracking to animate, and encoding the crack speed would divide by zero. + break + } s.writePacket(&packet.LevelEvent{ EventType: packet.LevelEventStartBlockCracking, Position: vec64To32(pos.Vec3()), @@ -1271,6 +1307,9 @@ func (s *Session) ViewBlockAction(pos cube.Pos, a world.BlockAction) { EventData: 0, }) case block.ContinueCrackAction: + if t.BreakTime <= 0 { + break + } s.writePacket(&packet.LevelEvent{ EventType: packet.LevelEventUpdateBlockCracking, Position: vec64To32(pos.Vec3()), diff --git a/server/world/biome.go b/server/world/biome.go index 9aa4e32544..3f49197d8c 100644 --- a/server/world/biome.go +++ b/server/world/biome.go @@ -77,12 +77,28 @@ func (br *BiomeRegistry) Biomes() []Biome { return bs } -// ocean returns an ocean biome. +// ocean returns the ocean biome, or an unknown biome if no biome is registered. func ocean() Biome { - o, _ := DefaultBiomes.BiomeByID(0) - return o + if o, ok := DefaultBiomes.BiomeByID(0); ok { + return o + } + return unknownBiome{} +} + +// unknownBiome is returned in place of a Biome that is not registered. It encodes back to the ID it was read from. +type unknownBiome struct { + id int } +func (unknownBiome) Temperature() float64 { return 0.5 } +func (unknownBiome) Rainfall() float64 { return 0 } +func (unknownBiome) Depth() float64 { return 0.1 } +func (unknownBiome) Scale() float64 { return 0.1 } +func (unknownBiome) WaterColour() color.RGBA { return color.RGBA{R: 0x44, G: 0xaf, B: 0xf5, A: 0xff} } +func (unknownBiome) Tags() []string { return nil } +func (unknownBiome) String() string { return "unknown" } +func (b unknownBiome) EncodeBiome() int { return b.id } + func RegisterBiome(b Biome) { DefaultBiomes.Register(b) } diff --git a/server/world/block.go b/server/world/block.go index c32a42f9dd..52034ac942 100644 --- a/server/world/block.go +++ b/server/world/block.go @@ -75,56 +75,6 @@ type Liquid interface { LiquidRemoveBlock(pos cube.Pos, tx *Tx, removed Block) } -// Conductor represents a block that can conduct a redstone signal. -type Conductor interface { - Block - // RedstoneSource returns true if the conductor is a signal source. - RedstoneSource() bool - - // WeakPower returns the weak power level emitted by this conductor toward a neighbouring receiver. - // The face argument is relative to the receiving block, not this conductor. - // Weak power can pass through a solid block to power redstone components on the other side, but - // cannot power solid blocks themselves or travel further. - // The accountForDust parameter indicates whether redstone dust should be considered when - // calculating power levels. - WeakPower(pos cube.Pos, face cube.Face, tx *Tx, accountForDust bool) int - - // StrongPower returns the strong power level emitted by this conductor toward a neighbouring - // receiver. The face argument uses the same convention as WeakPower. - // Strong power can be transmitted through solid blocks. When a solid block receives strong power - // through one of its faces, it can provide weak power to adjacent redstone components on all other - // faces. Strong power can also directly power any redstone component. - // The accountForDust parameter indicates whether redstone dust should be considered when - // calculating power levels. - StrongPower(pos cube.Pos, face cube.Face, tx *Tx, accountForDust bool) int -} - -// WeakBlockPowerer represents a conductor whose weak power may weakly power an adjacent conductive block. Weakly -// powered blocks may activate mechanisms and repeaters, but do not power adjacent redstone dust. For example, -// dust pointing into a stone block opens a door on the stone's far side, but a second stretch of dust there stays -// dark. -type WeakBlockPowerer interface { - Conductor - // WeaklyPowersBlocks returns true if this conductor's WeakPower can make an adjacent conductive block weakly powered. - WeaklyPowersBlocks() bool -} - -// RedstonePowerRelayer represents a block with custom behaviour for whether -// neighbouring redstone power may be relayed through it by Tx.RedstonePower. -type RedstonePowerRelayer interface { - Block - // RelaysRedstonePowerThrough reports whether this non-conductor block may - // relay neighbouring redstone power through itself to receivers on its other sides. - RelaysRedstonePowerThrough() bool -} - -// RedstoneUpdater represents a block that reacts to nearby redstone power changes. -type RedstoneUpdater interface { - Block - // RedstoneUpdate is called when a change in redstone signal is computed. - RedstoneUpdate(pos cube.Pos, tx *Tx) -} - // RegisterBlock registers the Block passed in the DefaultBlockRegistry. // // This function exists for backwards compatibility and works well for the common "single server per process" setup, diff --git a/server/world/block_search.go b/server/world/block_search.go new file mode 100644 index 0000000000..79a773c247 --- /dev/null +++ b/server/world/block_search.go @@ -0,0 +1,103 @@ +package world + +import ( + "errors" + "iter" + "slices" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world/chunk" + "github.com/df-mc/goleveldb/leveldb" +) + +// blocksWithin implements Tx.BlocksWithin. It must only be called during a transaction. +func (w *World) blocksWithin(pos cube.Pos, radius int, blocks ...Block) iter.Seq[cube.Pos] { + return func(yield func(cube.Pos) bool) { + if radius <= 0 || len(blocks) == 0 { + return + } + targets := make([]uint32, 0, len(blocks)) + for _, b := range blocks { + targets = append(targets, w.conf.Blocks.BlockRuntimeID(b)) + } + + // Horizontal bounds of the search: min inclusive, max exclusive. + minX, minZ := pos.X()-radius, pos.Z()-radius + maxX, maxZ := pos.X()+radius, pos.Z()+radius + + minChunk := chunkPosFromBlockPos(cube.Pos{minX, 0, minZ}) + maxChunk := chunkPosFromBlockPos(cube.Pos{maxX - 1, 0, maxZ - 1}) + var logged bool + for chunkX := minChunk.X(); chunkX <= maxChunk.X(); chunkX++ { + for chunkZ := minChunk.Z(); chunkZ <= maxChunk.Z(); chunkZ++ { + chunkPos := ChunkPos{chunkX, chunkZ} + var c *chunk.Chunk + if col, ok := w.chunks[chunkPos]; ok { + c = col.Chunk + } else { + col, err := w.conf.Provider.LoadColumn(chunkPos, w.conf.Dim) + if err != nil { + if !errors.Is(err, leveldb.ErrNotFound) && !logged { + // Log only the first error: a systemic provider failure would otherwise log once per chunk. + w.conf.Log.Error("blocks within: "+err.Error(), "X", chunkX, "Z", chunkZ) + logged = true + } + continue + } + c = col.Chunk + } + if !yieldMatchingBlocks(yield, chunkPos, c, targets, minX, minZ, maxX, maxZ) { + return + } + } + } + } +} + +// yieldMatchingBlocks yields the positions of blocks in the primary layer of a chunk that match one of the target +// runtime IDs and fall within the horizontal bounds passed. It returns false if the iteration was stopped. +func yieldMatchingBlocks(yield func(cube.Pos) bool, chunkPos ChunkPos, c *chunk.Chunk, targets []uint32, minX, minZ, maxX, maxZ int) bool { + baseX, baseZ := int(chunkPos.X())<<4, int(chunkPos.Z())<<4 + // Clip the block iteration bounds to the search area once per chunk. + x0, x1 := max(minX-baseX, 0), min(maxX-baseX, 16) + z0, z1 := max(minZ-baseZ, 0), min(maxZ-baseZ, 16) + for i, sub := range c.Sub() { + if sub.Empty() { + continue + } + layers := sub.Layers() + if len(layers) == 0 { + continue + } + storage := layers[0] + indices := matchingPaletteIndices(storage.Palette(), targets) + if len(indices) == 0 { + continue + } + baseY := int(c.SubY(int16(i))) + for x := x0; x < x1; x++ { + for z := z0; z < z1; z++ { + for y := range 16 { + if !slices.Contains(indices, storage.PaletteIndex(byte(x), byte(y), byte(z))) { + continue + } + if !yield(cube.Pos{baseX + x, baseY + y, baseZ + z}) { + return false + } + } + } + } + } + return true +} + +// matchingPaletteIndices returns the indices in the palette that hold one of the target runtime IDs. +func matchingPaletteIndices(palette *chunk.Palette, targets []uint32) []uint16 { + var indices []uint16 + for i := 0; i < palette.Len(); i++ { + if slices.Contains(targets, palette.Value(uint16(i))) { + indices = append(indices, uint16(i)) + } + } + return indices +} diff --git a/server/world/block_source.go b/server/world/block_source.go index 42d8fe5abe..4af7677ece 100644 --- a/server/world/block_source.go +++ b/server/world/block_source.go @@ -8,7 +8,7 @@ type BlockSource interface { Block(cube.Pos) Block } -// worldSource is a wrapper around a World that implements BlockSource. -type worldSource struct{ w *World } +// worldSource is a wrapper around a world transaction that implements BlockSource. +type worldSource struct{ tx *Tx } -func (w worldSource) Block(pos cube.Pos) Block { return w.w.block(pos) } +func (w worldSource) Block(pos cube.Pos) Block { return w.tx.block(pos) } diff --git a/server/world/block_states.nbt b/server/world/block_states.nbt index 1e4d94632c..7006d9d462 100644 Binary files a/server/world/block_states.nbt and b/server/world/block_states.nbt differ diff --git a/server/world/chunk/block_registry.go b/server/world/chunk/block_registry.go index d5a749dde4..24888d2bae 100644 --- a/server/world/chunk/block_registry.go +++ b/server/world/chunk/block_registry.go @@ -27,4 +27,6 @@ type BlockRegistry interface { LiquidBlock(rid uint32) bool // HashToRuntimeID resolves a "network block hash" to a runtime ID. HashToRuntimeID(hash uint32) (rid uint32, ok bool) + // RuntimeIDToHash resolves a runtime ID to its "network block hash". + RuntimeIDToHash(runtimeID uint32) (hash uint32, ok bool) } diff --git a/server/world/chunk/decode.go b/server/world/chunk/decode.go index da535e8491..ac34b1dcee 100644 --- a/server/world/chunk/decode.go +++ b/server/world/chunk/decode.go @@ -24,17 +24,20 @@ func NetworkDecode(br BlockRegistry, data []byte, count int, r cube.Range, hashe // The sub chunk count passed must be that found in the LevelChunk packet. // noinspection GoUnusedExportedFunction func NetworkDecodeBuffer(br BlockRegistry, buf *bytes.Buffer, count int, r cube.Range, hashedRids bool) (*Chunk, []map[string]any, error) { - var ( - c = New(br, r) - err error - ) - + c := New(br, r) + if count < 0 || count > len(c.sub) { + return nil, nil, fmt.Errorf("invalid sub-chunk count %d: chunk range has %d sub-chunks", count, len(c.sub)) + } for i := 0; i < count; i++ { index := uint8(i) - c.sub[index], err = decodeSubChunk(buf, c, &index, NetworkEncoding, hashedRids) + sub, err := decodeSubChunk(buf, c, &index, NetworkEncoding, hashedRids) if err != nil { return nil, nil, err } + if int(index) >= len(c.sub) { + return nil, nil, fmt.Errorf("invalid sub-chunk index %d: chunk range has %d sub-chunks", index, len(c.sub)) + } + c.sub[index] = sub } err = DecodeNetworkBiomes(c, buf) diff --git a/server/world/chunk/paletted_storage.go b/server/world/chunk/paletted_storage.go index ac49c67c44..f20fcd809a 100644 --- a/server/world/chunk/paletted_storage.go +++ b/server/world/chunk/paletted_storage.go @@ -75,6 +75,12 @@ func (storage *PalettedStorage) At(x, y, z byte) uint32 { return storage.palette.Value(storage.paletteIndex(x&15, y&15, z&15)) } +// PaletteIndex returns the index in the Palette that the value at a given x, y and z points to. It is a cheaper +// alternative to At when scanning a storage for specific palette entries, as it does not dereference the Palette. +func (storage *PalettedStorage) PaletteIndex(x, y, z byte) uint16 { + return storage.paletteIndex(x&15, y&15, z&15) +} + // Set sets a value at a specific x, y and z. The Palette and PalettedStorage are expanded // automatically to make space for the value, should that be needed. func (storage *PalettedStorage) Set(x, y, z byte, v uint32) { diff --git a/server/world/chunk_request.go b/server/world/chunk_request.go new file mode 100644 index 0000000000..ef35909eea --- /dev/null +++ b/server/world/chunk_request.go @@ -0,0 +1,143 @@ +package world + +import ( + "sync" + + "github.com/df-mc/dragonfly/server/world/chunk" +) + +// chunkRequest tracks a chunk that is being loaded or generated in the +// background. All callers waiting for the same chunk share a single request. +type chunkRequest struct { + pos ChunkPos + callbacks []chunkCallback + signalled bool + + done chan struct{} + col *chunk.Column + err error + result *Column +} + +// defaultChunkLoadWorkers is the number of chunk load workers started when +// Config.ChunkLoadWorkers is not set. +const defaultChunkLoadWorkers = 1 + +// chunkCallback is called with a chunk once it has been added to the world. +type chunkCallback = func(tx *Tx, col *Column) + +// chunkWorkerPool runs chunk requests on a fixed number of background workers. +type chunkWorkerPool struct { + w *World + queue chan *chunkRequest + wg sync.WaitGroup + + mu sync.Mutex + closed bool +} + +func newChunkWorkerPool(w *World) *chunkWorkerPool { + return &chunkWorkerPool{w: w, queue: make(chan *chunkRequest, 4096)} +} + +// doImmediate blocks until the chunk is ready and returns it. +func (r *chunkRequest) doImmediate(tx *Tx) *Column { + <-r.done + r.signal(tx) + return r.result +} + +// load loads or generates the chunk and hands it back to the world to be +// added. +func (r *chunkRequest) load(w *World) { + r.col, r.err = w.loadChunk(r.pos) + close(r.done) + w.Do(r.signal) +} + +// abort cancels a request that will never be carried out because the world is +// closing, releasing any callers waiting on it. +func (r *chunkRequest) abort() { + close(r.done) +} + +// schedule hands r to the workers without blocking. It returns false if the +// request cannot be accepted, e.g. when the world is closing. +func (p *chunkWorkerPool) schedule(r *chunkRequest) bool { + p.mu.Lock() + defer p.mu.Unlock() + if p.closed || p.w.closed.Load() { + p.closed = true + return false + } + select { + case p.queue <- r: + return true + default: + return false + } +} + +// handle continuously processes chunk requests until the world starts closing. +func (p *chunkWorkerPool) handle() { + defer p.wg.Done() + for { + if p.w.closed.Load() { + p.drainAndAbort() + return + } + select { + case r := <-p.queue: + r.load(p.w) + case <-p.w.closeStarted: + p.drainAndAbort() + return + } + } +} + +// drainAndAbort cancels all remaining requests and stops accepting new ones. +func (p *chunkWorkerPool) drainAndAbort() { + p.mu.Lock() + defer p.mu.Unlock() + p.closed = true + for { + select { + case r := <-p.queue: + r.abort() + default: + return + } + } +} + +// signal adds the finished chunk to the world and calls all callers waiting +// for it. It always runs inside a world transaction. +func (r *chunkRequest) signal(tx *Tx) { + if r.signalled { + return + } + r.signalled = true + + w := tx.World() + pos := r.pos + + delete(w.chunkRequests, pos) + if w.closed.Load() { + return + } + if r.err != nil { + w.conf.Log.Error("load chunk: "+r.err.Error(), "X", pos[0], "Z", pos[1]) + for _, recv := range r.callbacks { + recv(tx, nil) + } + return + } + r.result = w.addChunk(pos, r.col) + if w.closed.Load() { + return + } + for _, recv := range r.callbacks { + recv(tx, r.result) + } +} diff --git a/server/world/conf.go b/server/world/conf.go index 4c8092b820..380a1bcf0a 100644 --- a/server/world/conf.go +++ b/server/world/conf.go @@ -49,6 +49,10 @@ type Config struct { // ChunkUnloadInterval should not be used to prevent chunks from unloading // altogether. This should be done using a Loader with a custom Viewer. ChunkUnloadInterval time.Duration + // ChunkLoadWorkers is the number of background workers that load and generate + // chunks, defaulting to 1. Values above 1 generate chunks concurrently and + // require a concurrency-safe Generator. + ChunkLoadWorkers int // RandomTickSpeed specifies the rate at which blocks should be ticked in // the World. By default, each sub chunk has 3 blocks randomly ticked per // sub chunk, so the default value is 3. Setting this value to -1 or lower @@ -73,6 +77,19 @@ type Config struct { Blocks BlockRegistry Biomes *BiomeRegistry + + // Synchronous removes the World's own background goroutines. Immediate tasks + // from World.Do and Call run on the calling goroutine, the World is not saved + // or unloaded automatically, and time only passes on explicit + // World.AdvanceTick calls. World.DoAfter and entity work scheduled before an + // entity enters a world still use background goroutines and wall-clock + // delays; callers must synchronise on the returned Task. This makes + // Synchronous Worlds well suited to unit tests that need a World to interact + // with. + // A Synchronous World must be driven from one goroutine. Do, Call and + // AdvanceTick are not safe to call concurrently, including from delayed + // item or death callbacks. + Synchronous bool } // New creates a new World using the Config conf. The World returned will start @@ -90,6 +107,9 @@ func (conf Config) New() *World { if conf.ChunkUnloadInterval <= 0 { conf.ChunkUnloadInterval = time.Minute * 2 } + if conf.ChunkLoadWorkers <= 0 { + conf.ChunkLoadWorkers = defaultChunkLoadWorkers + } if conf.Generator == nil { conf.Generator = NopGenerator{} } @@ -119,12 +139,23 @@ func (conf Config) New() *World { conf.RandSource = rand.NewPCG(t, t) } s := conf.Provider.Settings() + + // Serialise Provider calls (made by both the owner and the chunk load workers) + // and, with a single worker, Generator calls, for implementations that aren't + // concurrency-safe. + conf.Provider = &lockedProvider{p: conf.Provider} + if conf.ChunkLoadWorkers == 1 { + conf.Generator = &lockedGenerator{g: conf.Generator} + } w := &World{ scheduledUpdates: newScheduledTickQueue(s.CurrentTick), + redstone: newRedstoneEngine(s.CurrentTick), entities: make(map[*EntityHandle]ChunkPos), viewers: make(map[*Loader]Viewer), chunks: make(map[ChunkPos]*Column), + chunkRequests: make(map[ChunkPos]*chunkRequest), queueClosing: make(chan struct{}), + closeStarted: make(chan struct{}), closing: make(chan struct{}), queue: make(chan transaction, 128), r: rand.New(conf.RandSource), @@ -133,18 +164,25 @@ func (conf Config) New() *World { ra: conf.Dim.Range(), set: s, } + w.chunkWorkers = newChunkWorkerPool(w) w.weather = weather{w: w} var h Handler = NopHandler{} w.handler.Store(&h) - w.queueing.Add(1) - w.running.Add(2) - t := ticker{interval: time.Second / 20} - go t.tickLoop(w) - go w.autoSave() - go w.handleTransactions() + if !conf.Synchronous { + w.queueing.Add(1) + w.running.Add(2) + + go t.tickLoop(w) + go w.autoSave() + go w.handleTransactions() + w.chunkWorkers.wg.Add(conf.ChunkLoadWorkers) + for range conf.ChunkLoadWorkers { + go w.chunkWorkers.handle() + } + } - <-w.Exec(t.tick) + <-w.exec(t.tick) return w } diff --git a/server/world/dimension.go b/server/world/dimension.go index 6eb0d818dc..a61b493e2c 100644 --- a/server/world/dimension.go +++ b/server/world/dimension.go @@ -1,6 +1,9 @@ package world import ( + "fmt" + "math" + "slices" "time" "github.com/df-mc/dragonfly/server/block/cube" @@ -41,6 +44,14 @@ func DimensionID(dim Dimension) (int, bool) { type dimensionRegistry struct { dimensions map[int]Dimension ids map[Dimension]int + custom []DimensionRegistration +} + +// DimensionRegistration holds a custom dimension's registration data. +type DimensionRegistration struct { + ID int + Name string + Dimension Dimension } // newDimensionRegistry returns an initialised dimensionRegistry. @@ -75,6 +86,54 @@ func (reg *dimensionRegistry) LookupID(dim Dimension) (int, bool) { return id, ok } +// RegisterDimension registers a custom dimension. +func (reg *dimensionRegistry) RegisterDimension(id int, name string, dim Dimension) error { + if id < 1000 || id > math.MaxUint16 { + return fmt.Errorf("custom dimension ID must be between 1000 and %d", math.MaxUint16) + } + if name == "" { + return fmt.Errorf("custom dimension name must not be empty") + } + if dim == nil { + return fmt.Errorf("custom dimension must not be nil") + } + r := dim.Range() + if r.Min() > r.Max() { + return fmt.Errorf("custom dimension range must not be empty") + } + if r.Min()%16 != 0 || (r.Max()+1)%16 != 0 { + return fmt.Errorf("custom dimension range must align with 16-block sub-chunks") + } + if r.Min() < math.MinInt16 || r.Max() > math.MaxInt16 { + return fmt.Errorf("custom dimension range must be between %d and %d", math.MinInt16, math.MaxInt16) + } + if _, ok := reg.dimensions[id]; ok { + return fmt.Errorf("dimension ID %d is already registered", id) + } + if existing, ok := reg.ids[dim]; ok { + return fmt.Errorf("dimension is already registered with ID %d", existing) + } + for _, existing := range reg.custom { + if existing.Name == name { + return fmt.Errorf("dimension name %q is already registered", name) + } + } + reg.dimensions[id] = dim + reg.ids[dim] = id + reg.custom = append(reg.custom, DimensionRegistration{ID: id, Name: name, Dimension: dim}) + return nil +} + +// RegisterDimension registers a custom dimension. +func RegisterDimension(id int, name string, dim Dimension) error { + return dimensionReg.RegisterDimension(id, name, dim) +} + +// CustomDimensions returns all registered custom dimensions. +func CustomDimensions() []DimensionRegistration { + return slices.Clone(dimensionReg.custom) +} + type ( // Dimension is a dimension of a World. It influences a variety of // properties of a World such as the building range, the sky colour and the diff --git a/server/world/entity.go b/server/world/entity.go index 89552ffa3a..3daa7914f4 100644 --- a/server/world/entity.go +++ b/server/world/entity.go @@ -4,6 +4,7 @@ import ( "encoding/binary" "io" "maps" + "math" "slices" "sync" "sync/atomic" @@ -17,7 +18,7 @@ import ( // EntityType is the type of Entity. It specifies the name, encoded Entity // ID and bounding box of an Entity. type EntityType interface { - // Open returns an Entity implementation in the context of a transaction. + // Open returns an Entity implementation in a transaction. Open(tx *Tx, handle *EntityHandle, data *EntityData) Entity // EncodeEntity converts the Entity to its encoded representation: It @@ -51,6 +52,17 @@ type EntityHandle struct { worldless *atomic.Bool weakTxActive bool w *World + // worldReady becomes true only after AddEntity finishes registering and + // opening the entity in w. Scheduled callbacks wait for this handoff. + worldReady bool + // worldVersion increments on every change to w, letting weak transactions + // detect that the entity moved while they were queued. + worldVersion atomic.Uint64 + // closed closes once the handle is closed. worldChanged, created lazily + // for delayed schedulers, is closed and dropped on every change to w. + closed chan struct{} + worldChanged chan struct{} + closeOnce sync.Once data EntityData @@ -83,7 +95,14 @@ func (opts EntitySpawnOpts) New(t EntityType, conf EntityConfig) *EntityHandle { opts.ID = uuid.New() clear(opts.ID[:8]) } - handle := &EntityHandle{id: opts.ID, t: t, cond: sync.NewCond(&sync.Mutex{}), worldless: &atomic.Bool{}} + handle := &EntityHandle{ + id: opts.ID, + t: t, + cond: sync.NewCond(&sync.Mutex{}), + worldless: &atomic.Bool{}, + closed: make(chan struct{}), + data: EntityData{AlwaysShowNameTag: true}, + } handle.worldless.Store(true) handle.data.Pos, handle.data.Rot, handle.data.Vel = opts.Position, opts.Rotation, opts.Velocity handle.data.Name = opts.NameTag @@ -102,7 +121,13 @@ func NewEntity(t EntityType, conf EntityConfig) *EntityHandle { // entityFromData reads an entity from the decoded NBT data passed and returns // an EntityHandle. func entityFromData(t EntityType, id int64, data map[string]any) *EntityHandle { - handle := &EntityHandle{t: t, cond: sync.NewCond(&sync.Mutex{}), worldless: &atomic.Bool{}} + handle := &EntityHandle{ + t: t, + cond: sync.NewCond(&sync.Mutex{}), + worldless: &atomic.Bool{}, + closed: make(chan struct{}), + data: EntityData{AlwaysShowNameTag: true}, + } binary.LittleEndian.PutUint64(handle.id[8:], uint64(id)) handle.decodeNBT(data) t.DecodeNBT(data, &handle.data) @@ -137,37 +162,69 @@ func (e *EntityHandle) UUID() uuid.UUID { return e.id } -// Close closes the EntityHandle. Any subsequent call to ExecWorld will return -// immediately without the transaction function being called. Close always -// returns nil. +// Closed reports whether the EntityHandle has been closed. +func (e *EntityHandle) Closed() bool { + if e == nil { + return true + } + select { + case <-e.closed: + return true + default: + return false + } +} + +// Close closes the EntityHandle. Any subsequently scheduled work will fail +// with ErrEntityClosed without the transaction function being called. Close +// always returns nil. func (e *EntityHandle) Close() error { - e.setAndUnlockWorld(closeWorld) + e.closeOnce.Do(func() { + e.setAndUnlockWorld(closeWorld) + close(e.closed) + }) return nil } -// ExecWorld obtains the EntityHandle's World in a thread-safe way and opens a -// transaction in it when it does. If the EntityHandle has not been added to a -// world, ExecWorld will block until the EntityHandle is added to a World and -// run the transaction function once it is. If the Entity is closed before -// ExecWorld is called, ExecWorld will return false immediately without running -// the transaction function. -func (e *EntityHandle) ExecWorld(f func(tx *Tx, e Entity)) bool { - return e.execWorld(f, false) +func cancelled(c <-chan struct{}) bool { + if c == nil { + return false + } + select { + case <-c: + return true + default: + return false + } } // execWorld uses a sync.Cond to synchronise access to the handler's world. We // are dealing with a rather complicated synchronisation pattern here. The goal -// for ExecWorld is to block until e.w becomes accessible. Meanwhile, World.Exec +// for execWorld is to block until e.w becomes accessible. Meanwhile, World.exec // may also affect e.w, which execWorld needs to deal with. -func (e *EntityHandle) execWorld(f func(tx *Tx, e Entity), weak bool) bool { +func (e *EntityHandle) execWorld(f func(tx *Tx, e Entity), weak bool, cancel <-chan struct{}, allowedCloseWorld *World) bool { e.cond.L.Lock() - for e.w == nil || (!weak && e.weakTxActive) { + for e.w == nil || (e.w != closeWorld && (!e.worldReady || (!weak && e.weakTxActive))) { + if cancelled(cancel) { + if weak { + e.clearWeakTxActiveLocked() + } + e.cond.L.Unlock() + return false + } // Wait suspends the current goroutine and unlocks e.cond.L, until // e.cond.Broadcast() is called. After this, one of the goroutines // waiting will acquire a lock of e.cond.L again. This means that only // one goroutine will run the code after this simultaneously. e.cond.Wait() } + if cancelled(cancel) { + if weak { + e.clearWeakTxActiveLocked() + } + e.cond.L.Unlock() + return false + } // If a goroutine manages to exit the for loop, it will have acquired a lock // on e.cond.L. This also means that e.w can be assumed to not be nil here. // Because of the lock on e.cond.L, no other transaction will be able to @@ -176,28 +233,52 @@ func (e *EntityHandle) execWorld(f func(tx *Tx, e Entity), weak bool) bool { e.worldless.Store(false) if e.w == closeWorld { // EntityHandle was closed. No need to continue. + if weak { + e.clearWeakTxActiveLocked() + } + e.cond.L.Unlock() + return false + } + if e.w.closed.Load() && !e.w.closeAcceptingEntityTasks.Load() && e.w != allowedCloseWorld { + if weak { + e.clearWeakTxActiveLocked() + } e.cond.L.Unlock() return false } - // We now arrive at the more complicated part. When we call e.w.Exec(), our + // We now arrive at the more complicated part. When we call e.w.exec(), our // transaction must await earlier transactions in the world. If one of those - // earlier transactions tries to change e.w (through e.unsetAndLockWorld() - // or e.setAndUnlockWorld()), it must lock e.cond.L. This would lead to a - // deadlock, because we already have e.cond.L locked here. + // earlier transactions tries to change e.w (through e.unsetAndLockWorld(), + // e.setAndUnlockWorld(), or e.setAndUnlockWorldAt()), it must lock + // e.cond.L. This would lead to a deadlock, because we already have e.cond.L + // locked here. // We work around this with so-called "weak transactions". This is a // transaction that may be invalidated before it is executed. In this case, // this invalidation happens by setting e.worldless to true. If the // transaction turns out to be invalidated (ret == false), we simply try // again, this time with e.execWorld(f, true) to make this goroutine bypass // any goroutines still awaiting e.cond. - ret := e.weakExec(func(tx *Tx) { f(tx, e.mustEntity(tx)) }) + var ran atomic.Bool + ret := e.weakExec(func(tx *Tx) { + ent := e.mustEntity(tx) + ran.Store(true) + f(tx, ent) + }, allowedCloseWorld) + if !ret && e.w != nil && e.w != closeWorld && e.w.closed.Load() && !e.w.closeAcceptingEntityTasks.Load() && e.w != allowedCloseWorld { + e.clearWeakTxActiveLocked() + e.cond.L.Unlock() + return false + } e.cond.L.Unlock() + if ran.Load() { + return true + } if !ret { // Our weak transaction was suspended. We try again, this time with // e.execWorld(f, true) to make this goroutine bypass any goroutines // still awaiting e.cond. - return e.execWorld(f, true) + return e.execWorld(f, true, cancel, allowedCloseWorld) } return true } @@ -205,17 +286,20 @@ func (e *EntityHandle) execWorld(f func(tx *Tx, e Entity), weak bool) bool { // weakExec performs a "weak transaction". It adds a transaction to the world // that is invalidated when e.worldless is set to true. In this case, weakExec // returns false. If the weak transaction is successfully executed, it returns -// true, and any calls to ExecWorld waiting on e.cond are awakened. The goal of +// true, and any calls to execWorld waiting on e.cond are awakened. The goal of // weakExec is to suspend the current goroutine and unlock e.cond.L while // waiting for previous transactions to finish. -func (e *EntityHandle) weakExec(f ExecFunc) bool { +func (e *EntityHandle) weakExec(f execFunc, allowedCloseWorld *World) bool { e.weakTxActive = true + w, version := e.w, e.worldVersion.Load() // We create a weak transaction and start a for loop to listen for the // length of the channel. This might look weird, but the crucial part here // is the call to e.cond.Wait(), which unlocks e.cond.L. This is required // to prevent a deadlock if an earlier transaction tries to change e.w. - c := e.w.weakExec(e.worldless, e.cond, f) + c := w.weakExec(func() bool { + return e.worldVersion.Load() == version && !e.worldless.Load() + }, e.cond, f, w == allowedCloseWorld) for len(c) == 0 && e.w != closeWorld { // Calling e.cond.Wait() here will free the lock on e.cond.L until our // transaction finishes. e.w.weakExec() ensures that e.cond.Broadcast() @@ -223,8 +307,6 @@ func (e *EntityHandle) weakExec(f ExecFunc) bool { // continue after that. e.cond.Wait() } - // If the EntityHandle was closed (e.w == closeWorld), we treat the - // transaction as successful, because all transactions must be cancelled. if e.w != closeWorld && !<-c { // Weak transaction was suspended. Return false and try again. return false @@ -232,14 +314,22 @@ func (e *EntityHandle) weakExec(f ExecFunc) bool { // After setting e.weakTxActive back to false, we must Broadcast to make // sure any goroutines waiting in e.execWorld as a result of the // e.weakTxActive condition can continue. + closed := e.w == closeWorld e.weakTxActive = false e.cond.Broadcast() - return true + return !closed +} + +func (e *EntityHandle) clearWeakTxActiveLocked() { + if e.weakTxActive { + e.weakTxActive = false + e.cond.Broadcast() + } } var closeWorld = &World{} -// unsetAndLockWorld sets e.w to nil, causing any subsequent calls to ExecWorld +// unsetAndLockWorld sets e.w to nil, causing any subsequent calls to execWorld // to block until e.w is set to a non-nil value. func (e *EntityHandle) unsetAndLockWorld() { e.cond.L.Lock() @@ -247,10 +337,13 @@ func (e *EntityHandle) unsetAndLockWorld() { e.worldless.Store(true) e.w = nil + e.worldReady = false + e.worldVersion.Add(1) + e.notifyWorldChangedLocked() } -// setAndUnlockWorld sets e.w to a World passed and broadcasts e.cond, so that -// any goroutines waiting for a non-nil world are awoken. +// setAndUnlockWorld starts binding e to w. Scheduled callbacks remain blocked +// until markWorldReady is called after AddEntity finishes. func (e *EntityHandle) setAndUnlockWorld(w *World) { e.cond.L.Lock() defer e.cond.L.Unlock() @@ -259,6 +352,41 @@ func (e *EntityHandle) setAndUnlockWorld(w *World) { panic("cannot add entity to new world before removing from old world") } e.w = w + e.worldReady = false + e.worldVersion.Add(1) + e.notifyWorldChangedLocked() +} + +// markWorldReady wakes scheduled callbacks after AddEntity has fully opened +// and registered the entity in w. +func (e *EntityHandle) markWorldReady(w *World) { + e.cond.L.Lock() + defer e.cond.L.Unlock() + if e.w == w { + e.worldReady = true + e.cond.Broadcast() + } +} + +func (e *EntityHandle) notifyWorldChangedLocked() { + if e.worldChanged != nil { + close(e.worldChanged) + e.worldChanged = nil + } + e.cond.Broadcast() +} + +// setAndUnlockWorldAt sets e's position before publishing e to the World +// passed, then broadcasts e.cond so waiters can open the entity. +func (e *EntityHandle) setAndUnlockWorldAt(w *World, pos mgl64.Vec3) { + e.cond.L.Lock() + defer e.cond.L.Unlock() + + if e.w != nil { + panic("cannot add entity to new world before removing from old world") + } + e.data.Pos = pos + e.w = w e.cond.Broadcast() } @@ -282,18 +410,19 @@ func (e *EntityHandle) encodeNBT() map[string]any { "Yaw": float32(e.data.Rot[0]), "Pitch": float32(e.data.Rot[1]), "Fire": int16(e.data.FireDuration.Seconds() * 20), - "Age": int16(e.data.Age / (time.Second * 20)), + "Age": int16(min(e.data.Age/(time.Second/20), math.MaxInt16)), "NameTag": e.data.Name, } } // EntityData holds data shared by every entity. It is kept in an EntityHandle. type EntityData struct { - Pos, Vel mgl64.Vec3 - Rot cube.Rotation - Name string - FireDuration time.Duration - Age time.Duration + Pos, Vel mgl64.Vec3 + Rot cube.Rotation + Name string + AlwaysShowNameTag bool + FireDuration time.Duration + Age time.Duration Data any } @@ -374,6 +503,7 @@ type EntityRegistryConfig struct { BottleOfEnchanting func(opts EntitySpawnOpts, owner Entity) *EntityHandle Arrow func(opts EntitySpawnOpts, conf ArrowSpawnConfig) *EntityHandle Egg func(opts EntitySpawnOpts, owner Entity) *EntityHandle + EndCrystal func(opts EntitySpawnOpts) *EntityHandle EnderPearl func(opts EntitySpawnOpts, owner Entity) *EntityHandle Firework func(opts EntitySpawnOpts, firework Item, owner Entity, sidewaysVelocityMultiplier, upwardsAcceleration float64, attached bool) *EntityHandle LingeringPotion func(opts EntitySpawnOpts, t any, owner Entity) *EntityHandle diff --git a/server/world/entity_ref.go b/server/world/entity_ref.go new file mode 100644 index 0000000000..7251187a95 --- /dev/null +++ b/server/world/entity_ref.go @@ -0,0 +1,77 @@ +package world + +import ( + "context" + "fmt" + "time" +) + +// EntityRef is a stable, typed reference to an entity. The entity value T is +// only handed to scheduled owner callbacks, where it is safe to use. +type EntityRef[T Entity] struct { + h *EntityHandle +} + +// NewEntityRef creates a typed reference from an EntityHandle. +func NewEntityRef[T Entity](h *EntityHandle) EntityRef[T] { return EntityRef[T]{h: h} } + +// Handle returns the underlying stable entity handle. +func (r EntityRef[T]) Handle() *EntityHandle { return r.h } + +// Do schedules f on the entity's current world owner, like EntityHandle.Do, +// but hands f the entity as T. If the entity is no longer a T when the task +// runs, the task fails with ErrEntityType. +func (r EntityRef[T]) Do(f func(tx *Tx, e T)) *Task { + return r.h.schedule(typed(f)) +} + +// DoAfter schedules f on the entity's world owner after delay, typed like Do. +func (r EntityRef[T]) DoAfter(delay time.Duration, f func(tx *Tx, e T)) *Task { + return r.h.scheduleAfter(delay, typed(f)) +} + +// typed wraps f so the scheduled entity is asserted to T before f runs. +func typed[T Entity](f func(tx *Tx, e T)) func(*Tx, Entity) error { + return func(tx *Tx, e Entity) error { + v, err := assertEntity[T](e) + if err != nil { + return err + } + f(tx, v) + return nil + } +} + +// CallRef runs f with the ref's entity on its current world owner and waits +// for the typed result. Off-owner code only, like Call. If f panics, CallRef +// re-panics with the original value on the waiting goroutine. Context +// cancellation stops pending work, but CallRef waits for a callback that has +// already started. +func CallRef[T any, E Entity](ctx context.Context, ref EntityRef[E], f func(tx *Tx, e E) (T, error)) (T, error) { + var zero T + ctx, err := callContext(ctx) + if err != nil { + return zero, err + } + var result T + task := ref.h.schedule(func(tx *Tx, e Entity) error { + v, err := assertEntity[E](e) + if err != nil { + return err + } + var callErr error + result, callErr = f(tx, v) + return callErr + }) + return awaitTask(ctx, task, &result) +} + +// assertEntity converts e to T, returning ErrEntityType if it no longer is one. +func assertEntity[T Entity](e Entity) (T, error) { + v, ok := e.(T) + if !ok { + var zero T + return zero, fmt.Errorf("%w: got %T", ErrEntityType, e) + } + return v, nil +} diff --git a/server/world/entity_schedule.go b/server/world/entity_schedule.go new file mode 100644 index 0000000000..bd2a6b4748 --- /dev/null +++ b/server/world/entity_schedule.go @@ -0,0 +1,179 @@ +package world + +import "time" + +// Do schedules f to run with the entity on its current world owner and +// returns immediately. If the entity is in no world yet, the task waits until +// it enters one or the handle closes. The entity passed to f is only valid +// inside f. On a synchronous World, f runs before Do returns. +func (e *EntityHandle) Do(f func(tx *Tx, e Entity)) *Task { + return e.schedule(func(tx *Tx, e Entity) error { + f(tx, e) + return nil + }) +} + +// DoAfter schedules f to run with the entity after delay, following the +// entity if it changes worlds in the meantime. +func (e *EntityHandle) DoAfter(delay time.Duration, f func(tx *Tx, e Entity)) *Task { + return e.scheduleAfter(delay, func(tx *Tx, e Entity) error { + f(tx, e) + return nil + }) +} + +// schedule runs f on the entity's current world owner from a goroutine that +// waits for the entity to be world-bound. It deliberately never enqueues onto +// the current world directly: that would commit to one world and fail instead +// of following the entity when it migrates. +func (e *EntityHandle) schedule(f func(tx *Tx, e Entity) error) *Task { + task := newTask() + if e == nil { + task.failIfPending(ErrEntityClosed) + return task + } + task.setCancel(e.wakeScheduled) + w := e.trackCloseSchedule(task) + if !task.pending() { + return task + } + run := func() { + if w != nil { + defer w.scheduling.Done() + } + e.runScheduled(task, f, w) + } + if e.currentWorldSynchronous() { + run() + } else { + go run() + } + return task +} + +// scheduleAfter runs its own timer loop instead of reusing World.DoAfter: +// the entity may change worlds during the delay, so the loop re-reads the +// current world's signals every iteration. +func (e *EntityHandle) scheduleAfter(delay time.Duration, f func(tx *Tx, e Entity) error) *Task { + if delay <= 0 { + return e.schedule(f) + } + task := newTask() + if e == nil { + task.failIfPending(ErrEntityClosed) + return task + } + task.setCancel(e.wakeScheduled) + go func() { + timer := time.NewTimer(delay) + defer timer.Stop() + for { + closeStarted, worldChanged := e.currentWorldSignals() + select { + case <-timer.C: + if e.currentWorldClosing() { + task.failIfPending(ErrWorldClosed) + return + } + e.runScheduled(task, f, nil) + return + case <-task.Done(): + return + case <-closeStarted: + if cs, _ := e.currentWorldSignals(); cs == closeStarted { + task.failIfPending(ErrWorldClosed) + return + } + case <-worldChanged: + case <-e.closed: + task.failIfPending(ErrEntityClosed) + return + } + } + }() + return task +} + +// trackCloseSchedule registers entity work created during the world's close +// transaction, so World.close drains it before shutting the queue down. +func (e *EntityHandle) trackCloseSchedule(task *Task) *World { + e.cond.L.Lock() + defer e.cond.L.Unlock() + w := e.w + if w == nil || w == closeWorld || !w.closed.Load() { + return nil + } + w.scheduleMu.Lock() + defer w.scheduleMu.Unlock() + if !w.closeAcceptingEntityTasks.Load() { + task.failIfPending(ErrWorldClosed) + return nil + } + select { + case <-w.queueClosing: + task.failIfPending(ErrWorldClosed) + return nil + default: + w.scheduling.Add(1) + return w + } +} + +// wakeScheduled wakes goroutines waiting on the handle's cond, so a scheduler +// blocked in execWorld re-checks its cancel signal. +func (e *EntityHandle) wakeScheduled() { + e.cond.L.Lock() + e.cond.Broadcast() + e.cond.L.Unlock() +} + +// currentWorldSignals returns the current world's close channel and the +// handle's world-change channel, creating the latter for this waiter if +// needed. +func (e *EntityHandle) currentWorldSignals() (<-chan struct{}, <-chan struct{}) { + e.cond.L.Lock() + defer e.cond.L.Unlock() + if e.worldChanged == nil { + e.worldChanged = make(chan struct{}) + } + if e.w == nil || e.w == closeWorld { + return nil, e.worldChanged + } + return e.w.closeStarted, e.worldChanged +} + +// currentWorldSynchronous reports whether the entity is bound to a +// synchronous World. +func (e *EntityHandle) currentWorldSynchronous() bool { + e.cond.L.Lock() + defer e.cond.L.Unlock() + return e.w != nil && e.w != closeWorld && e.worldReady && e.w.conf.Synchronous +} + +// currentWorldClosing reports whether the entity's current world has started +// closing. +func (e *EntityHandle) currentWorldClosing() bool { + closeStarted, _ := e.currentWorldSignals() + return cancelled(closeStarted) +} + +// runScheduled executes the scheduled entity callback via execWorld with the +// same completion model as scheduledTransaction: run, drain deferred work, +// finish the task. +func (e *EntityHandle) runScheduled(task *Task, f func(tx *Tx, e Entity) error, allowedCloseWorld *World) { + run := e.execWorld(func(tx *Tx, ent Entity) { + if !task.begin() { + return + } + err := executeWithRecovery(tx.w, func() error { return f(tx, ent) }) + tx.runDeferred() + task.finish(err) + }, false, task.Done(), allowedCloseWorld) + if !run || task.pending() { + err := ErrEntityClosed + if e.currentWorldClosing() { + err = ErrWorldClosed + } + task.failIfPending(err) + } +} diff --git a/server/world/explosion.go b/server/world/explosion.go new file mode 100644 index 0000000000..187990cab3 --- /dev/null +++ b/server/world/explosion.go @@ -0,0 +1,62 @@ +package world + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/go-gl/mathgl/mgl64" +) + +// defaultExplosionSize is the size used if a source does not specify one. +const defaultExplosionSize = 4 + +// ExplosionSource represents the source of an explosion. +type ExplosionSource interface { + // Position returns the position at the centre of the explosion. It must + // return the same position for the duration of an explosion. + Position() mgl64.Vec3 + // Size returns the radius which entities/blocks are affected within. + Size() float64 +} + +// EntityExplosionSource is used for an explosion caused by an entity. +type EntityExplosionSource struct { + // Entity is the entity that caused the explosion. + Entity Entity + // ExplosionSize is the size of the explosion. Defaults to 4 if 0. + ExplosionSize float64 +} + +// Position ... +func (e EntityExplosionSource) Position() mgl64.Vec3 { + return e.Entity.Position() +} + +// Size ... +func (e EntityExplosionSource) Size() float64 { + if e.ExplosionSize == 0 { + return defaultExplosionSize + } + return e.ExplosionSize +} + +// BlockExplosionSource is used for an explosion caused by a block. +type BlockExplosionSource struct { + // Block is the block that caused the explosion. + Block Block + // Pos is the position of the block that caused the explosion. + Pos cube.Pos + // ExplosionSize is the size of the explosion. Defaults to 4 if 0. + ExplosionSize float64 +} + +// Position ... +func (b BlockExplosionSource) Position() mgl64.Vec3 { + return b.Pos.Vec3Centre() +} + +// Size ... +func (b BlockExplosionSource) Size() float64 { + if b.ExplosionSize == 0 { + return defaultExplosionSize + } + return b.ExplosionSize +} diff --git a/server/world/game_mode.go b/server/world/game_mode.go index 0ae79f024e..b1769b8c42 100644 --- a/server/world/game_mode.go +++ b/server/world/game_mode.go @@ -20,6 +20,9 @@ type GameMode interface { // Visible specifies if a player with this GameMode can be visible to other players. If false, the player will be // invisible under any circumstance. Visible() bool + // InstantPortalTravel specifies if a player with this GameMode travels through nether portals instantly, + // without the four second wait. + InstantPortalTravel() bool } var ( @@ -96,47 +99,51 @@ func (reg *gameModeRegistry) LookupID(mode GameMode) (int, bool) { // taking some time. type survival struct{} -func (survival) AllowsEditing() bool { return true } -func (survival) AllowsTakingDamage() bool { return true } -func (survival) CreativeInventory() bool { return false } -func (survival) HasCollision() bool { return true } -func (survival) AllowsFlying() bool { return false } -func (survival) AllowsInteraction() bool { return true } -func (survival) Visible() bool { return true } +func (survival) AllowsEditing() bool { return true } +func (survival) AllowsTakingDamage() bool { return true } +func (survival) CreativeInventory() bool { return false } +func (survival) HasCollision() bool { return true } +func (survival) AllowsFlying() bool { return false } +func (survival) AllowsInteraction() bool { return true } +func (survival) Visible() bool { return true } +func (survival) InstantPortalTravel() bool { return false } // creative represents the creative game mode: Players with this game mode have infinite blocks and // items and can break blocks instantly. Players with creative mode can also fly. type creative struct{} -func (creative) AllowsEditing() bool { return true } -func (creative) AllowsTakingDamage() bool { return false } -func (creative) CreativeInventory() bool { return true } -func (creative) HasCollision() bool { return true } -func (creative) AllowsFlying() bool { return true } -func (creative) AllowsInteraction() bool { return true } -func (creative) Visible() bool { return true } +func (creative) AllowsEditing() bool { return true } +func (creative) AllowsTakingDamage() bool { return false } +func (creative) CreativeInventory() bool { return true } +func (creative) HasCollision() bool { return true } +func (creative) AllowsFlying() bool { return true } +func (creative) AllowsInteraction() bool { return true } +func (creative) Visible() bool { return true } +func (creative) InstantPortalTravel() bool { return true } // adventure represents the adventure game mode: Players with this game mode cannot edit the world // (placing or breaking blocks). type adventure struct{} -func (adventure) AllowsEditing() bool { return false } -func (adventure) AllowsTakingDamage() bool { return true } -func (adventure) CreativeInventory() bool { return false } -func (adventure) HasCollision() bool { return true } -func (adventure) AllowsFlying() bool { return false } -func (adventure) AllowsInteraction() bool { return true } -func (adventure) Visible() bool { return true } +func (adventure) AllowsEditing() bool { return false } +func (adventure) AllowsTakingDamage() bool { return true } +func (adventure) CreativeInventory() bool { return false } +func (adventure) HasCollision() bool { return true } +func (adventure) AllowsFlying() bool { return false } +func (adventure) AllowsInteraction() bool { return true } +func (adventure) Visible() bool { return true } +func (adventure) InstantPortalTravel() bool { return false } // spectator represents the spectator game mode: Players with this game mode cannot interact with the // world and cannot be seen by other players. spectator players can fly, like creative mode, and can // move through blocks. type spectator struct{} -func (spectator) AllowsEditing() bool { return false } -func (spectator) AllowsTakingDamage() bool { return false } -func (spectator) CreativeInventory() bool { return false } -func (spectator) HasCollision() bool { return false } -func (spectator) AllowsFlying() bool { return true } -func (spectator) AllowsInteraction() bool { return false } -func (spectator) Visible() bool { return false } +func (spectator) AllowsEditing() bool { return false } +func (spectator) AllowsTakingDamage() bool { return false } +func (spectator) CreativeInventory() bool { return false } +func (spectator) HasCollision() bool { return false } +func (spectator) AllowsFlying() bool { return true } +func (spectator) AllowsInteraction() bool { return false } +func (spectator) Visible() bool { return false } +func (spectator) InstantPortalTravel() bool { return false } diff --git a/server/world/generator.go b/server/world/generator.go index 6afd07b81c..7e901d31d5 100644 --- a/server/world/generator.go +++ b/server/world/generator.go @@ -1,6 +1,8 @@ package world import ( + "sync" + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/world/chunk" ) @@ -9,7 +11,7 @@ import ( // generate chunks when the provider of the world cannot find a chunk at a given chunk position. type Generator interface { // GenerateChunk generates a chunk at a chunk position passed. The generator sets blocks in the chunk that - // is passed to the method. + // is passed to the method. With more than one chunk load worker, GenerateChunk is called concurrently. GenerateChunk(pos ChunkPos, chunk *chunk.Chunk) // DefaultSpawn returns the default spawn position for worlds using this generator in the dimension passed. DefaultSpawn(dim Dimension) cube.Pos @@ -24,3 +26,20 @@ func (NopGenerator) GenerateChunk(ChunkPos, *chunk.Chunk) {} // DefaultSpawn ... func (NopGenerator) DefaultSpawn(Dimension) cube.Pos { return cube.Pos{} } + +// lockedGenerator wraps a Generator, serialising GenerateChunk calls for +// generators that are not safe for concurrent use. +type lockedGenerator struct { + mu sync.Mutex + g Generator +} + +func (l *lockedGenerator) GenerateChunk(pos ChunkPos, c *chunk.Chunk) { + l.mu.Lock() + defer l.mu.Unlock() + l.g.GenerateChunk(pos, c) +} + +func (l *lockedGenerator) DefaultSpawn(dim Dimension) cube.Pos { + return l.g.DefaultSpawn(dim) +} diff --git a/server/world/handler.go b/server/world/handler.go index bdd248ae16..dd4cff22ba 100644 --- a/server/world/handler.go +++ b/server/world/handler.go @@ -2,12 +2,9 @@ package world import ( "github.com/df-mc/dragonfly/server/block/cube" - "github.com/df-mc/dragonfly/server/event" "github.com/go-gl/mathgl/mgl64" ) -type Context = event.Context[*Tx] - // Handler handles events that are called by a world. Implementations of // Handler may be used to listen to specific events such as when an Entity is // added to the world. @@ -52,8 +49,16 @@ type Handler interface { // Leaves decaying happens when there is no wood block neighbouring it. // ctx.Cancel() may be called to prevent leaves from decaying. HandleLeavesDecay(ctx *Context, pos cube.Pos) + // HandlePortalCreate handles an active portal being built. portalType is + // Nether or End, and positions contains every block changed to build it. + // ctx.Cancel() may be called to prevent the portal from being built. + HandlePortalCreate(ctx *Context, portalType Dimension, positions []cube.Pos) + // HandlePortalActivate handles a portal frame being filled with portal + // blocks. portalType is Nether or End. ctx.Cancel() may be called to prevent + // the portal from being activated. + HandlePortalActivate(ctx *Context, portalType Dimension, positions []cube.Pos) // HandleEntitySpawn handles an Entity being spawned into a World through a - // call to Tx.AddEntity. + // call to Tx.AddEntity or Tx.AddEntityAt. HandleEntitySpawn(tx *Tx, e Entity) // HandleEntityDespawn handles an Entity being despawned from a World // through a call to Tx.RemoveEntity. @@ -62,10 +67,10 @@ type Handler interface { // to cancel the explosion. // The affected entities, affected blocks, item drop chance, and whether the // explosion spawns fire may be altered. - HandleExplosion(ctx *Context, position mgl64.Vec3, entities *[]Entity, blocks *[]cube.Pos, itemDropChance *float64, spawnFire *bool) - // HandleRedstoneUpdate handles a redstone update at a position. ctx.Cancel() may be called - // to cancel the redstone update. - HandleRedstoneUpdate(ctx *Context, pos cube.Pos) + HandleExplosion(ctx *Context, src ExplosionSource, entities *[]Entity, blocks *[]cube.Pos, itemDropChance *float64, spawnFire *bool) + // HandleRedstoneUpdate handles a redstone update proposed by the World redstone engine. ctx.Cancel() may be + // called to suppress the proposed redstone mutation and any propagation from that mutation. + HandleRedstoneUpdate(ctx *Context, update RedstoneUpdate) // HandleClose handles the World being closed. HandleClose may be used as a // moment to finish code running on other goroutines that operates on the // World specifically. HandleClose is called directly before the World stops @@ -81,16 +86,19 @@ var _ Handler = (*NopHandler)(nil) // Users may embed NopHandler to avoid having to implement each method. type NopHandler struct{} -func (NopHandler) HandleLiquidFlow(*Context, cube.Pos, cube.Pos, Liquid, Block) {} -func (NopHandler) HandleLiquidDecay(*Context, cube.Pos, Liquid, Liquid) {} -func (NopHandler) HandleLiquidHarden(*Context, cube.Pos, Block, Block, Block) {} -func (NopHandler) HandleSound(*Context, Sound, mgl64.Vec3) {} -func (NopHandler) HandleFireSpread(*Context, cube.Pos, cube.Pos) {} -func (NopHandler) HandleBlockBurn(*Context, cube.Pos) {} -func (NopHandler) HandleCropTrample(*Context, cube.Pos) {} -func (NopHandler) HandleLeavesDecay(*Context, cube.Pos) {} -func (NopHandler) HandleEntitySpawn(*Tx, Entity) {} -func (NopHandler) HandleEntityDespawn(*Tx, Entity) {} -func (NopHandler) HandleExplosion(*Context, mgl64.Vec3, *[]Entity, *[]cube.Pos, *float64, *bool) {} -func (NopHandler) HandleRedstoneUpdate(*Context, cube.Pos) {} -func (NopHandler) HandleClose(*Tx) {} +func (NopHandler) HandleLiquidFlow(*Context, cube.Pos, cube.Pos, Liquid, Block) {} +func (NopHandler) HandleLiquidDecay(*Context, cube.Pos, Liquid, Liquid) {} +func (NopHandler) HandleLiquidHarden(*Context, cube.Pos, Block, Block, Block) {} +func (NopHandler) HandleSound(*Context, Sound, mgl64.Vec3) {} +func (NopHandler) HandleFireSpread(*Context, cube.Pos, cube.Pos) {} +func (NopHandler) HandleBlockBurn(*Context, cube.Pos) {} +func (NopHandler) HandleCropTrample(*Context, cube.Pos) {} +func (NopHandler) HandleLeavesDecay(*Context, cube.Pos) {} +func (NopHandler) HandlePortalCreate(*Context, Dimension, []cube.Pos) {} +func (NopHandler) HandlePortalActivate(*Context, Dimension, []cube.Pos) {} +func (NopHandler) HandleEntitySpawn(*Tx, Entity) {} +func (NopHandler) HandleEntityDespawn(*Tx, Entity) {} +func (NopHandler) HandleExplosion(*Context, ExplosionSource, *[]Entity, *[]cube.Pos, *float64, *bool) { +} +func (NopHandler) HandleRedstoneUpdate(*Context, RedstoneUpdate) {} +func (NopHandler) HandleClose(*Tx) {} diff --git a/server/world/loader.go b/server/world/loader.go index e76e867747..16c985db5e 100644 --- a/server/world/loader.go +++ b/server/world/loader.go @@ -1,10 +1,11 @@ package world import ( - "github.com/go-gl/mathgl/mgl64" "maps" "math" "sync" + + "github.com/go-gl/mathgl/mgl64" ) // Loader implements the loading of the world. A loader can typically be moved around the world to load @@ -19,6 +20,7 @@ type Loader struct { pos ChunkPos loadQueue []ChunkPos loaded map[ChunkPos]*Column + pending map[ChunkPos]struct{} closed bool } @@ -28,7 +30,7 @@ type Loader struct { // The Viewer passed will handle the loading of chunks, including the viewing of entities that were loaded in // those chunks. func NewLoader(chunkRadius int, world *World, v Viewer) *Loader { - l := &Loader{r: chunkRadius, loaded: make(map[ChunkPos]*Column), viewer: v} + l := &Loader{r: chunkRadius, loaded: make(map[ChunkPos]*Column), pending: make(map[ChunkPos]struct{}), viewer: v} l.world(world) return l } @@ -47,12 +49,13 @@ func (l *Loader) ChangeWorld(tx *Tx, new *World) { defer l.mu.Unlock() loaded := maps.Clone(l.loaded) - l.w.Exec(func(tx *Tx) { + l.w.exec(func(tx *Tx) { for pos := range loaded { tx.World().removeViewer(tx, pos, l) } }) clear(l.loaded) + clear(l.pending) l.w.viewerMu.Lock() delete(l.w.viewers, l) l.w.viewerMu.Unlock() @@ -84,33 +87,64 @@ func (l *Loader) Move(tx *Tx, pos mgl64.Vec3) { l.populateLoadQueue() } -// Load loads n chunks around the centre of the chunk, starting with the middle and working outwards. For -// every chunk loaded, the Viewer passed through construction in New has its ViewChunk method called. -// Load does nothing for n <= 0. +// Load queues up to n chunks around the loader's centre, from the middle outwards, to be loaded in +// the background. The Viewer's ViewChunk is called for each chunk once ready, which may be after Load +// returns. Load does nothing for n <= 0. func (l *Loader) Load(tx *Tx, n int) { - l.mu.Lock() - defer l.mu.Unlock() - - if l.closed || l.w == nil { - return - } for i := 0; i < n; i++ { + l.mu.Lock() + if l.closed || l.w == nil { + l.mu.Unlock() + return + } if len(l.loadQueue) == 0 { + l.mu.Unlock() break } - pos := l.loadQueue[0] - c := tx.w.chunk(pos) - - l.viewer.ViewChunk(pos, l.w.Dimension(), c.BlockEntities, c.Chunk) - l.w.addViewer(tx, c, l) - - l.loaded[pos] = c + w := tx.World() + l.pending[pos] = struct{}{} // Shift the first element from the load queue off so that we can take a new one during the next // iteration. l.loadQueue = l.loadQueue[1:] + l.mu.Unlock() + + if !w.loadChunkAsync(tx, pos, func(tx2 *Tx, col *Column) { + l.viewChunk(tx2, pos, col) + }) { + l.mu.Lock() + delete(l.pending, pos) + l.queueLoad(pos) + l.mu.Unlock() + } + } +} + +// viewChunk passes a loaded chunk to the Loader's Viewer. If the chunk failed +// to load, it is queued to be loaded again. +func (l *Loader) viewChunk(tx *Tx, pos ChunkPos, c *Column) { + l.mu.Lock() + defer l.mu.Unlock() + + if l.closed || l.viewer == nil || l.w == nil || l.w != tx.World() { + return + } + delete(l.pending, pos) + if c == nil { + l.queueLoad(pos) + return + } + if _, ok := l.loaded[pos]; ok { + return } + if !l.withinLoadRadius(pos) { + return + } + l.viewer.ViewChunk(pos, l.w.Dimension(), c.BlockEntities, c.Chunk) + l.w.addViewer(tx, c, l) + + l.loaded[pos] = c } // Chunk attempts to return a chunk at the given ChunkPos. If the chunk is not loaded, the second return value will @@ -132,6 +166,7 @@ func (l *Loader) Close(tx *Tx) { tx.World().removeViewer(tx, pos, l) } l.loaded = map[ChunkPos]*Column{} + clear(l.pending) l.w.viewerMu.Lock() delete(l.w.viewers, l) @@ -153,15 +188,44 @@ func (l *Loader) world(new *World) { // and should therefore be removed. func (l *Loader) evictUnused(tx *Tx) { for pos := range l.loaded { - diffX, diffZ := pos[0]-l.pos[0], pos[1]-l.pos[1] - dist := math.Sqrt(float64(diffX*diffX) + float64(diffZ*diffZ)) - if int(dist) > l.r { + if !l.withinLoadRadius(pos) { delete(l.loaded, pos) l.w.removeViewer(tx, pos, l) } } } +// withinLoadRadius checks if a chunk position is within the Loader's radius. +func (l *Loader) withinLoadRadius(pos ChunkPos) bool { + return chunkDistance(pos, l.pos) <= int32(l.r) +} + +// chunkDistance returns the rounded distance between two chunk positions. +func chunkDistance(a, b ChunkPos) int32 { + diffX, diffZ := float64(a[0])-float64(b[0]), float64(a[1])-float64(b[1]) + return int32(math.Round(math.Sqrt(diffX*diffX + diffZ*diffZ))) +} + +// queueLoad adds pos back to the load queue, unless it is already loaded, +// queued, or no longer within the radius of the Loader. +func (l *Loader) queueLoad(pos ChunkPos) { + if l.closed || l.w == nil || !l.withinLoadRadius(pos) { + return + } + if _, ok := l.loaded[pos]; ok { + return + } + if _, ok := l.pending[pos]; ok { + return + } + for _, queued := range l.loadQueue { + if queued == pos { + return + } + } + l.loadQueue = append(l.loadQueue, pos) +} + // populateLoadQueue populates the load queue of the loader. This method is called once to create the order in // which chunks around the position the loader is now in should be loaded. Chunks are ordered to be loaded // from the middle outwards. @@ -173,27 +237,26 @@ func (l *Loader) populateLoadQueue() { r := int32(l.r) for x := -r; x <= r; x++ { for z := -r; z <= r; z++ { - distance := math.Sqrt(float64(x*x) + float64(z*z)) - chunkDistance := int32(math.Round(distance)) - if chunkDistance > r { + pos := ChunkPos{x + l.pos[0], z + l.pos[1]} + dist := chunkDistance(pos, l.pos) + if dist > r { // The chunk was outside the chunk radius. continue } - pos := ChunkPos{x + l.pos[0], z + l.pos[1]} if _, ok := l.loaded[pos]; ok { // The chunk was already loaded, so we don't need to do anything. continue } - if m, ok := queue[chunkDistance]; ok { - queue[chunkDistance] = append(m, pos) + if _, ok := l.pending[pos]; ok { + // The chunk is already queued to be loaded. continue } - queue[chunkDistance] = []ChunkPos{pos} + queue[dist] = append(queue[dist], pos) } } l.loadQueue = l.loadQueue[:0] - for i := int32(0); i < r; i++ { + for i := int32(0); i <= r; i++ { l.loadQueue = append(l.loadQueue, queue[i]...) } } diff --git a/server/world/portal/end.go b/server/world/portal/end.go new file mode 100644 index 0000000000..c64f0dad52 --- /dev/null +++ b/server/world/portal/end.go @@ -0,0 +1,185 @@ +package portal + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +// endSpawnX, endSpawnY and endSpawnZ are the centre of the End arrival platform. +const ( + endSpawnX = 100 + endSpawnY = 49 + endSpawnZ = 0 +) + +// EndSpawnPosition returns the Bedrock End platform arrival position: y=49 for players and y=50 for other entities. +func EndSpawnPosition(player bool) mgl64.Vec3 { + y := endSpawnY + 1 + if player { + y = endSpawnY + } + return mgl64.Vec3{float64(endSpawnX) + 0.5, float64(y), float64(endSpawnZ) + 0.5} +} + +// GenerateEndSpawnPlatform builds the 5x5 obsidian arrival platform at (100, 48, 0) and clears the 5x5x3 air column +// above it. It runs on every travel into the End, matching vanilla's unconditional regeneration. +func GenerateEndSpawnPlatform(tx *world.Tx) { + ob := obsidian() + for dx := -2; dx <= 2; dx++ { + for dz := -2; dz <= 2; dz++ { + tx.SetBlock(cube.Pos{endSpawnX + dx, endSpawnY - 1, endSpawnZ + dz}, ob, nil) + for dy := 0; dy < 3; dy++ { + tx.SetBlock(cube.Pos{endSpawnX + dx, endSpawnY + dy, endSpawnZ + dz}, nil, nil) + } + } + } +} + +// endRingFrame is one of the twelve canonical ring positions and the Facing each frame must have. +type endRingFrame struct { + pos cube.Pos + facing cube.Direction +} + +// endFrameBlock is implemented by block.EndPortalFrame, which cannot be imported here directly. +type endFrameBlock interface { + world.Block + EndPortalFrameState() (eye bool, facing cube.Direction) +} + +// ActivateEndPortal fills the 3x3 interior with end_portal blocks if a complete twelve-frame ring exists around the +// frame at the position passed. All twelve frames must hold an eye and face toward the centre, as in vanilla. +func ActivateEndPortal(tx *world.Tx, framePos cube.Pos) bool { + f, ok := tx.Block(framePos).(endFrameBlock) + if !ok { + return false + } + _, facing := f.EndPortalFrameState() + + // The frame may be the left, middle or right of its side: walk inward twice, then try the three candidate centres. + inward, tangent := facing.Face(), facing.RotateRight().Face() + base := framePos.Side(inward).Side(inward) + for _, center := range []cube.Pos{base.Side(tangent.Opposite()), base, base.Side(tangent)} { + interior, ok := matchEndRing(tx, center) + if !ok { + continue + } + ep := endPortal() + active := true + for _, pos := range interior { + if tx.Block(pos) != ep { + active = false + break + } + } + if active { + return false + } + ctx := tx.Event() + positions := append([]cube.Pos(nil), interior...) + if tx.World().Handler().HandlePortalActivate(ctx, world.End, positions); ctx.Cancelled() { + return false + } + for _, pos := range interior { + if tx.Block(pos) != ep { + tx.SetBlock(pos, ep, nil) + } + } + return true + } + return false +} + +// EndPortalRingIntact reports whether an intact twelve-frame ring still surrounds the end_portal block at the +// position passed, which may be any of the nine interior positions. +func EndPortalRingIntact(tx *world.Tx, portalPos cube.Pos) bool { + for dx := -1; dx <= 1; dx++ { + for dz := -1; dz <= 1; dz++ { + if _, ok := matchEndRing(tx, portalPos.Add(cube.Pos{dx, 0, dz})); ok { + return true + } + } + } + return false +} + +// DeactivateEndPortal clears every end_portal block reachable from the position passed through cardinal neighbours +// on the same y plane, removing a single portal's interior without touching unrelated portals. +func DeactivateEndPortal(tx *world.Tx, portalPos cube.Pos) { + ep := endPortal() + if tx.Block(portalPos) != ep { + return + } + var positions []cube.Pos + queue := []cube.Pos{portalPos} + seen := map[cube.Pos]struct{}{portalPos: {}} + for len(queue) > 0 { + p := queue[0] + queue = queue[1:] + if tx.Block(p) != ep { + continue + } + positions = append(positions, p) + for _, face := range cube.HorizontalFaces() { + n := p.Side(face) + if _, ok := seen[n]; ok { + continue + } + seen[n] = struct{}{} + queue = append(queue, n) + } + } + deactivate(tx, positions) +} + +// matchEndRing returns the 3x3 interior positions if the twelve canonical ring positions around centre all hold +// matching frames. +func matchEndRing(tx *world.Tx, center cube.Pos) ([]cube.Pos, bool) { + for _, want := range expectedEndRingFrames(center) { + b, ok := tx.Block(want.pos).(endFrameBlock) + if !ok { + return nil, false + } + eye, facing := b.EndPortalFrameState() + if !eye || facing != want.facing { + return nil, false + } + } + return endRingInterior(center), true +} + +// expectedEndRingFrames returns the twelve (position, facing) pairs a complete ring around the centre must have, with +// every frame facing toward the centre. +func expectedEndRingFrames(center cube.Pos) []endRingFrame { + frames := make([]endRingFrame, 0, 12) + for _, side := range cube.Directions() { + base := center.Side(side.Face()).Side(side.Face()) + tangent := side.RotateRight().Face() + inward := side.Opposite() + for _, pos := range []cube.Pos{base.Side(tangent.Opposite()), base, base.Side(tangent)} { + frames = append(frames, endRingFrame{pos: pos, facing: inward}) + } + } + return frames +} + +// endRingInterior returns the nine 3x3 interior positions on the y plane of centre. +func endRingInterior(center cube.Pos) []cube.Pos { + out := make([]cube.Pos, 0, 9) + for dx := -1; dx <= 1; dx++ { + for dz := -1; dz <= 1; dz++ { + out = append(out, center.Add(cube.Pos{dx, 0, dz})) + } + } + return out +} + +// endPortal returns the end_portal block. +func endPortal() world.Block { + p, ok := world.BlockByName("minecraft:end_portal", nil) + if !ok { + panic("could not find end_portal block") + } + return p +} diff --git a/server/world/portal/end_test.go b/server/world/portal/end_test.go new file mode 100644 index 0000000000..043d9bdff1 --- /dev/null +++ b/server/world/portal/end_test.go @@ -0,0 +1,225 @@ +package portal_test + +import ( + "testing" + + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/portal" +) + +// ringFrame is one (position, facing) pair on an end portal ring. +type ringFrame struct { + pos cube.Pos + facing cube.Direction +} + +// buildEndPortalRing places a complete twelve-frame end portal ring around center, with every frame holding an eye +// and facing the centre. +func buildEndPortalRing(tx *world.Tx, center cube.Pos) { + for _, fp := range endPortalRingFrames(center) { + tx.SetBlock(fp.pos, block.EndPortalFrame{Facing: fp.facing, Eye: true}, nil) + } +} + +// endRingOffsets is a hand-written oracle of the twelve (offset from centre, facing) pairs of a valid ring, +// independent of the production ring geometry. +var endRingOffsets = []ringFrame{ + {cube.Pos{-1, 0, -2}, cube.South}, {cube.Pos{0, 0, -2}, cube.South}, {cube.Pos{1, 0, -2}, cube.South}, + {cube.Pos{2, 0, -1}, cube.West}, {cube.Pos{2, 0, 0}, cube.West}, {cube.Pos{2, 0, 1}, cube.West}, + {cube.Pos{1, 0, 2}, cube.North}, {cube.Pos{0, 0, 2}, cube.North}, {cube.Pos{-1, 0, 2}, cube.North}, + {cube.Pos{-2, 0, 1}, cube.East}, {cube.Pos{-2, 0, 0}, cube.East}, {cube.Pos{-2, 0, -1}, cube.East}, +} + +// endPortalRingFrames returns the twelve (frame position, facing) pairs of a valid ring around center. +func endPortalRingFrames(center cube.Pos) []ringFrame { + frames := make([]ringFrame, len(endRingOffsets)) + for i, f := range endRingOffsets { + frames[i] = ringFrame{pos: center.Add(f.pos), facing: f.facing} + } + return frames +} + +// interiorPositions returns the 3x3 interior block positions around center. +func interiorPositions(center cube.Pos) []cube.Pos { + out := make([]cube.Pos, 0, 9) + for dx := -1; dx <= 1; dx++ { + for dz := -1; dz <= 1; dz++ { + out = append(out, center.Add(cube.Pos{dx, 0, dz})) + } + } + return out +} + +func TestActivateEndPortal(t *testing.T) { + w := world.New() + t.Cleanup(func() { _ = w.Close() }) + + center := cube.Pos{8, 10, 8} + mustDo(t, w, func(tx *world.Tx) { + buildEndPortalRing(tx, center) + + first := endPortalRingFrames(center)[0] + if !portal.ActivateEndPortal(tx, first.pos) { + t.Fatal("ActivateEndPortal() = false on a complete ring, want true") + } + for _, p := range interiorPositions(center) { + if _, ok := tx.Block(p).(block.EndPortal); !ok { + t.Fatalf("interior block at %v = %T, want block.EndPortal", p, tx.Block(p)) + } + } + }) +} + +func TestActivateEndPortalMissingEye(t *testing.T) { + w := world.New() + t.Cleanup(func() { _ = w.Close() }) + + center := cube.Pos{8, 10, 8} + mustDo(t, w, func(tx *world.Tx) { + buildEndPortalRing(tx, center) + + frames := endPortalRingFrames(center) + broken := frames[5] + tx.SetBlock(broken.pos, block.EndPortalFrame{Facing: broken.facing, Eye: false}, nil) + + if portal.ActivateEndPortal(tx, frames[0].pos) { + t.Fatal("ActivateEndPortal() = true with one missing eye, want false") + } + for _, p := range interiorPositions(center) { + if _, ok := tx.Block(p).(block.EndPortal); ok { + t.Fatalf("interior block at %v became EndPortal despite incomplete ring", p) + } + } + }) +} + +func TestActivateEndPortalWrongFacing(t *testing.T) { + w := world.New() + t.Cleanup(func() { _ = w.Close() }) + + center := cube.Pos{8, 10, 8} + mustDo(t, w, func(tx *world.Tx) { + buildEndPortalRing(tx, center) + + frames := endPortalRingFrames(center) + bad := frames[3] + tx.SetBlock(bad.pos, block.EndPortalFrame{Facing: bad.facing.Opposite(), Eye: true}, nil) + + if portal.ActivateEndPortal(tx, frames[0].pos) { + t.Fatal("ActivateEndPortal() = true with one mis-facing frame, want false") + } + }) +} + +func TestEnderEyeCompletesRing(t *testing.T) { + w := world.New() + t.Cleanup(func() { _ = w.Close() }) + + center := cube.Pos{8, 10, 8} + mustDo(t, w, func(tx *world.Tx) { + frames := endPortalRingFrames(center) + for i, fp := range frames { + tx.SetBlock(fp.pos, block.EndPortalFrame{Facing: fp.facing, Eye: i != 0}, nil) + } + + first := frames[0] + ctx := &item.UseContext{} + if !(item.EnderEye{}).UseOnBlock(first.pos, cube.FaceUp, cube.Pos{}.Vec3(), tx, nil, ctx) { + t.Fatal("EnderEye.UseOnBlock() = false on the last empty frame, want true") + } + if ctx.CountSub != 1 { + t.Fatalf("EnderEye.UseOnBlock() subtracted %d items, want 1", ctx.CountSub) + } + for _, p := range interiorPositions(center) { + if _, ok := tx.Block(p).(block.EndPortal); !ok { + t.Fatalf("interior block at %v = %T, want block.EndPortal", p, tx.Block(p)) + } + } + }) +} + +func TestEndPortalDespawnsOnFrameBreak(t *testing.T) { + w := world.New() + t.Cleanup(func() { _ = w.Close() }) + + center := cube.Pos{8, 10, 8} + mustDo(t, w, func(tx *world.Tx) { + buildEndPortalRing(tx, center) + first := endPortalRingFrames(center)[0] + if !portal.ActivateEndPortal(tx, first.pos) { + t.Fatal("ActivateEndPortal() = false on a complete ring, want true") + } + + // Break one frame: the portal blocks should despawn once the ring is no longer complete. + broken := endPortalRingFrames(center)[5] + tx.SetBlock(broken.pos, nil, nil) + + // Drive a neighbour update on an interior end_portal block, mimicking what the world does after the removal. + ep, ok := tx.Block(center).(block.EndPortal) + if !ok { + t.Fatalf("centre block = %T, want block.EndPortal", tx.Block(center)) + } + ep.NeighbourUpdateTick(center, broken.pos, tx) + + for _, p := range interiorPositions(center) { + if _, ok := tx.Block(p).(block.EndPortal); ok { + t.Fatalf("interior at %v still EndPortal after frame break", p) + } + } + }) +} + +func TestEndPortalKeptOnPortalBlockBreak(t *testing.T) { + w := world.New() + t.Cleanup(func() { _ = w.Close() }) + + center := cube.Pos{8, 10, 8} + mustDo(t, w, func(tx *world.Tx) { + buildEndPortalRing(tx, center) + if !portal.ActivateEndPortal(tx, endPortalRingFrames(center)[0].pos) { + t.Fatal("ActivateEndPortal() = false on a complete ring, want true") + } + + // Break the centre portal block: the ring is still complete, so the other portal blocks must stay. + tx.SetBlock(center, nil, nil) + neighbour := center.Add(cube.Pos{1, 0, 0}) + ep, ok := tx.Block(neighbour).(block.EndPortal) + if !ok { + t.Fatalf("neighbour block = %T, want block.EndPortal", tx.Block(neighbour)) + } + ep.NeighbourUpdateTick(neighbour, center, tx) + + for _, p := range interiorPositions(center) { + if p == center { + continue + } + if _, ok := tx.Block(p).(block.EndPortal); !ok { + t.Fatalf("interior at %v despawned after breaking a portal block", p) + } + } + }) +} + +func TestGenerateEndSpawnPlatformClearsThreeLayers(t *testing.T) { + w := world.New() + t.Cleanup(func() { _ = w.Close() }) + + mustDo(t, w, func(tx *world.Tx) { + for y := 49; y <= 52; y++ { + tx.SetBlock(cube.Pos{100, y, 0}, block.Obsidian{}, nil) + } + portal.GenerateEndSpawnPlatform(tx) + + for y := 49; y <= 51; y++ { + if _, ok := tx.Block(cube.Pos{100, y, 0}).(block.Air); !ok { + t.Fatalf("block at y=%d = %T, want block.Air", y, tx.Block(cube.Pos{100, y, 0})) + } + } + if _, ok := tx.Block(cube.Pos{100, 52, 0}).(block.Obsidian); !ok { + t.Fatalf("block at y=52 = %T, want block.Obsidian", tx.Block(cube.Pos{100, 52, 0})) + } + }) +} diff --git a/server/world/portal/nether.go b/server/world/portal/nether.go new file mode 100644 index 0000000000..9d1a7dfe10 --- /dev/null +++ b/server/world/portal/nether.go @@ -0,0 +1,335 @@ +package portal + +import ( + "math" + "math/rand/v2" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/block/model" + "github.com/df-mc/dragonfly/server/world" +) + +// Nether contains information about a nether portal structure. Values returned from this package are tied to the +// transaction that created them and must not be retained after that transaction finishes. +type Nether struct { + w, h int + framed bool + axis cube.Axis + tx *world.Tx + spawnPos cube.Pos + positions []cube.Pos +} + +const ( + minimumNetherPortalWidth, maximumNetherPortalWidth = 2, 21 + minimumNetherPortalHeight, maximumNetherPortalHeight = 3, 21 + + minimumNetherPortalArea = minimumNetherPortalWidth * minimumNetherPortalHeight +) + +// NetherPortalFromPos returns Nether portal information from a given position in the frame. +func NetherPortalFromPos(tx *world.Tx, pos cube.Pos) (Nether, bool) { + if tx.World().Dimension() == world.End { + return Nether{}, false + } + + axis, positions, width, height, completed, ok := multiAxisScan(pos, tx, matchesNetherPortalInterior) + if !ok { + axis, positions, width, height, completed, ok = multiAxisScan(pos, tx, matchesNetherPortal) + } + if !ok { + return Nether{}, false + } + return Nether{ + w: width, h: height, + spawnPos: pos, + positions: positions, + framed: completed, + axis: axis, + tx: tx, + }, ok +} + +// ActivateNetherPortal activates an inactive framed Nether portal at the position passed. +func ActivateNetherPortal(tx *world.Tx, pos cube.Pos) bool { + p, ok := NetherPortalFromPos(tx, pos) + if !ok || !p.Framed() || p.Activated() { + return false + } + ctx := tx.Event() + positions := append([]cube.Pos(nil), p.Positions()...) + if tx.World().Handler().HandlePortalActivate(ctx, world.Nether, positions); ctx.Cancelled() { + return false + } + p.Activate() + return true +} + +// DeactivateNetherPortal deactivates the connected Nether portal at the position passed. +func DeactivateNetherPortal(tx *world.Tx, pos cube.Pos) bool { + _, positions, ok := connectedNetherPortal(tx, pos) + if !ok { + return false + } + deactivate(tx, positions) + return true +} + +// FindOrCreateNetherPortal finds or creates a Nether portal at the given position. +func FindOrCreateNetherPortal(tx *world.Tx, pos cube.Pos, radius int) (Nether, bool) { + n, ok := FindNetherPortal(tx, pos, radius) + if ok { + return n, true + } + return CreateNetherPortal(tx, pos) +} + +// portalBlock represents a block that can be used as a portal to travel between dimensions. +type portalBlock interface { + // Portal returns the dimension that the portal leads to. + Portal() world.Dimension +} + +// frameBlock represents a block that can be used as a frame for a Nether portal. +type frameBlock interface { + // Frame returns true if the block is used as a frame for the given dimension. + Frame(dimension world.Dimension) bool +} + +// FindNetherPortal searches a provided radius for a Nether portal. +func FindNetherPortal(tx *world.Tx, pos cube.Pos, radius int) (Nether, bool) { + if tx.World().Dimension() == world.End { + return Nether{}, false + } + + closest, closestDist, found := Nether{}, math.MaxFloat64, false + seen := make(map[cube.Pos]struct{}) + for selectedPos := range tx.BlocksWithin(pos, radius, portal(cube.X), portal(cube.Z)) { + if _, ok := seen[selectedPos]; ok { + // Part of a portal that was already validated through an earlier block. + continue + } + if n, ok := NetherPortalFromPos(tx, selectedPos); ok && n.Framed() && n.Activated() { + for _, p := range n.Positions() { + seen[p] = struct{}{} + if dist := p.Vec3().Sub(pos.Vec3()).Len(); dist < closestDist { + closestDist, closest, found = dist, n, true + } + } + } + } + return closest, found +} + +// CreateNetherPortal creates a Nether portal at the given position. +func CreateNetherPortal(tx *world.Tx, pos cube.Pos) (Nether, bool) { + if tx.World().Dimension() == world.End { + return Nether{}, false + } + + resultPos, random, distance, a, r := pos, rand.IntN(4), -1.0, 0, tx.Range() + searchValidArea := func(directions int, valid func(pos cube.Pos, riv int, coEff1, coEff2 int) bool) { + for tempX := pos.X() - 16; tempX <= pos.X()+16; tempX++ { + offsetX := float64(tempX-pos.X()) + 0.5 + for tempZ := pos.Z() - 16; tempZ <= pos.Z()+16; tempZ++ { + offsetZ := float64(tempZ-pos.Z()) + 0.5 + for tempY := r.Max() - 1; tempY >= r.Min(); tempY-- { + entryPos := cube.Pos{tempX, tempY, tempZ} + if tx.Block(entryPos) != air() { + continue + } + + for tempY > r.Min() && tx.Block(entryPos.Side(cube.FaceDown)) == air() { + tempY-- + entryPos[1]-- + } + + for riv := random; riv < random+directions; riv++ { + coEff1 := riv % 2 + coEff2 := 1 - coEff1 + + if !valid(entryPos, riv, coEff1, coEff2) { + break + } + + offsetY := float64(tempY-pos.Y()) + 0.5 + newDist := offsetX*offsetX + offsetY*offsetY + offsetZ*offsetZ + if distance < 0.0 || newDist < distance { + distance = newDist + a = riv % directions + resultPos = cube.Pos{tempX, tempY, tempZ} + } + } + } + } + } + } + + // Search for a valid area in all four directions, adding some extra space for comfort. + searchValidArea(4, func(pos cube.Pos, riv int, coEff1, coEff2 int) bool { + if riv%4 >= 2 { + coEff1 = -coEff1 + coEff2 = -coEff2 + } + + for safeSpace1 := range 3 { + for safeSpace2 := -1; safeSpace2 < 3; safeSpace2++ { + for height := -1; height < 4; height++ { + b := tx.Block(cube.Pos{ + pos.X() + safeSpace2*coEff1 + safeSpace1*coEff2, + pos.Y() + height, + pos.Z() + safeSpace2*coEff2 - safeSpace1*coEff1, + }) + _, solid := b.Model().(model.Solid) + if height < 0 && !solid || height >= 0 && b != air() { + return false + } + } + } + } + return true + }) + + if distance < 0.0 { + // If we couldn't find a valid area under those specifications, we can search the two main directions instead, + // reducing comfort but at least allowing us to have a portal in the area. + searchValidArea(2, func(pos cube.Pos, riv int, coEff1, coEff2 int) bool { + for safeSpace := range 3 { + for height := -1; height < 4; height++ { + b := tx.Block(cube.Pos{ + pos.X() + safeSpace*coEff1, + pos.Y() + height, + pos.Z() + safeSpace*coEff2, + }) + _, solid := b.Model().(model.Solid) + if height < 0 && !solid || height >= 0 && b != air() { + return false + } + } + } + return true + }) + } + + coEff1 := a % 2 + coEff2 := 1 - coEff1 + if a%4 >= 2 { + coEff1 = -coEff1 + coEff2 = -coEff2 + } + + axis := cube.X + if coEff1 == 0 { + axis = cube.Z + } + + // Buffer the blocks the portal is made up of so a world.Handler may cancel the creation before any of them are + // written to the world. affected holds each position exactly once, in the order it was first set. + ob, pb := obsidian(), portal(axis) + blocks := make(map[cube.Pos]world.Block) + var affected []cube.Pos + setBlock := func(pos cube.Pos, b world.Block) { + if _, ok := blocks[pos]; !ok { + affected = append(affected, pos) + } + blocks[pos] = b + } + + if distance < 0.0 { + // If all else fails, we can simply create a floating platform in the void with the portal on it. + resultPos[1] = min(max(resultPos[1], 70), r.Max()-10) + for safeBeforeAfter := -1; safeBeforeAfter <= 1; safeBeforeAfter++ { + for safeWidth := range 2 { + for height := -1; height < 3; height++ { + entryPos := cube.Pos{ + resultPos.X() + safeWidth*coEff1 + safeBeforeAfter*coEff2, + resultPos.Y() + height, + resultPos.Z() + safeWidth*coEff2 - safeBeforeAfter*coEff1, + } + + if height < 0 { + setBlock(entryPos, ob) + } else { + setBlock(entryPos, nil) + } + } + } + } + } + + // Build the portal frame and activate it. + var positions []cube.Pos + for width := -1; width < 3; width++ { + for height := -1; height < 4; height++ { + entryPos := cube.Pos{ + resultPos.X() + width*coEff1, + resultPos.Y() + height, + resultPos.Z() + width*coEff2, + } + + if width == -1 || width == 2 || height == -1 || height == 3 { + setBlock(entryPos, ob) + continue + } + positions = append(positions, entryPos) + setBlock(entryPos, pb) + } + } + + ctx := tx.Event() + handlerPositions := append([]cube.Pos(nil), affected...) + if tx.World().Handler().HandlePortalCreate(ctx, world.Nether, handlerPositions); ctx.Cancelled() { + return Nether{}, false + } + for _, pos := range affected { + tx.SetBlock(pos, blocks[pos], nil) + } + + return Nether{ + w: minimumNetherPortalWidth, + h: minimumNetherPortalHeight, + framed: true, + spawnPos: resultPos, + positions: positions, + axis: axis, + tx: tx, + }, true +} + +// Activate ... +func (n Nether) Activate() { + for _, pos := range n.Positions() { + n.tx.SetBlock(pos, portal(n.axis), nil) + } +} + +func deactivate(tx *world.Tx, positions []cube.Pos) { + for _, pos := range positions { + tx.SetBlock(pos, nil, nil) + } +} + +// Framed ... +func (n Nether) Framed() bool { + return n.framed +} + +// Activated ... +func (n Nether) Activated() bool { + for _, pos := range n.Positions() { + if n.tx.Block(pos) != portal(n.axis) { + return false + } + } + return true +} + +// Spawn ... +func (n Nether) Spawn() cube.Pos { + return n.spawnPos +} + +// Positions ... +func (n Nether) Positions() []cube.Pos { + return n.positions +} diff --git a/server/world/portal/nether_test.go b/server/world/portal/nether_test.go new file mode 100644 index 0000000000..fd61f9ddf8 --- /dev/null +++ b/server/world/portal/nether_test.go @@ -0,0 +1,245 @@ +package portal_test + +import ( + "testing" + + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/block/model" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/portal" +) + +func TestNetherPortalFromPos(t *testing.T) { + tests := []struct { + name string + build func(tx *world.Tx, origin cube.Pos) + pos cube.Pos + ok bool + }{ + { + name: "valid vertical frame", + build: func(tx *world.Tx, origin cube.Pos) { + buildVerticalFrame(tx, origin, cube.Z, 2, 3) + }, + pos: cube.Pos{}, + ok: true, + }, + { + name: "valid X axis frame", + build: func(tx *world.Tx, origin cube.Pos) { + buildVerticalFrame(tx, origin, cube.X, 2, 3) + }, + pos: cube.Pos{}, + ok: true, + }, + { + name: "maximum size frame", + build: func(tx *world.Tx, origin cube.Pos) { + buildVerticalFrame(tx, origin, cube.Z, 21, 21) + }, + pos: cube.Pos{}, + ok: true, + }, + { + name: "too wide frame", + build: func(tx *world.Tx, origin cube.Pos) { + buildVerticalFrame(tx, origin, cube.Z, 22, 3) + }, + pos: cube.Pos{}, + }, + { + name: "too tall frame", + build: func(tx *world.Tx, origin cube.Pos) { + buildVerticalFrame(tx, origin, cube.Z, 2, 22) + }, + pos: cube.Pos{}, + }, + { + name: "horizontal frame", + build: func(tx *world.Tx, origin cube.Pos) { + buildHorizontalFrame(tx, origin) + }, + pos: cube.Pos{1, 0, 1}, + }, + { + name: "crying obsidian does not complete frame", + build: func(tx *world.Tx, origin cube.Pos) { + buildVerticalFrame(tx, origin, cube.Z, 2, 3) + tx.SetBlock(origin.Side(cube.FaceNorth), block.Obsidian{Crying: true}, nil) + }, + pos: cube.Pos{}, + }, + { + name: "missing side frame does not complete frame", + build: func(tx *world.Tx, origin cube.Pos) { + buildVerticalFrame(tx, origin, cube.Z, 2, 3) + tx.SetBlock(origin.Side(cube.FaceNorth), nil, nil) + }, + pos: cube.Pos{}, + }, + { + name: "soul fire does not activate frame", + build: func(tx *world.Tx, origin cube.Pos) { + buildVerticalFrame(tx, origin, cube.Z, 2, 3) + tx.SetBlock(origin, block.Fire{Type: block.SoulFire()}, nil) + }, + pos: cube.Pos{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := world.Config{Synchronous: true}.New() + t.Cleanup(func() { _ = w.Close() }) + + origin := cube.Pos{8, 10, 8} + mustDo(t, w, func(tx *world.Tx) { + tt.build(tx, origin) + p, ok := portal.NetherPortalFromPos(tx, origin.Add(tt.pos)) + if ok != tt.ok { + t.Fatalf("NetherPortalFromPos() ok = %v, want %v, portal = %#v", ok, tt.ok, p) + } + if ok && !p.Framed() { + t.Fatal("NetherPortalFromPos() returned an unframed portal") + } + }) + }) + } +} + +func TestPortalModelHasNoCollisionBBox(t *testing.T) { + for _, axis := range []cube.Axis{cube.X, cube.Z} { + if boxes := (model.Portal{Axis: axis}).BBox(cube.Pos{}, nil); len(boxes) != 0 { + t.Fatalf("BBox() returned %d boxes, want 0", len(boxes)) + } + } +} + +func TestActivateNetherPortal(t *testing.T) { + for _, axis := range []cube.Axis{cube.Z, cube.X} { + t.Run(axis.String(), func(t *testing.T) { + w := world.Config{Synchronous: true}.New() + t.Cleanup(func() { _ = w.Close() }) + + origin := cube.Pos{8, 10, 8} + mustDo(t, w, func(tx *world.Tx) { + buildVerticalFrame(tx, origin, axis, 2, 3) + if !portal.ActivateNetherPortal(tx, origin) { + t.Fatal("ActivateNetherPortal() = false, want true") + } + for x := range 2 { + for y := range 3 { + pos := origin.Add(widthOffset(axis, x)).Add(cube.Pos{0, y}) + pb, ok := tx.Block(pos).(block.Portal) + if !ok { + t.Fatalf("portal block not placed at interior offset %d,%d", x, y) + } + if pb.Axis != axis { + t.Fatalf("portal block at interior offset %d,%d has axis %v, want %v", x, y, pb.Axis, axis) + } + } + } + }) + }) + } +} + +func TestFireChargeActivatesNetherPortal(t *testing.T) { + w := world.Config{Synchronous: true}.New() + t.Cleanup(func() { _ = w.Close() }) + + origin := cube.Pos{8, 10, 8} + mustDo(t, w, func(tx *world.Tx) { + buildVerticalFrame(tx, origin, cube.Z, 2, 3) + ctx := &item.UseContext{} + if ok := (item.FireCharge{}).UseOnBlock(origin.Side(cube.FaceDown), cube.FaceUp, cube.Pos{}.Vec3(), tx, nil, ctx); !ok { + t.Fatal("FireCharge.UseOnBlock() = false, want true") + } + if ctx.CountSub != 1 { + t.Fatalf("FireCharge.UseOnBlock() subtracted %d items, want 1", ctx.CountSub) + } + if _, ok := tx.Block(origin).(block.Portal); !ok { + t.Fatal("FireCharge.UseOnBlock() did not activate portal") + } + }) +} + +func TestActivatedPortalCleanupOnBrokenFrame(t *testing.T) { + w := world.Config{Synchronous: true}.New() + t.Cleanup(func() { _ = w.Close() }) + + origin := cube.Pos{8, 10, 8} + mustDo(t, w, func(tx *world.Tx) { + buildVerticalFrame(tx, origin, cube.Z, 2, 3) + if !portal.ActivateNetherPortal(tx, origin) { + t.Fatal("ActivateNetherPortal() = false, want true") + } + + broken := origin.Add(widthOffset(cube.Z, 2)).Add(cube.Pos{0, 1}) + tx.SetBlock(broken, nil, nil) + + updated := origin.Add(widthOffset(cube.Z, 1)).Add(cube.Pos{0, 1}) + pb, ok := tx.Block(updated).(block.Portal) + if !ok { + t.Fatalf("block at updated position = %T, want block.Portal", tx.Block(updated)) + } + pb.NeighbourUpdateTick(updated, broken, tx) + + var remaining []cube.Pos + for x := range 2 { + for y := range 3 { + p := origin.Add(widthOffset(cube.Z, x)).Add(cube.Pos{0, y}) + if _, ok := tx.Block(p).(block.Portal); ok { + remaining = append(remaining, p) + } + } + } + if len(remaining) != 0 { + t.Fatalf("after frame break: %d orphan portal blocks remain at %v", len(remaining), remaining) + } + }) +} + +func buildVerticalFrame(tx *world.Tx, origin cube.Pos, axis cube.Axis, width, height int) { + for x := 0; x < width; x++ { + p := origin.Add(widthOffset(axis, x)) + tx.SetBlock(p.Side(cube.FaceDown), block.Obsidian{}, nil) + tx.SetBlock(p.Add(cube.Pos{0, height}), block.Obsidian{}, nil) + } + negative := cube.FaceNorth + if axis == cube.X { + negative = cube.FaceWest + } + for y := 0; y < height; y++ { + p := origin.Add(cube.Pos{0, y}) + tx.SetBlock(p.Side(negative), block.Obsidian{}, nil) + tx.SetBlock(p.Add(widthOffset(axis, width)), block.Obsidian{}, nil) + } +} + +func buildHorizontalFrame(tx *world.Tx, origin cube.Pos) { + for x := 0; x < 3; x++ { + for z := 0; z < 3; z++ { + if x == 1 && z == 1 { + continue + } + tx.SetBlock(origin.Add(cube.Pos{x, 0, z}), block.Obsidian{}, nil) + } + } +} + +func widthOffset(axis cube.Axis, width int) cube.Pos { + if axis == cube.X { + return cube.Pos{width, 0, 0} + } + return cube.Pos{0, 0, width} +} + +func mustDo(t *testing.T, w *world.World, f func(tx *world.Tx)) { + t.Helper() + if err := w.Do(f).Err(); err != nil { + t.Fatalf("world task failed: %v", err) + } +} diff --git a/server/world/portal/scan.go b/server/world/portal/scan.go new file mode 100644 index 0000000000..65d62b44ab --- /dev/null +++ b/server/world/portal/scan.go @@ -0,0 +1,192 @@ +package portal + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/block/model" + "github.com/df-mc/dragonfly/server/world" +) + +// blockMatcher reports whether a block belongs to a portal interior on the given axis. +type blockMatcher func(world.Block, cube.Axis) bool + +// multiAxisScan performs a scan on the Z and X axis, favouring the Z axis unless only the X axis reaches the minimum +// area. The last return value reports whether a portal-like interior was found; use Framed to check completion. +func multiAxisScan(framePos cube.Pos, tx *world.Tx, matches blockMatcher) (cube.Axis, []cube.Pos, int, int, bool, bool) { + zPositions, zWidth, zHeight, zCompleted := scan(cube.Z, framePos, tx, matches) + xPositions, xWidth, xHeight, xCompleted := scan(cube.X, framePos, tx, matches) + if len(zPositions) < minimumNetherPortalArea && len(xPositions) >= minimumNetherPortalArea { + return cube.X, xPositions, xWidth, xHeight, xCompleted, len(xPositions) > 0 + } + return cube.Z, zPositions, zWidth, zHeight, zCompleted, len(zPositions) > 0 +} + +// scan validates a vertical rectangular portal interior on the given horizontal axis. +func scan(axis cube.Axis, pos cube.Pos, tx *world.Tx, matches blockMatcher) ([]cube.Pos, int, int, bool) { + // Return if the starting block isn't part of a portal interior. + if !matches(tx.Block(pos), axis) { + return nil, 0, 0, false + } + negative, positive := axis.Faces() + + // Walk down then towards the negative face to land on the bottom-left interior corner. + origin := pos + for down, next := 0, origin.Side(cube.FaceDown); matches(tx.Block(next), axis); down, next = down+1, origin.Side(cube.FaceDown) { + if down >= maximumNetherPortalHeight { + return nil, 0, 0, false + } + origin = next + } + for left, next := 0, origin.Side(negative); matches(tx.Block(next), axis); left, next = left+1, origin.Side(negative) { + if left >= maximumNetherPortalWidth { + return nil, 0, 0, false + } + origin = next + } + + // Measure the bottom row and the leftmost column from the origin. + width := 0 + for p := origin; matches(tx.Block(p), axis); p = p.Side(positive) { + width++ + if width > maximumNetherPortalWidth { + return nil, 0, 0, false + } + } + height := 0 + for p := origin; matches(tx.Block(p), axis); p = p.Side(cube.FaceUp) { + height++ + if height > maximumNetherPortalHeight { + return nil, 0, 0, false + } + } + // Reject anything smaller than the minimum frame size. + if width < minimumNetherPortalWidth || height < minimumNetherPortalHeight { + return nil, width, height, false + } + + // Validate each row: side frames intact and every interior block matches. + positions := make([]cube.Pos, 0, width*height) + for y := 0; y < height; y++ { + row := origin.Add(cube.Pos{0, y}) + if !isFrame(tx.Block(row.Side(negative))) || !isFrame(tx.Block(row.Add(widthOffset(axis, width)))) { + return nil, width, height, false + } + for x := 0; x < width; x++ { + p := row.Add(widthOffset(axis, x)) + if !matches(tx.Block(p), axis) { + return nil, width, height, false + } + positions = append(positions, p) + } + } + // Validate the top and bottom frames over each column. + for x := 0; x < width; x++ { + p := origin.Add(widthOffset(axis, x)) + if !isFrame(tx.Block(p.Side(cube.FaceDown))) || !isFrame(tx.Block(p.Add(cube.Pos{0, height}))) { + return nil, width, height, false + } + } + return positions, width, height, true +} + +// connectedNetherPortal flood-fills the region of portal blocks reachable from pos and returns its axis and positions. +// Used to clean up an entire portal when its frame breaks, where scan would only return a partial rectangle. +func connectedNetherPortal(tx *world.Tx, pos cube.Pos) (cube.Axis, []cube.Pos, bool) { + for _, axis := range []cube.Axis{cube.Z, cube.X} { + if !matchesNetherPortal(tx.Block(pos), axis) { + continue + } + positions := connectedPortalBlocks(tx, pos, axis) + return axis, positions, len(positions) > 0 + } + return 0, nil, false +} + +// connectedPortalBlocks returns every portal block of the given axis reachable from pos via face neighbours. +func connectedPortalBlocks(tx *world.Tx, pos cube.Pos, axis cube.Axis) []cube.Pos { + var positions []cube.Pos + queue := []cube.Pos{pos} + seen := map[cube.Pos]struct{}{pos: {}} + faces := portalFaces(axis) + for len(queue) > 0 { + p := queue[0] + queue = queue[1:] + if !matchesNetherPortal(tx.Block(p), axis) { + continue + } + positions = append(positions, p) + for _, face := range faces { + next := p.Side(face) + if _, ok := seen[next]; ok { + continue + } + seen[next] = struct{}{} + queue = append(queue, next) + } + } + return positions +} + +// portalFaces returns the four neighbouring faces used to flood-fill a portal of the given horizontal axis. +func portalFaces(axis cube.Axis) []cube.Face { + negative, positive := axis.Faces() + return []cube.Face{cube.FaceDown, cube.FaceUp, negative, positive} +} + +// widthOffset returns the position offset for moving by the given number of blocks along the portal's width axis. +func widthOffset(axis cube.Axis, offset int) cube.Pos { + if axis == cube.X { + return cube.Pos{offset, 0, 0} + } + return cube.Pos{0, 0, offset} +} + +// isFrame reports whether the block can act as a Nether portal frame block. +func isFrame(b world.Block) bool { + f, ok := b.(frameBlock) + return ok && f.Frame(world.Nether) +} + +// matchesNetherPortalInterior reports whether the block may sit inside an unactivated Nether portal frame. +func matchesNetherPortalInterior(b world.Block, _ cube.Axis) bool { + i, ok := b.(interface { + PortalInterior(target world.Dimension) bool + }) + return ok && i.PortalInterior(world.Nether) +} + +// matchesNetherPortal reports whether the block is an active Nether portal block aligned with the given axis. +func matchesNetherPortal(b world.Block, axis cube.Axis) bool { + p, ok := b.(portalBlock) + if !ok || p.Portal() != world.Nether { + return false + } + m, ok := b.Model().(model.Portal) + return ok && m.Axis == axis +} + +// air returns an air block. +func air() world.Block { + a, ok := world.BlockByName("minecraft:air", nil) + if !ok { + panic("could not find air block") + } + return a +} + +// portal returns a portal block. +func portal(axis cube.Axis) world.Block { + p, ok := world.BlockByName("minecraft:portal", map[string]any{"portal_axis": axis.String()}) + if !ok { + panic("could not find portal block") + } + return p +} + +// obsidian returns an obsidian block. +func obsidian() world.Block { + o, ok := world.BlockByName("minecraft:obsidian", nil) + if !ok { + panic("could not find obsidian block") + } + return o +} diff --git a/server/world/provider.go b/server/world/provider.go index 713c38bfb1..f54cf0f59d 100644 --- a/server/world/provider.go +++ b/server/world/provider.go @@ -1,6 +1,8 @@ package world import ( + "sync" + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/world/chunk" "github.com/df-mc/goleveldb/leveldb" @@ -58,3 +60,52 @@ func (NopProvider) LoadPlayerSpawnPosition(uuid.UUID) (cube.Pos, bool, error) { } func (NopProvider) SavePlayerSpawnPosition(uuid.UUID, cube.Pos) error { return nil } func (NopProvider) Close() error { return nil } + +// lockedProvider wraps a Provider, serialising all calls for providers that +// are not safe for concurrent use. +type lockedProvider struct { + mu sync.Mutex + p Provider +} + +func (l *lockedProvider) Settings() *Settings { + l.mu.Lock() + defer l.mu.Unlock() + return l.p.Settings() +} + +func (l *lockedProvider) SaveSettings(s *Settings) { + l.mu.Lock() + defer l.mu.Unlock() + l.p.SaveSettings(s) +} + +func (l *lockedProvider) LoadPlayerSpawnPosition(id uuid.UUID) (cube.Pos, bool, error) { + l.mu.Lock() + defer l.mu.Unlock() + return l.p.LoadPlayerSpawnPosition(id) +} + +func (l *lockedProvider) SavePlayerSpawnPosition(id uuid.UUID, pos cube.Pos) error { + l.mu.Lock() + defer l.mu.Unlock() + return l.p.SavePlayerSpawnPosition(id, pos) +} + +func (l *lockedProvider) LoadColumn(pos ChunkPos, dim Dimension) (*chunk.Column, error) { + l.mu.Lock() + defer l.mu.Unlock() + return l.p.LoadColumn(pos, dim) +} + +func (l *lockedProvider) StoreColumn(pos ChunkPos, dim Dimension, col *chunk.Column) error { + l.mu.Lock() + defer l.mu.Unlock() + return l.p.StoreColumn(pos, dim, col) +} + +func (l *lockedProvider) Close() error { + l.mu.Lock() + defer l.mu.Unlock() + return l.p.Close() +} diff --git a/server/world/redstone.go b/server/world/redstone.go new file mode 100644 index 0000000000..df1de21313 --- /dev/null +++ b/server/world/redstone.go @@ -0,0 +1,1214 @@ +package world + +import ( + "maps" + "slices" + + "github.com/df-mc/dragonfly/server/block/cube" +) + +// RedstoneUpdateCause describes the world event that caused a redstone update to be evaluated. +type RedstoneUpdateCause uint8 + +const ( + // RedstoneUpdateCauseBlockUpdate means a block or liquid change invalidated nearby redstone. + RedstoneUpdateCauseBlockUpdate RedstoneUpdateCause = iota + // RedstoneUpdateCauseScheduledTick means a scheduled redstone tick invalidated a component. + RedstoneUpdateCauseScheduledTick + // RedstoneUpdateCauseCompilerRebuild means a redstone compiler rebuild invalidated a component. + RedstoneUpdateCauseCompilerRebuild +) + +// RedstoneUpdate represents a redstone state transition proposed by the world redstone engine. Handlers may cancel +// the event to suppress the proposed mutation and any propagation from that mutation. +type RedstoneUpdate struct { + // Pos is the block position that will receive the update. + Pos cube.Pos + // ChangedNeighbour is the neighbouring block that caused the update, if any. + ChangedNeighbour cube.Pos + // HasChangedNeighbour reports whether ChangedNeighbour is set. A zero block position is valid, so callers must not + // use ChangedNeighbour == cube.Pos{} as an absence check. + HasChangedNeighbour bool + // ChangedRedstoneRelevant reports whether ChangedNeighbour was a redstone component before or after the change. + ChangedRedstoneRelevant bool + // Source is the original block position that caused this redstone propagation, if known. + Source cube.Pos + // HasSource reports whether Source is set. + HasSource bool + // Before is the block currently at Pos. + Before Block + // After is the block that will replace Before, if the update is a block-state update. After is nil for updates + // that perform side effects instead of replacing the block. + After Block + // OldPower is the last redstone power observed by the engine at Pos. + OldPower int + // NewPower is the redstone power observed by the engine at Pos for this update. + NewPower int + // CurrentTick is the world tick during which the update was evaluated. + CurrentTick int64 + // Cause identifies why the update was evaluated. + Cause RedstoneUpdateCause +} + +// RedstonePowerSource is implemented by blocks that emit redstone power. The face passed is the face of the source +// block that power is being read from. +type RedstonePowerSource interface { + RedstonePower(pos cube.Pos, tx *Tx, face cube.Face) int +} + +// RedstoneStrongPowerSource is implemented by sources that strongly power blocks from specific faces. Strong power may +// pass through solid blocks, unlike weak redstone wire power. +type RedstoneStrongPowerSource interface { + RedstoneStrongPower(pos cube.Pos, tx *Tx, face cube.Face) int +} + +// RedstoneWeakBlockPowerer may be implemented by sources whose weak output can make an adjacent conductive block +// weakly powered. Weakly powered blocks activate adjacent mechanisms, but do not power adjacent redstone dust. +type RedstoneWeakBlockPowerer interface { + RedstoneWeaklyPowersBlocks() bool +} + +// RedstonePowerRelayer is implemented by redstone wire-like blocks that relay power through a compiled redstone +// network. The returned value is the signal loss when power crosses one relayer edge. +type RedstonePowerRelayer interface { + RedstoneSignalLoss(pos cube.Pos, tx *Tx) int +} + +// RedstonePowerRelayerNeighbourer may be implemented by relayers with non-adjacent connections, such as redstone +// wire stepping up and down block edges. +type RedstonePowerRelayerNeighbourer interface { + RedstoneRelayerNeighbours(pos cube.Pos, tx *Tx) []cube.Pos +} + +// RedstonePowerConsumer is implemented by blocks whose block state changes when their input power changes. The +// returned block is written to the world if changed is true and the redstone update event is not cancelled. +type RedstonePowerConsumer interface { + RedstonePowerUpdate(pos cube.Pos, tx *Tx, power int) (after Block, changed bool) +} + +// RedstonePowerPostUpdater may be implemented by consumers that need to apply side effects after an uncancelled +// redstone state update, such as syncing the other half of a door. +type RedstonePowerPostUpdater interface { + RedstonePowerPostUpdate(pos cube.Pos, tx *Tx, before, after Block, oldPower, newPower int) +} + +// RedstonePowerAction is implemented by blocks that perform a side effect when their input power changes, such as TNT +// priming on a rising edge. The action is run only if the redstone update event is not cancelled. +type RedstonePowerAction interface { + RedstonePowerAction(pos cube.Pos, tx *Tx, oldPower, newPower int) +} + +// RedstonePowerContextAction may be implemented by action blocks that need the proposed update metadata to distinguish +// self-caused redstone changes from external block updates. +type RedstonePowerContextAction interface { + RedstonePowerActionUpdate(pos cube.Pos, tx *Tx, update RedstoneUpdate) +} + +// RedstoneNonConductive may be implemented by solid redstone blocks that should not conduct strong power. +type RedstoneNonConductive interface { + RedstoneNonConductive() +} + +// redstoneEngine evaluates the immediate-power network for a world and caches transient power state. Neighbour updates +// and scheduled block ticks remain the semantic model for block behaviour; the engine just resolves wire/relayer power +// and lets consumers and actions schedule any delayed or directional changes themselves. +type redstoneEngine struct { + currentTick int64 + dirty map[cube.Pos]redstoneDirty + power map[cube.Pos]int + output map[cube.Pos]int + evaluating map[cube.Pos]struct{} + suppressedSources map[cube.Pos]int + torchBurnout map[cube.Pos]redstoneTorchBurnout +} + +// redstoneDirty records why a position needs redstone evaluation. +type redstoneDirty struct { + changed cube.Pos + hasChanged bool + changedRedstoneRelevant bool + source cube.Pos + hasSource bool + cause RedstoneUpdateCause +} + +// redstoneTorchBurnout tracks a torch's recent turn-off history and whether it is currently burned out. +type redstoneTorchBurnout struct { + offTicks []int64 + burnedOut bool + pendingSelfTriggered bool +} + +const ( + redstoneTorchBurnoutThreshold = 8 + redstoneTorchBurnoutWindowTicks = 60 +) + +// redstoneGraph is the immediate-power network compiled for one engine tick. Edges are signal loss between relayers +// only — never delay, direction locking, or mechanical state — so delayed or directional components stay as graph +// endpoints and drive their own state changes via scheduled ticks or actions. +type redstoneGraph struct { + nodes []redstoneNode + edges []redstoneEdge +} + +// redstoneNode represents one redstone-relevant block in a compiled graph. +type redstoneNode struct { + pos cube.Pos + source bool + sink bool +} + +// redstoneEdge connects two graph nodes with a signal-loss weight. +type redstoneEdge struct { + from, to int + weight int +} + +// newRedstoneEngine creates a redstone engine initialized at tick. +func newRedstoneEngine(tick int64) *redstoneEngine { + return &redstoneEngine{ + currentTick: tick, + dirty: make(map[cube.Pos]redstoneDirty), + power: make(map[cube.Pos]int), + output: make(map[cube.Pos]int), + evaluating: make(map[cube.Pos]struct{}), + } +} + +// invalidateAround marks pos and its direct neighbours dirty for redstone evaluation. +func (e *redstoneEngine) invalidateAround(pos, changed cube.Pos, cause RedstoneUpdateCause, r cube.Range) { + e.invalidateAroundWith(pos, redstoneDirty{changed: changed, hasChanged: true, source: changed, hasSource: true, cause: cause}, r) +} + +// invalidateAroundBlockChange marks pos and its direct neighbours dirty for a block change. +func (e *redstoneEngine) invalidateAroundBlockChange(pos cube.Pos, before, after Block, cause RedstoneUpdateCause, r cube.Range) { + d := redstoneDirty{ + changed: pos, + hasChanged: true, + changedRedstoneRelevant: isRedstoneRelevant(before) || isRedstoneRelevant(after), + source: pos, + hasSource: true, + cause: cause, + } + e.invalidateAroundWith(pos, d, r) +} + +// invalidateAroundWith marks pos and its direct neighbours dirty using the same update context. +func (e *redstoneEngine) invalidateAroundWith(pos cube.Pos, d redstoneDirty, r cube.Range) { + if e == nil || pos.OutOfBounds(r) { + return + } + e.invalidate(pos, d, r) + pos.Neighbours(func(neighbour cube.Pos) { + e.invalidate(neighbour, d, r) + }, r) +} + +// invalidate marks a single in-range position dirty for redstone evaluation. +func (e *redstoneEngine) invalidate(pos cube.Pos, d redstoneDirty, r cube.Range) { + if pos.OutOfBounds(r) { + return + } + if existing, ok := e.dirty[pos]; ok { + e.dirty[pos] = mergeRedstoneDirty(existing, d) + return + } + e.dirty[pos] = d +} + +// mergeRedstoneDirty keeps the strongest cause when multiple invalidations touch the same position before evaluation. +func mergeRedstoneDirty(a, b redstoneDirty) redstoneDirty { + if redstoneDirtyPriority(b) >= redstoneDirtyPriority(a) { + b.changedRedstoneRelevant = a.changedRedstoneRelevant || b.changedRedstoneRelevant + return b + } + a.changedRedstoneRelevant = a.changedRedstoneRelevant || b.changedRedstoneRelevant + return a +} + +func redstoneDirtyPriority(d redstoneDirty) int { + switch d.cause { + case RedstoneUpdateCauseBlockUpdate: + return 3 + case RedstoneUpdateCauseScheduledTick: + return 2 + case RedstoneUpdateCauseCompilerRebuild: + return 1 + default: + return 0 + } +} + +// removeChunk drops transient redstone state tied to an unloaded chunk. +func (e *redstoneEngine) removeChunk(chunkPos ChunkPos) { + if e == nil { + return + } + maps.DeleteFunc(e.dirty, func(pos cube.Pos, dirty redstoneDirty) bool { + return chunkPosFromBlockPos(pos) == chunkPos || + (dirty.hasChanged && chunkPosFromBlockPos(dirty.changed) == chunkPos) + }) + maps.DeleteFunc(e.power, func(pos cube.Pos, _ int) bool { + return chunkPosFromBlockPos(pos) == chunkPos + }) + maps.DeleteFunc(e.output, func(pos cube.Pos, _ int) bool { + return chunkPosFromBlockPos(pos) == chunkPos + }) + maps.DeleteFunc(e.torchBurnout, func(pos cube.Pos, _ redstoneTorchBurnout) bool { + return chunkPosFromBlockPos(pos) == chunkPos + }) +} + +// forget clears cached redstone input and output power for pos. +func (e *redstoneEngine) forget(pos cube.Pos) { + if e == nil { + return + } + delete(e.power, pos) + delete(e.output, pos) + delete(e.evaluating, pos) +} + +// tick evaluates all dirty redstone positions for the current world tick. +func (e *redstoneEngine) tick(tx *Tx, tick int64) { + if e == nil || len(e.dirty) == 0 { + return + } + e.currentTick = tick + dirty := maps.Clone(e.dirty) + clear(e.dirty) + + candidates := slices.Collect(maps.Keys(dirty)) + slices.SortFunc(candidates, compareBlockPos) + + graph := e.compile(tx, candidates) + cancelledSources, checkedSources := e.updateGraphSources(tx, graph, dirty) + previousSuppressed := e.suppressedSources + e.suppressedSources = cancelledSources + defer func() { + e.suppressedSources = previousSuppressed + }() + + powers := e.graphPower(tx, graph) + for i, node := range graph.nodes { + d := redstoneDirtyContext(dirty, node.pos) + if node.sink { + e.update(tx, node.pos, d, powers[i]) + } + } + for _, node := range graph.nodes { + if _, ok := checkedSources[node.pos]; ok { + continue + } + d := redstoneDirtyContext(dirty, node.pos) + if node.source { + e.updateSource(tx, node.pos, d) + } + } +} + +// redstoneDirtyContext returns the direct dirty context for pos, or the nearest dirty context that pulled pos into +// the current graph. Graph compilation intentionally includes connected blocks that are not dirty themselves. +func redstoneDirtyContext(dirty map[cube.Pos]redstoneDirty, pos cube.Pos) redstoneDirty { + if d, ok := dirty[pos]; ok { + return d + } + var ( + bestPos cube.Pos + best redstoneDirty + bestDist int + ok bool + ) + for dirtyPos, d := range dirty { + dist := redstoneManhattanDistance(pos, dirtyPos) + if !ok || dist < bestDist || (dist == bestDist && compareBlockPos(dirtyPos, bestPos) < 0) { + bestPos, best, bestDist, ok = dirtyPos, d, dist, true + } + } + if !ok { + return redstoneDirty{changed: pos, hasChanged: true, source: pos, hasSource: true, cause: RedstoneUpdateCauseCompilerRebuild} + } + return best +} + +// redstoneUpdate builds the public update payload for a dirty context. +func (d redstoneDirty) redstoneUpdate(pos cube.Pos, before Block, oldPower, newPower int, tick int64) RedstoneUpdate { + return RedstoneUpdate{ + Pos: pos, + ChangedNeighbour: d.changed, + HasChangedNeighbour: d.hasChanged, + ChangedRedstoneRelevant: d.changedRedstoneRelevant, + Source: d.source, + HasSource: d.hasSource, + Before: before, + OldPower: oldPower, + NewPower: newPower, + CurrentTick: tick, + Cause: d.cause, + } +} + +// propagatedFrom keeps the original source but records the block state that changed during propagation. +func (d redstoneDirty) propagatedFrom(pos cube.Pos) redstoneDirty { + d.changed = pos + d.hasChanged = true + d.changedRedstoneRelevant = true + return d +} + +func redstoneManhattanDistance(a, b cube.Pos) int { + return abs(a[0]-b[0]) + abs(a[1]-b[1]) + abs(a[2]-b[2]) +} + +func abs(v int) int { + if v < 0 { + return -v + } + return v +} + +// compile builds a deterministic graph around the candidate positions. +func (e *redstoneEngine) compile(tx *Tx, candidates []cube.Pos) redstoneGraph { + nodes := make([]redstoneNode, 0, len(candidates)) + seen := make(map[cube.Pos]struct{}, len(candidates)*2) + for _, pos := range candidates { + nodeCount := len(nodes) + e.compileRegion(tx, pos, seen, &nodes) + if len(nodes) == nodeCount { + e.compileAdjacentRedstone(tx, pos, seen, &nodes) + } + } + for i := 0; i < len(nodes); i++ { + e.compileAdjacentRedstone(tx, nodes[i].pos, seen, &nodes) + } + slices.SortFunc(nodes, func(a, b redstoneNode) int { + return compareBlockPos(a.pos, b.pos) + }) + edges := e.compileEdges(tx, nodes) + return redstoneGraph{nodes: nodes, edges: edges} +} + +// compileAdjacentRedstone adds redstone blocks that can interact with pos directly or through an adjacent conductor. +func (e *redstoneEngine) compileAdjacentRedstone(tx *Tx, pos cube.Pos, seen map[cube.Pos]struct{}, nodes *[]redstoneNode) { + if b, ok := tx.World().blockLoaded(pos); ok && e.redstoneBlockMayConduct(tx, pos, b) { + pos.Neighbours(func(neighbour cube.Pos) { + if b, ok := tx.World().blockLoaded(neighbour); ok && isRedstoneRelevant(b) { + e.compileRegion(tx, neighbour, seen, nodes) + } + }, tx.Range()) + } + pos.Neighbours(func(neighbour cube.Pos) { + b, ok := tx.World().blockLoaded(neighbour) + if !ok { + return + } + if isRedstoneRelevant(b) { + e.compileRegion(tx, neighbour, seen, nodes) + } + if e.redstoneBlockMayConduct(tx, neighbour, b) { + neighbour.Neighbours(func(conductedNeighbour cube.Pos) { + if b, ok := tx.World().blockLoaded(conductedNeighbour); ok && isRedstoneRelevant(b) { + e.compileRegion(tx, conductedNeighbour, seen, nodes) + } + }, tx.Range()) + } + }, tx.Range()) +} + +// redstoneBlockMayConduct reports whether a block should include its adjacent receivers in graph compilation. +func (e *redstoneEngine) redstoneBlockMayConduct(tx *Tx, pos cube.Pos, b Block) bool { + for _, face := range cube.Faces() { + if redstoneStrongPowerConductor(pos, b, tx, face) { + return true + } + } + return false +} + +// compileRegion walks a connected redstone-relevant region into nodes. +func (e *redstoneEngine) compileRegion(tx *Tx, pos cube.Pos, seen map[cube.Pos]struct{}, nodes *[]redstoneNode) { + if _, ok := seen[pos]; ok || pos.OutOfBounds(tx.Range()) { + return + } + queue := []cube.Pos{pos} + for len(queue) != 0 { + p := queue[0] + queue = queue[1:] + if _, ok := seen[p]; ok || p.OutOfBounds(tx.Range()) { + continue + } + seen[p] = struct{}{} + + b, ok := tx.World().blockLoaded(p) + if !ok { + continue + } + source, consumer, action, relayer := classifyRedstoneBlock(b) + if !source && !consumer && !action && !relayer { + continue + } + *nodes = append(*nodes, redstoneNode{ + pos: p, + source: source, + sink: consumer || action, + }) + if !relayer { + continue + } + for _, neighbour := range e.redstoneRelayerConnectedPositions(tx, p, b) { + if b, ok := tx.World().blockLoaded(neighbour); ok && isRedstoneRelevant(b) { + queue = append(queue, neighbour) + } + } + } +} + +// update applies a computed input power to a consumer or action block. +func (e *redstoneEngine) update(tx *Tx, pos cube.Pos, d redstoneDirty, newPower int) { + b := tx.Block(pos) + oldPower, newPower := e.power[pos], ClampRedstonePower(newPower) + + after, blockChanged := b, false + if consumer, ok := b.(RedstonePowerConsumer); ok { + after, blockChanged = consumer.RedstonePowerUpdate(pos, tx, newPower) + } + action, hasAction := b.(RedstonePowerAction) + contextAction, hasContextAction := b.(RedstonePowerContextAction) + + update := d.redstoneUpdate(pos, b, oldPower, newPower, e.currentTick) + if blockChanged { + update.After = after + } + shouldRunAction := hasContextAction || (hasAction && oldPower != newPower) + if oldPower != newPower || blockChanged || shouldRunAction { + if !e.redstoneUpdateAllowed(tx, update) { + return + } + } + + if !blockChanged && !shouldRunAction { + storeRedstonePower(e.power, pos, newPower) + return + } + + if blockChanged { + tx.SetBlock(pos, after, &SetOpts{DisableRedstoneUpdates: true}) + e.invalidateAroundWith(pos, d.propagatedFrom(pos), tx.Range()) + if postUpdater, ok := b.(RedstonePowerPostUpdater); ok { + postUpdater.RedstonePowerPostUpdate(pos, tx, b, after, oldPower, newPower) + } + } + if hasContextAction { + contextAction.RedstonePowerActionUpdate(pos, tx, update) + } else if shouldRunAction { + action.RedstonePowerAction(pos, tx, oldPower, newPower) + } + if blockChanged || shouldRunAction { + storeRedstonePower(e.power, pos, newPower) + } +} + +// updateGraphSources updates non-relayer source outputs before graph propagation. +func (e *redstoneEngine) updateGraphSources(tx *Tx, graph redstoneGraph, dirty map[cube.Pos]redstoneDirty) (map[cube.Pos]int, map[cube.Pos]struct{}) { + var cancelled map[cube.Pos]int + var checked map[cube.Pos]struct{} + for _, node := range graph.nodes { + if !node.source { + continue + } + b, ok := tx.World().blockLoaded(node.pos) + if !ok { + continue + } + if _, ok := b.(RedstonePowerRelayer); ok { + continue + } + if checked == nil { + checked = make(map[cube.Pos]struct{}) + } + checked[node.pos] = struct{}{} + + d := redstoneDirtyContext(dirty, node.pos) + if !e.updateSource(tx, node.pos, d) { + if cancelled == nil { + cancelled = make(map[cube.Pos]int) + } + cancelled[node.pos] = e.output[node.pos] + } + } + return cancelled, checked +} + +// updateSource updates cached output power for a source and reports whether it was allowed. +func (e *redstoneEngine) updateSource(tx *Tx, pos cube.Pos, d redstoneDirty) bool { + b := tx.Block(pos) + oldPower, newPower := e.output[pos], e.sourcePower(pos, tx) + if oldPower == newPower { + return true + } + update := d.redstoneUpdate(pos, b, oldPower, newPower, e.currentTick) + if !e.redstoneUpdateAllowed(tx, update) { + return false + } + storeRedstonePower(e.output, pos, newPower) + e.invalidateAroundWith(pos, d.propagatedFrom(pos), tx.Range()) + return true +} + +// storeRedstonePower stores non-zero redstone power and removes zero entries from cache maps. +func storeRedstonePower(cache map[cube.Pos]int, pos cube.Pos, power int) { + if power == 0 { + delete(cache, pos) + return + } + cache[pos] = power +} + +// directPower returns the strongest direct power reaching pos from any side. +func (e *redstoneEngine) directPower(pos cube.Pos, tx *Tx) int { + power := 0 + for _, face := range cube.Faces() { + power = max(power, e.directPowerFrom(pos, tx, face)) + } + return power +} + +// directPowerFrom returns direct power reaching pos through face. +func (e *redstoneEngine) directPowerFrom(pos cube.Pos, tx *Tx, face cube.Face) int { + neighbour := pos.Side(face) + if neighbour.OutOfBounds(tx.Range()) { + return 0 + } + b, ok := tx.World().blockLoaded(neighbour) + if !ok { + return 0 + } + if source, ok := b.(RedstonePowerSource); ok { + return ClampRedstonePower(e.redstonePower(source, neighbour, tx, face.Opposite())) + } + return 0 +} + +// strongPower returns the strongest strong power reaching pos from any side. +func (e *redstoneEngine) strongPower(pos cube.Pos, tx *Tx) int { + power := 0 + for _, face := range cube.Faces() { + power = max(power, e.strongPowerFrom(pos, tx, face)) + } + return power +} + +// strongPowerFrom returns strong power reaching pos through face. +func (e *redstoneEngine) strongPowerFrom(pos cube.Pos, tx *Tx, face cube.Face) int { + neighbour := pos.Side(face) + if neighbour.OutOfBounds(tx.Range()) { + return 0 + } + b, ok := tx.World().blockLoaded(neighbour) + if !ok { + return 0 + } + if source, ok := b.(RedstoneStrongPowerSource); ok { + if power, ok := e.suppressedSources[neighbour]; ok { + return ClampRedstonePower(power) + } + return ClampRedstonePower(source.RedstoneStrongPower(neighbour, tx, face.Opposite())) + } + return 0 +} + +// conductedStrongPower returns strong power conducted through adjacent conductive blocks. +func (e *redstoneEngine) conductedStrongPower(pos cube.Pos, tx *Tx) int { + power := 0 + for _, face := range cube.Faces() { + power = max(power, e.conductedStrongPowerFrom(pos, tx, face)) + } + return power +} + +// conductedStrongPowerFrom returns strong power conducted through the block on face. +func (e *redstoneEngine) conductedStrongPowerFrom(pos cube.Pos, tx *Tx, face cube.Face) int { + conductorPos := pos.Side(face) + if conductorPos.OutOfBounds(tx.Range()) { + return 0 + } + conductor, ok := tx.World().blockLoaded(conductorPos) + if !ok || !redstoneStrongPowerConductor(conductorPos, conductor, tx, face.Opposite()) { + return 0 + } + power := 0 + for _, sourceFace := range cube.Faces() { + power = max(power, e.strongPowerFrom(conductorPos, tx, sourceFace)) + } + return power +} + +// weakBlockPower returns weak power directly applied to a conductive block by sources that weak-power blocks. +func (e *redstoneEngine) weakBlockPower(pos cube.Pos, tx *Tx) int { + power := 0 + for _, face := range cube.Faces() { + power = max(power, e.weakBlockPowerFrom(pos, tx, face)) + } + return power +} + +// weakBlockPowerFrom returns weak power applied to pos from face by a source that weak-powers conductive blocks. +func (e *redstoneEngine) weakBlockPowerFrom(pos cube.Pos, tx *Tx, face cube.Face) int { + sourcePos := pos.Side(face) + if sourcePos.OutOfBounds(tx.Range()) { + return 0 + } + b, ok := tx.World().blockLoaded(sourcePos) + if !ok { + return 0 + } + if source, ok := b.(RedstonePowerSource); ok && e.redstoneWeaklyPowersBlocks(b) { + return ClampRedstonePower(e.redstonePower(source, sourcePos, tx, face.Opposite())) + } + return 0 +} + +// conductedWeakPower returns weak power conducted through adjacent conductive blocks for mechanism activation. +func (e *redstoneEngine) conductedWeakPower(pos cube.Pos, tx *Tx) int { + power := 0 + for _, face := range cube.Faces() { + power = max(power, e.conductedWeakPowerFrom(pos, tx, face)) + } + return power +} + +// conductedWeakPowerFrom returns weak power conducted through the block on face for mechanism activation. It excludes +// strong power and only counts sources that explicitly weak-power conductive blocks, such as redstone dust. +func (e *redstoneEngine) conductedWeakPowerFrom(pos cube.Pos, tx *Tx, face cube.Face) int { + conductorPos := pos.Side(face) + if conductorPos.OutOfBounds(tx.Range()) { + return 0 + } + conductor, ok := tx.World().blockLoaded(conductorPos) + if !ok || !redstoneStrongPowerConductor(conductorPos, conductor, tx, face.Opposite()) { + return 0 + } + return e.weakBlockPower(conductorPos, tx) +} + +// conductedActivationPower returns power that can activate a non-relayer mechanism behind adjacent conductive blocks. +func (e *redstoneEngine) conductedActivationPower(pos cube.Pos, tx *Tx) int { + return max(e.conductedStrongPower(pos, tx), e.conductedWeakPower(pos, tx)) +} + +// conductedActivationPowerFrom returns mechanism activation power conducted through the block on face. +func (e *redstoneEngine) conductedActivationPowerFrom(pos cube.Pos, tx *Tx, face cube.Face) int { + return max(e.conductedStrongPowerFrom(pos, tx, face), e.conductedWeakPowerFrom(pos, tx, face)) +} + +// conductivePowerTo returns power held by pos as a conductive block, excluding direct component activation. +func (e *redstoneEngine) conductivePowerTo(pos cube.Pos, tx *Tx) int { + b, ok := tx.World().blockLoaded(pos) + if !ok || !RedstoneFullPowerConductor(pos, b, tx) { + return 0 + } + return max(e.strongPower(pos, tx), e.weakBlockPower(pos, tx)) +} + +// acceptsDirectSourcePower reports whether direct source output should activate the block at pos. +func (e *redstoneEngine) acceptsDirectSourcePower(pos cube.Pos, tx *Tx) bool { + b, ok := tx.World().blockLoaded(pos) + if !ok { + return true + } + if isRedstoneRelevant(b) { + return true + } + return !RedstoneFullPowerConductor(pos, b, tx) +} + +// acceptsWeakConductedPower reports whether the block at pos may be activated by a weakly powered conductor. +func (e *redstoneEngine) acceptsWeakConductedPower(pos cube.Pos, tx *Tx) bool { + b, ok := tx.World().blockLoaded(pos) + if !ok { + return true + } + _, relayer := b.(RedstonePowerRelayer) + return !relayer +} + +// redstoneWeaklyPowersBlocks reports whether b's weak source output can weak-power adjacent conductive blocks. +func (e *redstoneEngine) redstoneWeaklyPowersBlocks(b Block) bool { + weakBlockPowerer, ok := b.(RedstoneWeakBlockPowerer) + return ok && weakBlockPowerer.RedstoneWeaklyPowersBlocks() +} + +// sourcePower returns the strongest output emitted by a source block. +func (e *redstoneEngine) sourcePower(pos cube.Pos, tx *Tx) int { + b, ok := tx.World().blockLoaded(pos) + if !ok { + return 0 + } + source, ok := b.(RedstonePowerSource) + if !ok { + return 0 + } + power := 0 + for _, face := range cube.Faces() { + power = max(power, ClampRedstonePower(e.redstonePower(source, pos, tx, face))) + } + return power +} + +// graphPower computes propagated graph power for every graph node. +func (e *redstoneEngine) graphPower(tx *Tx, graph redstoneGraph) []int { + powers := make([]int, len(graph.nodes)) + if len(graph.nodes) == 0 { + return powers + } + + index := make(map[cube.Pos]int, len(graph.nodes)) + sources := make([]RedstonePowerSource, len(graph.nodes)) + relayers := make([]RedstonePowerRelayer, len(graph.nodes)) + edges := make([][]redstoneEdge, len(graph.nodes)) + for i, node := range graph.nodes { + index[node.pos] = i + if b, ok := tx.World().blockLoaded(node.pos); ok { + sources[i], _ = b.(RedstonePowerSource) + relayers[i], _ = b.(RedstonePowerRelayer) + } + } + for _, edge := range graph.edges { + edges[edge.from] = append(edges[edge.from], edge) + } + + queue := make([]int, 0, len(graph.nodes)) + push := func(i, power int) { + power = ClampRedstonePower(power) + if power <= powers[i] { + return + } + powers[i] = power + queue = append(queue, i) + } + + for i, node := range graph.nodes { + if node.sink && relayers[i] == nil { + push(i, e.conductedActivationPower(node.pos, tx)) + continue + } + push(i, e.conductedStrongPower(node.pos, tx)) + } + + for i, source := range sources { + // Relayers such as redstone wire store their previous output as RedstonePower. + // They must be recomputed from real sources, not used as seeds themselves. + if source == nil || relayers[i] != nil { + continue + } + pos := graph.nodes[i].pos + for _, face := range cube.Faces() { + j, ok := index[pos.Side(face)] + if !ok { + continue + } + power := ClampRedstonePower(e.redstonePower(source, pos, tx, face)) + push(j, power) + } + } + + for head := 0; head < len(queue); head++ { + i := queue[head] + if relayers[i] == nil { + continue + } + for _, edge := range edges[i] { + push(edge.to, powers[i]-edge.weight) + } + } + return powers +} + +// powerTo returns the strongest redstone power currently reaching pos. +func (e *redstoneEngine) powerTo(pos cube.Pos, tx *Tx) int { + power := 0 + for _, face := range cube.Faces() { + power = max(power, e.powerFrom(pos, tx, face)) + } + if e.acceptsWeakConductedPower(pos, tx) { + power = max(power, e.conductedActivationPower(pos, tx)) + } else { + power = max(power, e.conductedStrongPower(pos, tx)) + } + return ClampRedstonePower(power) +} + +// powerFrom returns redstone power reaching pos through face. +func (e *redstoneEngine) powerFrom(pos cube.Pos, tx *Tx, face cube.Face) int { + power := e.conductedStrongPowerFrom(pos, tx, face) + if e.acceptsWeakConductedPower(pos, tx) { + power = e.conductedActivationPowerFrom(pos, tx, face) + } + type step struct { + pos cube.Pos + from cube.Face + loss int + depth int + } + queue := []step{{pos: pos.Side(face), from: face.Opposite(), loss: 0, depth: 0}} + seen := make(map[cube.Pos]int, 16) + for len(queue) != 0 { + s := queue[0] + queue = queue[1:] + if s.pos.OutOfBounds(tx.Range()) || s.loss >= 15 || s.depth >= 15 { + continue + } + if s.pos == pos { + continue + } + if loss, ok := seen[s.pos]; ok && loss <= s.loss { + continue + } + seen[s.pos] = s.loss + + b, ok := tx.World().blockLoaded(s.pos) + if !ok { + continue + } + relayer, isRelayer := b.(RedstonePowerRelayer) + // See graphPower: relayers carry recomputed power through edges, so their + // stored RedstonePower should not count as an independent source here. + if source, ok := b.(RedstonePowerSource); ok && !isRelayer && e.acceptsDirectSourcePower(pos, tx) { + power = max(power, ClampRedstonePower(e.redstonePower(source, s.pos, tx, s.from)-s.loss)) + } + if !isRelayer { + continue + } + for _, next := range e.redstoneRelayerNeighbourPositions(tx, s.pos, b) { + to := redstoneStepFace(s.pos, next) + if to == s.from { + continue + } + nextBlock, ok := tx.World().blockLoaded(next) + if !ok { + continue + } + loss := s.loss + if _, nextRelayer := nextBlock.(RedstonePowerRelayer); nextRelayer { + loss += max(relayer.RedstoneSignalLoss(s.pos, tx), 1) + } + if loss <= 15 { + queue = append(queue, step{pos: next, from: to.Opposite(), loss: loss, depth: s.depth + 1}) + } + } + } + return ClampRedstonePower(power) +} + +// torchBurnoutStatus returns whether the torch at pos is burned out and whether it can recover at currentTick. +func (e *redstoneEngine) torchBurnoutStatus(pos cube.Pos, currentTick int64) (burnedOut, recoverable bool) { + data, ok := e.pruneTorchBurnout(pos, currentTick) + if !ok { + return false, true + } + if !data.burnedOut { + return false, true + } + return true, len(data.offTicks) < redstoneTorchBurnoutThreshold +} + +// recordTorchTurnOff records a torch being forced off and reports whether that torch should burn out. +func (e *redstoneEngine) recordTorchTurnOff(pos cube.Pos, currentTick int64) bool { + if e.torchBurnout == nil { + e.torchBurnout = make(map[cube.Pos]redstoneTorchBurnout) + } + data, _ := e.pruneTorchBurnout(pos, currentTick) + data.offTicks = append(data.offTicks, currentTick) + if len(data.offTicks) >= redstoneTorchBurnoutThreshold { + data.burnedOut = true + } + e.torchBurnout[pos] = data + return data.burnedOut +} + +// clearTorchBurnout removes transient burnout state for a redstone torch. +func (e *redstoneEngine) clearTorchBurnout(pos cube.Pos) { + if e == nil { + return + } + delete(e.torchBurnout, pos) +} + +// markTorchSelfTriggered records that the next torch turn-off at pos was caused by that torch's own output loop. +func (e *redstoneEngine) markTorchSelfTriggered(pos cube.Pos) { + if e == nil { + return + } + if e.torchBurnout == nil { + e.torchBurnout = make(map[cube.Pos]redstoneTorchBurnout) + } + data := e.torchBurnout[pos] + data.pendingSelfTriggered = true + e.torchBurnout[pos] = data +} + +// consumeTorchSelfTriggered reports and clears whether the next torch turn-off at pos was self-triggered. +func (e *redstoneEngine) consumeTorchSelfTriggered(pos cube.Pos) bool { + if e == nil || e.torchBurnout == nil { + return false + } + data, ok := e.torchBurnout[pos] + if !ok { + return false + } + selfTriggered := data.pendingSelfTriggered + data.pendingSelfTriggered = false + if len(data.offTicks) == 0 && !data.burnedOut { + delete(e.torchBurnout, pos) + } else { + e.torchBurnout[pos] = data + } + return selfTriggered +} + +// pruneTorchBurnout removes expired turn-off entries and returns the remaining burnout data. +func (e *redstoneEngine) pruneTorchBurnout(pos cube.Pos, currentTick int64) (redstoneTorchBurnout, bool) { + if e == nil || e.torchBurnout == nil { + return redstoneTorchBurnout{}, false + } + data, ok := e.torchBurnout[pos] + if !ok { + return redstoneTorchBurnout{}, false + } + data.offTicks = slices.DeleteFunc(data.offTicks, func(tick int64) bool { + return currentTick-tick >= redstoneTorchBurnoutWindowTicks + }) + if len(data.offTicks) == 0 && !data.burnedOut && !data.pendingSelfTriggered { + delete(e.torchBurnout, pos) + return redstoneTorchBurnout{}, false + } + e.torchBurnout[pos] = data + return data, true +} + +// redstonePower reads source power while guarding against recursive source evaluation. +func (e *redstoneEngine) redstonePower(source RedstonePowerSource, pos cube.Pos, tx *Tx, face cube.Face) int { + if power, ok := e.suppressedSources[pos]; ok { + return ClampRedstonePower(power) + } + if _, ok := e.evaluating[pos]; ok { + return 0 + } + e.evaluating[pos] = struct{}{} + defer delete(e.evaluating, pos) + return source.RedstonePower(pos, tx, face) +} + +// redstoneUpdateAllowed dispatches redstone callbacks and reports whether the update was cancelled. +func (e *redstoneEngine) redstoneUpdateAllowed(tx *Tx, update RedstoneUpdate) bool { + ctx := tx.Event() + tx.World().Handler().HandleRedstoneUpdate(ctx, update) + return !ctx.Cancelled() +} + +// redstoneLightDiffuser is the local subset needed to exclude transparent conductors. +type redstoneLightDiffuser interface { + LightDiffusionLevel() uint8 +} + +// redstoneStrongPowerConductor reports whether b can conduct strong power through face. +func redstoneStrongPowerConductor(pos cube.Pos, b Block, tx *Tx, face cube.Face) bool { + if !b.Model().FaceSolid(pos, face, tx) { + return false + } + if _, ok := b.(RedstoneNonConductive); ok { + return false + } + if diffuser, ok := b.(redstoneLightDiffuser); ok && diffuser.LightDiffusionLevel() == 0 { + return false + } + return true +} + +// RedstoneFullPowerConductor reports whether b is a full solid redstone conductor according to the default redstone +// conductivity rules. +func RedstoneFullPowerConductor(pos cube.Pos, b Block, tx *Tx) bool { + for _, face := range cube.Faces() { + if !redstoneStrongPowerConductor(pos, b, tx, face) { + return false + } + } + return true +} + +// compileEdges builds deterministic weighted edges between relayer-connected nodes. +func (e *redstoneEngine) compileEdges(tx *Tx, nodes []redstoneNode) []redstoneEdge { + index := make(map[cube.Pos]int, len(nodes)) + for i, node := range nodes { + index[node.pos] = i + } + edges := make([]redstoneEdge, 0, len(nodes)) + for i, node := range nodes { + b, loaded := tx.World().blockLoaded(node.pos) + if !loaded { + continue + } + relayer, ok := b.(RedstonePowerRelayer) + if !ok { + continue + } + for _, neighbour := range e.redstoneRelayerNeighbourPositions(tx, node.pos, b) { + j, ok := index[neighbour] + if !ok { + continue + } + weight := 0 + if neighbourBlock, ok := tx.World().blockLoaded(neighbour); ok { + if _, neighbourRelayer := neighbourBlock.(RedstonePowerRelayer); neighbourRelayer { + weight = max(relayer.RedstoneSignalLoss(node.pos, tx), 1) + } + } + edges = append(edges, redstoneEdge{from: i, to: j, weight: weight}) + } + } + slices.SortFunc(edges, compareRedstoneEdge) + return edges +} + +// redstoneRelayerNeighbourPositions returns sorted relayer neighbours for b at pos. +func (e *redstoneEngine) redstoneRelayerNeighbourPositions(tx *Tx, pos cube.Pos, b Block) []cube.Pos { + if neighbourer, ok := b.(RedstonePowerRelayerNeighbourer); ok { + neighbours := slices.Clone(neighbourer.RedstoneRelayerNeighbours(pos, tx)) + slices.SortFunc(neighbours, compareBlockPos) + return neighbours + } + neighbours := make([]cube.Pos, 0, len(cube.Faces())) + e.redstoneRelayerNeighbours(tx, pos, func(neighbour cube.Pos) { + neighbours = append(neighbours, neighbour) + }) + slices.SortFunc(neighbours, compareBlockPos) + return neighbours +} + +// redstoneRelayerConnectedPositions returns relayers connected to pos in either direction. Graph membership must be +// weakly connected so a one-way relayer, such as dust climbing glowstone, still compiles with its lower input path. +func (e *redstoneEngine) redstoneRelayerConnectedPositions(tx *Tx, pos cube.Pos, b Block) []cube.Pos { + neighbours := e.redstoneRelayerNeighbourPositions(tx, pos, b) + seen := make(map[cube.Pos]struct{}, len(neighbours)+8) + for _, neighbour := range neighbours { + seen[neighbour] = struct{}{} + } + for _, candidate := range redstoneRelayerIncomingCandidates(pos, tx.Range()) { + if _, ok := seen[candidate]; ok { + continue + } + candidateBlock, ok := tx.World().blockLoaded(candidate) + if !ok { + continue + } + if _, ok := candidateBlock.(RedstonePowerRelayer); !ok { + continue + } + if slices.Contains(e.redstoneRelayerNeighbourPositions(tx, candidate, candidateBlock), pos) { + neighbours = append(neighbours, candidate) + seen[candidate] = struct{}{} + } + } + slices.SortFunc(neighbours, compareBlockPos) + return neighbours +} + +// redstoneRelayerIncomingCandidates returns nearby positions that can point at pos with the built-in relayer geometry. +func redstoneRelayerIncomingCandidates(pos cube.Pos, r cube.Range) []cube.Pos { + candidates := make([]cube.Pos, 0, 26) + for x := pos[0] - 1; x <= pos[0]+1; x++ { + for y := pos[1] - 1; y <= pos[1]+1; y++ { + for z := pos[2] - 1; z <= pos[2]+1; z++ { + candidate := cube.Pos{x, y, z} + if candidate == pos || candidate.OutOfBounds(r) { + continue + } + candidates = append(candidates, candidate) + } + } + } + return candidates +} + +// redstoneRelayerNeighbours visits the six default adjacent relayer neighbours. +func (e *redstoneEngine) redstoneRelayerNeighbours(tx *Tx, pos cube.Pos, f func(cube.Pos)) { + for _, face := range cube.Faces() { + neighbour := pos.Side(face) + if !neighbour.OutOfBounds(tx.Range()) { + f(neighbour) + } + } +} + +// redstoneStepFace returns the dominant face direction from one relayer position to another. +func redstoneStepFace(from, to cube.Pos) cube.Face { + dx, dy, dz := to[0]-from[0], to[1]-from[1], to[2]-from[2] + switch { + case dy > 0: + return cube.FaceUp + case dy < 0: + return cube.FaceDown + case dx > 0: + return cube.FaceEast + case dx < 0: + return cube.FaceWest + case dz > 0: + return cube.FaceSouth + case dz < 0: + return cube.FaceNorth + default: + return cube.FaceUp + } +} + +// classifyRedstoneBlock reports the redstone capabilities implemented by b. +func classifyRedstoneBlock(b Block) (source, consumer, action, relayer bool) { + _, source = b.(RedstonePowerSource) + _, consumer = b.(RedstonePowerConsumer) + _, action = b.(RedstonePowerAction) + if !action { + _, action = b.(RedstonePowerContextAction) + } + _, relayer = b.(RedstonePowerRelayer) + return +} + +// isRedstoneRelevant reports whether b should be included in redstone graph compilation. +func isRedstoneRelevant(b Block) bool { + source, consumer, action, relayer := classifyRedstoneBlock(b) + return source || consumer || action || relayer +} + +// compareBlockPos orders positions deterministically by Y, Z, then X. +func compareBlockPos(a, b cube.Pos) int { + if a[1] != b[1] { + return a[1] - b[1] + } + if a[2] != b[2] { + return a[2] - b[2] + } + return a[0] - b[0] +} + +// compareRedstoneEdge orders edges deterministically by endpoints and weight. +func compareRedstoneEdge(a, b redstoneEdge) int { + if a.from != b.from { + return a.from - b.from + } + if a.to != b.to { + return a.to - b.to + } + return a.weight - b.weight +} + +// ClampRedstonePower clamps power to the vanilla 0-15 redstone range. +func ClampRedstonePower(power int) int { + if power < 0 { + return 0 + } + if power > 15 { + return 15 + } + return power +} diff --git a/server/world/redstone_bench_test.go b/server/world/redstone_bench_test.go new file mode 100644 index 0000000000..ad1cc3b264 --- /dev/null +++ b/server/world/redstone_bench_test.go @@ -0,0 +1,43 @@ +package world + +import ( + "testing" + + "github.com/df-mc/dragonfly/server/block/cube" +) + +var redstoneDirtyTickBenchmarkPower int + +func BenchmarkRedstoneDirtyTickLongLineWithClocks(b *testing.B) { + const lineLength = 96 + + w := Config{Synchronous: true, Blocks: redstoneSignalLossTestRegistry()}.New() + defer w.Close() + + runWorld(w, func(tx *Tx) { + clockA := cube.Pos{-1, 64, 0} + clockB := cube.Pos{lineLength / 2, 64, 1} + line := make([]cube.Pos, lineLength) + for x := range lineLength { + line[x] = cube.Pos{x, 64, 0} + tx.SetBlock(line[x], redstoneLossRelayer{}, nil) + } + tx.SetBlock(clockA, redstoneLossSource{Power: 15}, nil) + tx.SetBlock(clockB, redstoneLossSource{Power: 15}, nil) + tx.SetBlock(cube.Pos{lineLength, 64, 0}, redstoneLossConsumer{}, nil) + tx.World().redstone.tick(tx, 0) + + b.ReportAllocs() + b.ResetTimer() + for tick := range b.N { + tx.World().redstone.invalidateAround(clockA, clockA, RedstoneUpdateCauseBlockUpdate, tx.Range()) + tx.World().redstone.invalidateAround(clockB, clockB, RedstoneUpdateCauseBlockUpdate, tx.Range()) + tx.World().redstone.tick(tx, int64(tick+1)) + } + b.StopTimer() + + if consumer, ok := tx.Block(cube.Pos{lineLength, 64, 0}).(redstoneLossConsumer); ok { + redstoneDirtyTickBenchmarkPower = consumer.Power + } + }) +} diff --git a/server/world/redstone_test.go b/server/world/redstone_test.go new file mode 100644 index 0000000000..161deea7d0 --- /dev/null +++ b/server/world/redstone_test.go @@ -0,0 +1,1115 @@ +package world + +import ( + "context" + "math/rand/v2" + "slices" + "testing" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/go-gl/mathgl/mgl64" +) + +var _ Handler = minimalRedstoneTestHandler{} + +type minimalRedstoneTestHandler struct{} + +func runWorld(w *World, f func(*Tx)) { + w.Do(f).Wait(context.Background()) +} + +func (minimalRedstoneTestHandler) HandleRedstoneUpdate(*Context, RedstoneUpdate) {} +func (minimalRedstoneTestHandler) HandleLiquidFlow(*Context, cube.Pos, cube.Pos, Liquid, Block) {} +func (minimalRedstoneTestHandler) HandleLiquidDecay(*Context, cube.Pos, Liquid, Liquid) {} +func (minimalRedstoneTestHandler) HandleLiquidHarden(*Context, cube.Pos, Block, Block, Block) {} +func (minimalRedstoneTestHandler) HandleSound(*Context, Sound, mgl64.Vec3) {} +func (minimalRedstoneTestHandler) HandleFireSpread(*Context, cube.Pos, cube.Pos) {} +func (minimalRedstoneTestHandler) HandleBlockBurn(*Context, cube.Pos) {} +func (minimalRedstoneTestHandler) HandleCropTrample(*Context, cube.Pos) {} +func (minimalRedstoneTestHandler) HandleLeavesDecay(*Context, cube.Pos) {} +func (minimalRedstoneTestHandler) HandlePortalCreate(*Context, Dimension, []cube.Pos) {} +func (minimalRedstoneTestHandler) HandlePortalActivate(*Context, Dimension, []cube.Pos) {} +func (minimalRedstoneTestHandler) HandleEntitySpawn(*Tx, Entity) {} +func (minimalRedstoneTestHandler) HandleEntityDespawn(*Tx, Entity) {} +func (minimalRedstoneTestHandler) HandleExplosion(*Context, ExplosionSource, *[]Entity, *[]cube.Pos, *float64, *bool) { +} +func (minimalRedstoneTestHandler) HandleClose(*Tx) {} + +func TestClampRedstonePower(t *testing.T) { + tests := []struct { + name string + power int + want int + }{ + {name: "negative", power: -1, want: 0}, + {name: "zero", power: 0, want: 0}, + {name: "middle", power: 8, want: 8}, + {name: "maximum", power: 15, want: 15}, + {name: "over maximum", power: 16, want: 15}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := ClampRedstonePower(test.power); got != test.want { + t.Fatalf("ClampRedstonePower(%d) = %d, want %d", test.power, got, test.want) + } + }) + } +} + +func TestRedstoneStepFace(t *testing.T) { + tests := []struct { + name string + from cube.Pos + to cube.Pos + want cube.Face + }{ + {name: "diagonal step up", from: cube.Pos{0, 64, 0}, to: cube.Pos{1, 65, 0}, want: cube.FaceUp}, + {name: "diagonal step down", from: cube.Pos{0, 64, 0}, to: cube.Pos{-1, 63, 0}, want: cube.FaceDown}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := redstoneStepFace(test.from, test.to); got != test.want { + t.Fatalf("redstoneStepFace(%v, %v) = %v, want %v", test.from, test.to, got, test.want) + } + }) + } +} + +func TestRedstoneTorchBurnoutRecoveryUsesRollingWindow(t *testing.T) { + e := newRedstoneEngine(0) + pos := cube.Pos{1, 64, 1} + for tick := int64(0); tick < redstoneTorchBurnoutThreshold; tick++ { + burnedOut := e.recordTorchTurnOff(pos, tick) + if burnedOut != (tick == redstoneTorchBurnoutThreshold-1) { + t.Fatalf("recordTorchTurnOff after %d turn-offs burnedOut=%t", tick+1, burnedOut) + } + } + + burnedOut, recoverable := e.torchBurnoutStatus(pos, redstoneTorchBurnoutWindowTicks-1) + if !burnedOut || recoverable { + t.Fatalf("burnout status with all eight turn-offs still inside the rolling window = burnedOut:%t recoverable:%t, want burnedOut:true recoverable:false", burnedOut, recoverable) + } + burnedOut, recoverable = e.torchBurnoutStatus(pos, redstoneTorchBurnoutWindowTicks) + if !burnedOut || !recoverable { + t.Fatalf("burnout status after earliest turn-off has aged out of the rolling window = burnedOut:%t recoverable:%t, want burnedOut:true recoverable:true", burnedOut, recoverable) + } +} + +func TestCompareBlockPosSortOrder(t *testing.T) { + positions := []cube.Pos{ + {3, 2, 1}, + {2, 1, 2}, + {1, 1, 1}, + {0, 1, 1}, + {0, 0, 9}, + {9, 0, -1}, + } + want := []cube.Pos{ + {9, 0, -1}, + {0, 0, 9}, + {0, 1, 1}, + {1, 1, 1}, + {2, 1, 2}, + {3, 2, 1}, + } + + slices.SortFunc(positions, compareBlockPos) + if !slices.Equal(positions, want) { + t.Fatalf("sorted positions = %v, want %v", positions, want) + } + if got := compareBlockPos(cube.Pos{1, 2, 3}, cube.Pos{1, 2, 3}); got != 0 { + t.Fatalf("compareBlockPos(equal positions) = %d, want 0", got) + } +} + +func TestRedstoneRelayerNeighbourPositionsAreDeterministic(t *testing.T) { + engine := newRedstoneEngine(0) + pos := cube.Pos{0, 64, 0} + got := engine.redstoneRelayerNeighbourPositions(nil, pos, redstoneNeighbourOrderTestBlock{neighbours: []cube.Pos{ + {1, 64, 0}, + {0, 63, 0}, + {0, 64, -1}, + {-1, 64, 0}, + {0, 65, 0}, + {0, 64, 1}, + }}) + want := []cube.Pos{ + {0, 63, 0}, + {0, 64, -1}, + {-1, 64, 0}, + {1, 64, 0}, + {0, 64, 1}, + {0, 65, 0}, + } + if !slices.Equal(got, want) { + t.Fatalf("redstone relayer neighbours = %v, want %v", got, want) + } +} + +func TestRedstoneStrongPowerConductorExcludesMarkedNonConductors(t *testing.T) { + pos := cube.Pos{0, 64, 0} + tests := []struct { + name string + block Block + want bool + }{ + {name: "solid block", block: redstoneSolidBlock{}, want: true}, + {name: "marked non-conductor", block: redstoneNonConductiveSolidBlock{}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := redstoneStrongPowerConductor(pos, test.block, nil, cube.FaceWest); got != test.want { + t.Fatalf("redstoneStrongPowerConductor(%T) = %t, want %t", test.block, got, test.want) + } + }) + } +} + +func TestRedstoneEngineInvalidateAround(t *testing.T) { + var nilEngine *redstoneEngine + nilEngine.invalidateAround(cube.Pos{0, 0, 0}, cube.Pos{0, 0, 0}, RedstoneUpdateCauseBlockUpdate, cube.Range{0, 0}) + + engine := newRedstoneEngine(42) + pos, changed := cube.Pos{8, 0, 8}, cube.Pos{9, 0, 8} + engine.invalidateAround(pos, changed, RedstoneUpdateCauseBlockUpdate, cube.Range{0, 1}) + + want := map[cube.Pos]redstoneDirty{ + {8, 0, 8}: redstoneDirty{changed: changed, hasChanged: true, source: changed, hasSource: true, cause: RedstoneUpdateCauseBlockUpdate}, + {9, 0, 8}: redstoneDirty{changed: changed, hasChanged: true, source: changed, hasSource: true, cause: RedstoneUpdateCauseBlockUpdate}, + {7, 0, 8}: redstoneDirty{changed: changed, hasChanged: true, source: changed, hasSource: true, cause: RedstoneUpdateCauseBlockUpdate}, + {8, 1, 8}: redstoneDirty{changed: changed, hasChanged: true, source: changed, hasSource: true, cause: RedstoneUpdateCauseBlockUpdate}, + {8, 0, 9}: redstoneDirty{changed: changed, hasChanged: true, source: changed, hasSource: true, cause: RedstoneUpdateCauseBlockUpdate}, + {8, 0, 7}: redstoneDirty{changed: changed, hasChanged: true, source: changed, hasSource: true, cause: RedstoneUpdateCauseBlockUpdate}, + } + if len(engine.dirty) != len(want) { + t.Fatalf("dirty positions = %v, want %v", engine.dirty, want) + } + for pos, dirty := range want { + if got, ok := engine.dirty[pos]; !ok || got != dirty { + t.Fatalf("dirty[%v] = %v, %t; want %v, true", pos, got, ok, dirty) + } + } + + engine.invalidateAround(cube.Pos{0, -1, 0}, changed, RedstoneUpdateCauseScheduledTick, cube.Range{0, 1}) + if len(engine.dirty) != len(want) { + t.Fatalf("out-of-bounds invalidation changed dirty positions to %v, want %v", engine.dirty, want) + } + for pos, dirty := range want { + if got, ok := engine.dirty[pos]; !ok || got != dirty { + t.Fatalf("dirty[%v] after out-of-bounds invalidation = %v, %t; want %v, true", pos, got, ok, dirty) + } + } +} + +func TestRedstoneEngineRemoveChunkKeepsUnchangedDirtyOutsideChunk(t *testing.T) { + engine := newRedstoneEngine(42) + unloadedChunk := chunkPosFromBlockPos(cube.Pos{0, 64, 0}) + dirtyPos := cube.Pos{32, 64, 0} + unchanged := redstoneDirty{cause: RedstoneUpdateCauseCompilerRebuild} + engine.dirty[dirtyPos] = unchanged + + engine.removeChunk(unloadedChunk) + if got, ok := engine.dirty[dirtyPos]; !ok || got != unchanged { + t.Fatalf("dirty[%v] after unrelated chunk removal = %v, %t; want %v, true", dirtyPos, got, ok, unchanged) + } + + changed := redstoneDirty{changed: cube.Pos{0, 64, 0}, hasChanged: true, cause: RedstoneUpdateCauseBlockUpdate} + engine.dirty[dirtyPos] = changed + engine.removeChunk(unloadedChunk) + if _, ok := engine.dirty[dirtyPos]; ok { + t.Fatalf("dirty[%v] with changed position in unloaded chunk was not removed", dirtyPos) + } +} + +func TestRedstoneEngineRemoveChunkClearsTransientStateInChunk(t *testing.T) { + engine := newRedstoneEngine(42) + unloadedPos := cube.Pos{0, 64, 0} + keptPos := cube.Pos{32, 64, 0} + unloadedChunk := chunkPosFromBlockPos(unloadedPos) + + engine.power[unloadedPos] = 15 + engine.output[unloadedPos] = 14 + engine.torchBurnout = map[cube.Pos]redstoneTorchBurnout{unloadedPos: {burnedOut: true}, keptPos: {burnedOut: true}} + + engine.power[keptPos] = 13 + engine.output[keptPos] = 12 + engine.removeChunk(unloadedChunk) + + if _, ok := engine.power[unloadedPos]; ok { + t.Fatalf("power for unloaded position %v was not cleared", unloadedPos) + } + if _, ok := engine.output[unloadedPos]; ok { + t.Fatalf("output for unloaded position %v was not cleared", unloadedPos) + } + if _, ok := engine.torchBurnout[unloadedPos]; ok { + t.Fatalf("torch burnout state for unloaded position %v was not cleared", unloadedPos) + } + if got := engine.power[keptPos]; got != 13 { + t.Fatalf("power for kept position = %d, want 13", got) + } + if got := engine.output[keptPos]; got != 12 { + t.Fatalf("output for kept position = %d, want 12", got) + } + if burnout, ok := engine.torchBurnout[keptPos]; !ok || !burnout.burnedOut { + t.Fatalf("torch burnout state for kept position = %v, %t; want burned out state", burnout, ok) + } +} + +func TestRedstoneCancelledSourceDoesNotPropagate(t *testing.T) { + sourcePos, sinkPos := cube.Pos{0, 64, 0}, cube.Pos{1, 64, 0} + w := Config{Synchronous: true, Blocks: redstoneCancellationTestRegistry()}.New() + defer w.Close() + + w.Handle(&redstoneCancellationHandler{cancel: map[cube.Pos]struct{}{sourcePos: {}}}) + var sinkPowered bool + var sourceOutput int + runWorld(w, func(tx *Tx) { + tx.SetBlock(sourcePos, redstoneCancellationSource{Power: 15}, nil) + tx.SetBlock(sinkPos, redstoneCancellationConsumer{}, nil) + tx.World().redstone.tick(tx, 1) + + sinkPowered = tx.Block(sinkPos).(redstoneCancellationConsumer).Powered + sourceOutput = tx.World().redstone.output[sourcePos] + }) + if sinkPowered { + t.Fatalf("sink powered after cancelling source update") + } + if sourceOutput != 0 { + t.Fatalf("stored source output = %d, want 0", sourceOutput) + } +} + +func TestRedstoneCancelledSourceKeepsPreviousOutputDuringEvaluation(t *testing.T) { + sourcePos := cube.Pos{0, 64, 0} + w := Config{Synchronous: true, Blocks: redstoneCancellationTestRegistry()}.New() + defer w.Close() + + w.Handle(&redstoneCancellationHandler{cancel: map[cube.Pos]struct{}{sourcePos: {}}}) + + var sourceOutput int + runWorld(w, func(tx *Tx) { + tx.SetBlock(sourcePos, redstoneCancellationSource{}, &SetOpts{DisableRedstoneUpdates: true}) + tx.World().redstone.output[sourcePos] = 15 + tx.World().redstone.invalidate(sourcePos, redstoneDirty{changed: sourcePos, hasChanged: true, source: sourcePos, hasSource: true, cause: RedstoneUpdateCauseBlockUpdate}, tx.Range()) + tx.World().redstone.tick(tx, 2) + + sourceOutput = tx.World().redstone.output[sourcePos] + }) + if sourceOutput != 15 { + t.Fatalf("stored source output after cancelled update = %d, want 15", sourceOutput) + } +} + +func TestRedstoneCancelledConsumerDoesNotUpdate(t *testing.T) { + sourcePos, sinkPos := cube.Pos{0, 64, 0}, cube.Pos{1, 64, 0} + w := Config{Synchronous: true, Blocks: redstoneCancellationTestRegistry()}.New() + defer w.Close() + + w.Handle(&redstoneCancellationHandler{cancel: map[cube.Pos]struct{}{sinkPos: {}}}) + var sinkPowered bool + runWorld(w, func(tx *Tx) { + tx.SetBlock(sourcePos, redstoneCancellationSource{Power: 15}, nil) + tx.SetBlock(sinkPos, redstoneCancellationConsumer{}, nil) + tx.World().redstone.tick(tx, 1) + + sinkPowered = tx.Block(sinkPos).(redstoneCancellationConsumer).Powered + }) + if sinkPowered { + t.Fatalf("sink powered after cancelling consumer update") + } +} + +func TestRedstoneUpdateIncludesContextMetadata(t *testing.T) { + sourcePos, sinkPos := cube.Pos{0, 64, 0}, cube.Pos{1, 64, 0} + w := Config{Synchronous: true, Blocks: redstoneCancellationTestRegistry()}.New() + defer w.Close() + + handler := &redstoneRecordingHandler{pos: sinkPos} + w.Handle(handler) + runWorld(w, func(tx *Tx) { + tx.SetBlock(sinkPos, redstoneCancellationConsumer{}, &SetOpts{DisableRedstoneUpdates: true}) + tx.SetBlock(sourcePos, redstoneCancellationSource{Power: 15}, nil) + tx.World().redstone.tick(tx, 7) + }) + if len(handler.updates) == 0 { + t.Fatal("no redstone update recorded for consumer") + } + update := handler.updates[0] + if update.Pos != sinkPos { + t.Fatalf("update Pos = %v, want %v", update.Pos, sinkPos) + } + if !update.HasChangedNeighbour { + t.Fatal("update did not record a changed neighbour") + } + if !update.ChangedRedstoneRelevant { + t.Fatal("update did not mark changed neighbour as redstone relevant") + } + if !update.HasSource || update.Source != sourcePos { + t.Fatalf("update source = %v, %t; want %v, true", update.Source, update.HasSource, sourcePos) + } + if update.OldPower != 0 || update.NewPower != 15 { + t.Fatalf("update power = old:%d new:%d, want old:0 new:15", update.OldPower, update.NewPower) + } + if update.CurrentTick != 7 { + t.Fatalf("update CurrentTick = %d, want 7", update.CurrentTick) + } + if update.Cause != RedstoneUpdateCauseBlockUpdate { + t.Fatalf("update Cause = %d, want RedstoneUpdateCauseBlockUpdate", update.Cause) + } + if _, ok := update.Before.(redstoneCancellationConsumer); !ok { + t.Fatalf("update Before = %T, want redstoneCancellationConsumer", update.Before) + } + if after, ok := update.After.(redstoneCancellationConsumer); !ok || !after.Powered { + t.Fatalf("update After = %T %#v, want powered redstoneCancellationConsumer", update.After, update.After) + } +} + +func TestRedstoneRecursiveSourceEvaluationReturnsZero(t *testing.T) { + sourcePos, targetPos := cube.Pos{0, 64, 0}, cube.Pos{1, 64, 0} + w := Config{Synchronous: true, Blocks: redstoneRecursiveSourceTestRegistry()}.New() + defer w.Close() + + var power int + runWorld(w, func(tx *Tx) { + tx.SetBlock(sourcePos, redstoneRecursiveSource{Target: targetPos}, nil) + power = tx.RedstonePower(targetPos) + }) + if power != 0 { + t.Fatalf("recursive source power = %d, want 0", power) + } +} + +func TestRedstoneCancelledActionDoesNotRun(t *testing.T) { + sourcePos, actionPos := cube.Pos{0, 64, 0}, cube.Pos{1, 64, 0} + w := Config{Synchronous: true, Blocks: redstoneCancellationTestRegistry()}.New() + defer w.Close() + + actions := 0 + redstoneCancellationActions = &actions + t.Cleanup(func() { + redstoneCancellationActions = nil + }) + w.Handle(&redstoneCancellationHandler{cancel: map[cube.Pos]struct{}{actionPos: {}}}) + runWorld(w, func(tx *Tx) { + tx.SetBlock(sourcePos, redstoneCancellationSource{Power: 15}, nil) + tx.SetBlock(actionPos, redstoneCancellationAction{}, nil) + tx.World().redstone.tick(tx, 1) + }) + if actions != 0 { + t.Fatalf("actions = %d, want 0", actions) + } +} + +func TestRedstoneActionOnlyRunsOnPowerChange(t *testing.T) { + sourcePos, actionPos := cube.Pos{0, 64, 0}, cube.Pos{1, 64, 0} + w := Config{Synchronous: true, Blocks: redstoneCancellationTestRegistry()}.New() + defer w.Close() + + actions := 0 + redstoneCancellationActions = &actions + t.Cleanup(func() { + redstoneCancellationActions = nil + }) + runWorld(w, func(tx *Tx) { + tx.SetBlock(sourcePos, redstoneCancellationSource{Power: 15}, nil) + tx.SetBlock(actionPos, redstoneCancellationAction{}, nil) + tx.World().redstone.tick(tx, 1) + tx.World().redstone.invalidate(actionPos, redstoneDirty{cause: RedstoneUpdateCauseBlockUpdate}, tx.Range()) + tx.World().redstone.tick(tx, 2) + }) + if actions != 1 { + t.Fatalf("actions after same-power dirty evaluation = %d, want 1", actions) + } +} + +func TestRedstoneRelayerToSinkDoesNotLosePower(t *testing.T) { + sourcePos, relayerPos, sinkPos := cube.Pos{0, 64, 0}, cube.Pos{1, 64, 0}, cube.Pos{2, 64, 0} + w := Config{Synchronous: true, Blocks: redstoneSignalLossTestRegistry()}.New() + defer w.Close() + + var directPower, sinkPower int + runWorld(w, func(tx *Tx) { + tx.SetBlock(sourcePos, redstoneLossSource{Power: 15}, nil) + tx.SetBlock(relayerPos, redstoneLossRelayer{}, nil) + tx.SetBlock(sinkPos, redstoneLossConsumer{}, nil) + + directPower = tx.RedstonePower(sinkPos) + tx.World().redstone.tick(tx, 1) + if sink, ok := tx.Block(sinkPos).(redstoneLossConsumer); ok { + sinkPower = sink.Power + } + }) + if directPower != 15 { + t.Fatalf("powerFrom through relayer into sink = %d, want 15", directPower) + } + if sinkPower != 15 { + t.Fatalf("graph power through relayer into sink = %d, want 15", sinkPower) + } +} + +func TestRedstoneVerticalRelayerPropagation(t *testing.T) { + tests := []struct { + name string + from cube.Pos + to cube.Pos + block Block + want int + }{ + {name: "glowstone upward", from: cube.Pos{1, 64, 0}, to: cube.Pos{0, 65, 0}, block: redstoneLadderGlowstone{}, want: 14}, + {name: "glowstone downward", from: cube.Pos{0, 65, 0}, to: cube.Pos{1, 64, 0}, block: redstoneLadderGlowstone{}, want: 0}, + {name: "glass downward", from: cube.Pos{0, 65, 0}, to: cube.Pos{1, 64, 0}, block: redstoneLadderGlass{}, want: 14}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + w := Config{Synchronous: true, Blocks: redstoneVerticalRelayerTestRegistry()}.New() + defer w.Close() + + var got int + runWorld(w, func(tx *Tx) { + low, high := cube.Pos{1, 64, 0}, cube.Pos{0, 65, 0} + source := test.from.Side(cube.FaceNorth) + tx.SetBlock(low, redstoneVerticalRelayer{Power: 0}, nil) + tx.SetBlock(high, redstoneVerticalRelayer{Power: 0}, nil) + tx.SetBlock(high.Side(cube.FaceDown), test.block, nil) + tx.SetBlock(source, redstoneVerticalSource{}, nil) + + tx.World().redstone.tick(tx, 1) + got = tx.Block(test.to).(redstoneVerticalRelayer).Power + }) + if got != test.want { + t.Fatalf("propagated power = %d, want %d", got, test.want) + } + }) + } +} + +func TestPlacedRedstoneTorchTurnsOffWhenAttachmentBecomesPowered(t *testing.T) { + w := Config{Synchronous: true, Blocks: redstoneTorchAttachmentTestRegistry()}.New() + defer w.Close() + + torchPos := cube.Pos{1, 64, 0} + attachmentPos := torchPos.Side(cube.FaceWest) + var lit bool + runWorld(w, func(tx *Tx) { + tx.SetBlock(attachmentPos, redstoneSolidBlock{}, nil) + tx.SetBlock(torchPos, redstoneAttachmentTorch{Facing: cube.FaceWest, Lit: true}, nil) + tx.SetBlock(attachmentPos.Side(cube.FaceNorth), redstoneWeakBlockSource{}, nil) + + tx.World().scheduledUpdates.tick(tx, 2) + tx.World().redstone.tick(tx, 2) + lit = tx.Block(torchPos).(redstoneAttachmentTorch).Lit + }) + + if lit { + t.Fatal("torch stayed lit after its attachment block became powered") + } +} + +func TestRedstoneConsumerUpdatesBehindPoweredConductor(t *testing.T) { + w := Config{Synchronous: true, Blocks: redstonePoweredConductorTestRegistry()}.New() + defer w.Close() + + sourcePos, conductorPos, consumerPos := cube.Pos{0, 64, 0}, cube.Pos{1, 64, 0}, cube.Pos{2, 64, 0} + var powered bool + runWorld(w, func(tx *Tx) { + tx.SetBlock(sourcePos, redstoneStrongSource{}, nil) + tx.SetBlock(conductorPos, redstoneSolidBlock{}, nil) + tx.SetBlock(consumerPos, redstoneCancellationConsumer{}, nil) + + tx.World().redstone.tick(tx, 1) + powered = tx.Block(consumerPos).(redstoneCancellationConsumer).Powered + }) + + if !powered { + t.Fatal("consumer behind strongly powered conductor was not powered") + } +} + +func TestWeaklyPoweredConductorActivatesConsumerButNotDust(t *testing.T) { + w := Config{Synchronous: true, Blocks: redstoneWeakConductorTestRegistry()}.New() + defer w.Close() + + sourcePos := cube.Pos{0, 64, 0} + conductorPos := sourcePos.Side(cube.FaceEast) + consumerPos := conductorPos.Side(cube.FaceEast) + dustPos := conductorPos.Side(cube.FaceSouth) + var consumerPowered bool + var dustPower int + runWorld(w, func(tx *Tx) { + tx.SetBlock(sourcePos, redstoneWeakBlockSource{}, nil) + tx.SetBlock(conductorPos, redstoneSolidBlock{}, nil) + tx.SetBlock(consumerPos, redstoneCancellationConsumer{}, nil) + tx.SetBlock(dustPos, redstoneVerticalRelayer{}, nil) + + tx.World().redstone.tick(tx, 1) + consumerPowered = tx.Block(consumerPos).(redstoneCancellationConsumer).Powered + dustPower = tx.Block(dustPos).(redstoneVerticalRelayer).Power + }) + + if !consumerPowered { + t.Fatal("consumer behind weakly powered conductor was not powered") + } + if dustPower != 0 { + t.Fatalf("dust behind weakly powered conductor = %d, want 0", dustPower) + } +} + +func TestDirectSourceDoesNotWeakPowerConductor(t *testing.T) { + w := Config{Synchronous: true, Blocks: redstonePoweredConductorTestRegistry()}.New() + defer w.Close() + + sourcePos, conductorPos, consumerPos := cube.Pos{0, 64, 0}, cube.Pos{1, 64, 0}, cube.Pos{2, 64, 0} + var powered bool + runWorld(w, func(tx *Tx) { + tx.SetBlock(sourcePos, redstoneCancellationSource{Power: 15}, nil) + tx.SetBlock(conductorPos, redstoneSolidBlock{}, nil) + tx.SetBlock(consumerPos, redstoneCancellationConsumer{}, nil) + + tx.World().redstone.tick(tx, 1) + powered = tx.Block(consumerPos).(redstoneCancellationConsumer).Powered + }) + + if powered { + t.Fatal("direct-only source weak-powered a conductor") + } +} + +func TestScheduledTickQueueDuplicateScheduling(t *testing.T) { + tests := []struct { + name string + delays []time.Duration + wantTicks []int64 + }{ + {name: "keeps earlier tick when later tick is scheduled", delays: []time.Duration{time.Second / 20, time.Second / 10}, wantTicks: []int64{101, 102}}, + {name: "ignores earlier tick behind later tick", delays: []time.Duration{time.Second / 10, time.Second / 20}, wantTicks: []int64{102}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + queue := newScheduledTickQueue(100) + pos := cube.Pos{8, 64, 8} + b := scheduledTickTestBlock{} + + for _, delay := range test.delays { + queue.schedule(DefaultBlockRegistry, pos, b, delay) + } + + index := scheduledTickIndex{pos: pos, hash: DefaultBlockRegistry.BlockHash(b)} + if got, want := queue.furthestTicks[index], int64(102); got != want { + t.Fatalf("furthest tick = %d, want %d", got, want) + } + ticks := queue.fromChunk(chunkPosFromBlockPos(pos)) + if len(ticks) != len(test.wantTicks) { + t.Fatalf("active ticks = %v, want %v", ticks, test.wantTicks) + } + for i, want := range test.wantTicks { + if got := ticks[i].t; got != want { + t.Fatalf("active tick %d = %d, want %d; ticks=%v", i, got, want, ticks) + } + } + }) + } +} + +func TestScheduledTickQueueRemoveChunkClearsSchedule(t *testing.T) { + queue := newScheduledTickQueue(100) + pos := cube.Pos{8, 64, 8} + b := scheduledTickTestBlock{} + + queue.schedule(DefaultBlockRegistry, pos, b, time.Second) + queue.removeChunk(chunkPosFromBlockPos(pos)) + + if len(queue.ticks) != 0 { + t.Fatalf("ticks after removeChunk = %v, want empty", queue.ticks) + } + if len(queue.furthestTicks) != 0 { + t.Fatalf("furthest ticks after removeChunk = %v, want empty", queue.furthestTicks) + } +} + +func TestScheduledTickQueueCanRescheduleWhileCurrentTickIsDue(t *testing.T) { + queue := newScheduledTickQueue(100) + pos := cube.Pos{8, 64, 8} + b := scheduledTickTestBlock{} + index := scheduledTickIndex{pos: pos, hash: DefaultBlockRegistry.BlockHash(b)} + queue.furthestTicks[index] = 100 + + queue.schedule(DefaultBlockRegistry, pos, b, time.Second/2) + if got, want := queue.furthestTicks[index], int64(110); got != want { + t.Fatalf("rescheduled tick = %d, want %d", got, want) + } +} + +func TestScheduledTickQueueExecutesEarlierDueTickBeforeLaterTick(t *testing.T) { + registry := scheduledTickTestRegistry() + w := Config{Synchronous: true, Blocks: registry}.New() + defer w.Close() + + queue := newScheduledTickQueue(100) + pos := cube.Pos{8, 64, 8} + b := scheduledTickTestBlock{} + index := scheduledTickIndex{pos: pos, hash: registry.BlockHash(b)} + + ticks := 0 + scheduledTickTestBlockTicks = &ticks + t.Cleanup(func() { + scheduledTickTestBlockTicks = nil + }) + + var ( + ticksAfterFirst, ticksAfterSecond int + activeAfterFirst, activeAfterSecond []scheduledTick + furthestAfterFirst int64 + hasFurthestAfterFirst bool + ) + runWorld(w, func(tx *Tx) { + tx.SetBlock(pos, b, nil) + queue.schedule(registry, pos, b, time.Second/20) + queue.schedule(registry, pos, b, time.Second/10) + + queue.tick(tx, 101) + ticksAfterFirst = ticks + activeAfterFirst = queue.fromChunk(chunkPosFromBlockPos(pos)) + furthestAfterFirst, hasFurthestAfterFirst = queue.furthestTicks[index] + + queue.tick(tx, 102) + ticksAfterSecond = ticks + activeAfterSecond = queue.fromChunk(chunkPosFromBlockPos(pos)) + }) + if ticksAfterFirst != 1 { + t.Fatalf("earlier due tick executed %d time(s), want 1", ticksAfterFirst) + } + if !hasFurthestAfterFirst || furthestAfterFirst != 102 { + t.Fatalf("furthest tick after earlier tick = %d, %t; want 102, true", furthestAfterFirst, hasFurthestAfterFirst) + } + if len(activeAfterFirst) != 1 || activeAfterFirst[0].t != 102 { + t.Fatalf("active ticks after earlier tick = %v, want only tick 102", activeAfterFirst) + } + if ticksAfterSecond != 2 { + t.Fatalf("later scheduled tick execution count = %d, want 2", ticksAfterSecond) + } + if len(activeAfterSecond) != 0 { + t.Fatalf("active ticks after later tick = %v, want empty", activeAfterSecond) + } +} + +type scheduledTickTestBlock struct{} + +// Test counters below are package-level because block instances are registry values; tests using them must stay serial. +var scheduledTickTestBlockTicks *int + +func (scheduledTickTestBlock) ScheduledTick(cube.Pos, *Tx, *rand.Rand) { + if scheduledTickTestBlockTicks != nil { + (*scheduledTickTestBlockTicks)++ + } +} +func (scheduledTickTestBlock) EncodeBlock() (string, map[string]any) { + return "test:scheduled_tick", nil +} +func (scheduledTickTestBlock) Hash() (uint64, uint64) { return 1 << 40, 0 } +func (scheduledTickTestBlock) Model() BlockModel { return nil } + +func scheduledTickTestRegistry() BlockRegistry { + registry := NewBlockRegistry() + registry.RegisterBlockState(BlockState{Name: "test:scheduled_tick", Properties: map[string]any{}}) + registry.RegisterBlock(scheduledTickTestBlock{}) + return registry +} + +type redstoneNeighbourOrderTestBlock struct { + neighbours []cube.Pos +} + +func (b redstoneNeighbourOrderTestBlock) RedstoneRelayerNeighbours(cube.Pos, *Tx) []cube.Pos { + return slices.Clone(b.neighbours) +} +func (redstoneNeighbourOrderTestBlock) EncodeBlock() (string, map[string]any) { + return "test:redstone_neighbour_order", nil +} +func (redstoneNeighbourOrderTestBlock) Hash() (uint64, uint64) { return 1 << 41, 0 } +func (redstoneNeighbourOrderTestBlock) Model() BlockModel { return nil } + +type redstoneCancellationHandler struct { + NopHandler + cancel map[cube.Pos]struct{} +} + +func (h *redstoneCancellationHandler) HandleRedstoneUpdate(ctx *Context, update RedstoneUpdate) { + if _, ok := h.cancel[update.Pos]; ok { + ctx.Cancel() + } +} + +type redstoneRecordingHandler struct { + NopHandler + pos cube.Pos + updates []RedstoneUpdate +} + +func (h *redstoneRecordingHandler) HandleRedstoneUpdate(_ *Context, update RedstoneUpdate) { + if update.Pos == h.pos { + h.updates = append(h.updates, update) + } +} + +var redstoneCancellationActions *int + +func redstoneCancellationTestRegistry() BlockRegistry { + registry := NewBlockRegistry() + for _, power := range []int32{0, 15} { + registry.RegisterBlockState(BlockState{Name: "test:redstone_source", Properties: map[string]any{"power": power}}) + registry.RegisterBlock(redstoneCancellationSource{Power: int(power)}) + } + for _, powered := range []bool{false, true} { + registry.RegisterBlockState(BlockState{Name: "test:redstone_consumer", Properties: map[string]any{"powered": powered}}) + registry.RegisterBlock(redstoneCancellationConsumer{Powered: powered}) + } + registry.RegisterBlockState(BlockState{Name: "test:redstone_action", Properties: map[string]any{}}) + registry.RegisterBlock(redstoneCancellationAction{}) + return registry +} + +type redstoneCancellationSource struct { + Power int +} + +func (b redstoneCancellationSource) RedstonePower(cube.Pos, *Tx, cube.Face) int { + return b.Power +} +func (b redstoneCancellationSource) EncodeBlock() (string, map[string]any) { + return "test:redstone_source", map[string]any{"power": int32(b.Power)} +} +func (b redstoneCancellationSource) Hash() (uint64, uint64) { + return 1 << 42, uint64(b.Power) +} +func (redstoneCancellationSource) Model() BlockModel { return redstoneCancellationModel{} } + +func redstoneRecursiveSourceTestRegistry() BlockRegistry { + registry := NewBlockRegistry() + registry.RegisterBlockState(BlockState{Name: "test:redstone_recursive_source", Properties: map[string]any{}}) + registry.RegisterBlock(redstoneRecursiveSource{}) + return registry +} + +type redstoneRecursiveSource struct { + Target cube.Pos +} + +func (b redstoneRecursiveSource) RedstonePower(_ cube.Pos, tx *Tx, _ cube.Face) int { + return tx.RedstonePower(b.Target) +} +func (redstoneRecursiveSource) EncodeBlock() (string, map[string]any) { + return "test:redstone_recursive_source", nil +} +func (redstoneRecursiveSource) Hash() (uint64, uint64) { return 1 << 57, 0 } +func (redstoneRecursiveSource) Model() BlockModel { return redstoneCancellationModel{} } + +type redstoneCancellationConsumer struct { + Powered bool +} + +func (b redstoneCancellationConsumer) RedstonePowerUpdate(_ cube.Pos, _ *Tx, power int) (Block, bool) { + powered := power > 0 + if b.Powered == powered { + return b, false + } + b.Powered = powered + return b, true +} +func (b redstoneCancellationConsumer) EncodeBlock() (string, map[string]any) { + return "test:redstone_consumer", map[string]any{"powered": b.Powered} +} +func (b redstoneCancellationConsumer) Hash() (uint64, uint64) { + if b.Powered { + return 1 << 43, 1 + } + return 1 << 43, 0 +} +func (redstoneCancellationConsumer) Model() BlockModel { return redstoneCancellationModel{} } + +type redstoneCancellationAction struct{} + +func (redstoneCancellationAction) RedstonePowerAction(cube.Pos, *Tx, int, int) { + if redstoneCancellationActions != nil { + (*redstoneCancellationActions)++ + } +} +func (redstoneCancellationAction) EncodeBlock() (string, map[string]any) { + return "test:redstone_action", nil +} +func (redstoneCancellationAction) Hash() (uint64, uint64) { return 1 << 44, 0 } +func (redstoneCancellationAction) Model() BlockModel { return redstoneCancellationModel{} } + +type redstoneCancellationModel struct{} + +func (redstoneCancellationModel) BBox(cube.Pos, BlockSource) []cube.BBox { return nil } +func (redstoneCancellationModel) FaceSolid(cube.Pos, cube.Face, BlockSource) bool { + return false +} + +func redstoneSignalLossTestRegistry() BlockRegistry { + registry := NewBlockRegistry() + registry.RegisterBlockState(BlockState{Name: "test:redstone_loss_source", Properties: map[string]any{"power": int32(15)}}) + registry.RegisterBlock(redstoneLossSource{Power: 15}) + registry.RegisterBlockState(BlockState{Name: "test:redstone_loss_relayer", Properties: map[string]any{}}) + registry.RegisterBlock(redstoneLossRelayer{}) + for power := int32(0); power <= 15; power++ { + registry.RegisterBlockState(BlockState{Name: "test:redstone_loss_consumer", Properties: map[string]any{"power": power}}) + registry.RegisterBlock(redstoneLossConsumer{Power: int(power)}) + } + return registry +} + +type redstoneLossSource struct { + Power int +} + +func (b redstoneLossSource) RedstonePower(cube.Pos, *Tx, cube.Face) int { + return b.Power +} +func (b redstoneLossSource) EncodeBlock() (string, map[string]any) { + return "test:redstone_loss_source", map[string]any{"power": int32(b.Power)} +} +func (b redstoneLossSource) Hash() (uint64, uint64) { + return 1 << 45, uint64(b.Power) +} +func (redstoneLossSource) Model() BlockModel { return redstoneCancellationModel{} } + +type redstoneLossRelayer struct{} + +func (redstoneLossRelayer) RedstoneSignalLoss(cube.Pos, *Tx) int { + return 1 +} +func (redstoneLossRelayer) EncodeBlock() (string, map[string]any) { + return "test:redstone_loss_relayer", nil +} +func (redstoneLossRelayer) Hash() (uint64, uint64) { return 1 << 46, 0 } +func (redstoneLossRelayer) Model() BlockModel { return redstoneCancellationModel{} } + +type redstoneLossConsumer struct { + Power int +} + +func (b redstoneLossConsumer) RedstonePowerUpdate(_ cube.Pos, _ *Tx, power int) (Block, bool) { + if b.Power == power { + return b, false + } + b.Power = power + return b, true +} +func (b redstoneLossConsumer) EncodeBlock() (string, map[string]any) { + return "test:redstone_loss_consumer", map[string]any{"power": int32(b.Power)} +} +func (b redstoneLossConsumer) Hash() (uint64, uint64) { + return 1 << 47, uint64(b.Power) +} +func (redstoneLossConsumer) Model() BlockModel { return redstoneCancellationModel{} } + +func redstoneVerticalRelayerTestRegistry() BlockRegistry { + registry := NewBlockRegistry() + registry.RegisterBlockState(BlockState{Name: "test:redstone_vertical_source", Properties: map[string]any{}}) + registry.RegisterBlock(redstoneVerticalSource{}) + for power := int32(0); power <= 15; power++ { + registry.RegisterBlockState(BlockState{Name: "test:redstone_vertical_relayer", Properties: map[string]any{"power": power}}) + registry.RegisterBlock(redstoneVerticalRelayer{Power: int(power)}) + } + registry.RegisterBlockState(BlockState{Name: "test:redstone_ladder_glowstone", Properties: map[string]any{}}) + registry.RegisterBlock(redstoneLadderGlowstone{}) + registry.RegisterBlockState(BlockState{Name: "test:redstone_ladder_glass", Properties: map[string]any{}}) + registry.RegisterBlock(redstoneLadderGlass{}) + return registry +} + +func redstoneTorchAttachmentTestRegistry() BlockRegistry { + registry := redstoneVerticalRelayerTestRegistry() + for _, lit := range []bool{false, true} { + registry.RegisterBlockState(BlockState{Name: "test:redstone_attachment_torch", Properties: map[string]any{"lit": lit}}) + registry.RegisterBlock(redstoneAttachmentTorch{Facing: cube.FaceWest, Lit: lit}) + } + registry.RegisterBlockState(BlockState{Name: "test:redstone_weak_block_source", Properties: map[string]any{}}) + registry.RegisterBlock(redstoneWeakBlockSource{}) + registry.RegisterBlockState(BlockState{Name: "test:solid_block", Properties: map[string]any{}}) + registry.RegisterBlock(redstoneSolidBlock{}) + return registry +} + +func redstonePoweredConductorTestRegistry() BlockRegistry { + registry := redstoneCancellationTestRegistry() + registry.RegisterBlockState(BlockState{Name: "test:redstone_strong_source", Properties: map[string]any{}}) + registry.RegisterBlock(redstoneStrongSource{}) + registry.RegisterBlockState(BlockState{Name: "test:solid_block", Properties: map[string]any{}}) + registry.RegisterBlock(redstoneSolidBlock{}) + return registry +} + +func redstoneWeakConductorTestRegistry() BlockRegistry { + registry := redstoneVerticalRelayerTestRegistry() + for _, powered := range []bool{false, true} { + registry.RegisterBlockState(BlockState{Name: "test:redstone_consumer", Properties: map[string]any{"powered": powered}}) + registry.RegisterBlock(redstoneCancellationConsumer{Powered: powered}) + } + registry.RegisterBlockState(BlockState{Name: "test:redstone_weak_block_source", Properties: map[string]any{}}) + registry.RegisterBlock(redstoneWeakBlockSource{}) + registry.RegisterBlockState(BlockState{Name: "test:solid_block", Properties: map[string]any{}}) + registry.RegisterBlock(redstoneSolidBlock{}) + return registry +} + +type redstoneVerticalSource struct{} + +func (redstoneVerticalSource) RedstonePower(cube.Pos, *Tx, cube.Face) int { return 15 } +func (redstoneVerticalSource) EncodeBlock() (string, map[string]any) { + return "test:redstone_vertical_source", nil +} +func (redstoneVerticalSource) Hash() (uint64, uint64) { return 1 << 52, 0 } +func (redstoneVerticalSource) Model() BlockModel { return redstoneCancellationModel{} } + +type redstoneStrongSource struct{} + +func (redstoneStrongSource) RedstonePower(cube.Pos, *Tx, cube.Face) int { return 15 } +func (redstoneStrongSource) RedstoneStrongPower(cube.Pos, *Tx, cube.Face) int { + return 15 +} +func (redstoneStrongSource) EncodeBlock() (string, map[string]any) { + return "test:redstone_strong_source", nil +} +func (redstoneStrongSource) Hash() (uint64, uint64) { return 1 << 54, 0 } +func (redstoneStrongSource) Model() BlockModel { return redstoneCancellationModel{} } + +type redstoneWeakBlockSource struct{} + +func (redstoneWeakBlockSource) RedstonePower(cube.Pos, *Tx, cube.Face) int { return 15 } +func (redstoneWeakBlockSource) RedstoneWeaklyPowersBlocks() bool { return true } +func (redstoneWeakBlockSource) EncodeBlock() (string, map[string]any) { + return "test:redstone_weak_block_source", nil +} +func (redstoneWeakBlockSource) Hash() (uint64, uint64) { return 1 << 55, 0 } +func (redstoneWeakBlockSource) Model() BlockModel { return redstoneCancellationModel{} } + +type redstoneVerticalRelayer struct { + Power int +} + +func (b redstoneVerticalRelayer) RedstonePower(cube.Pos, *Tx, cube.Face) int { + return b.Power +} +func (redstoneVerticalRelayer) RedstoneSignalLoss(cube.Pos, *Tx) int { + return 1 +} +func (b redstoneVerticalRelayer) RedstonePowerUpdate(_ cube.Pos, _ *Tx, power int) (Block, bool) { + if b.Power == power { + return b, false + } + b.Power = power + return b, true +} +func (b redstoneVerticalRelayer) RedstoneRelayerNeighbours(pos cube.Pos, tx *Tx) []cube.Pos { + neighbours := make([]cube.Pos, 0, 2) + for _, side := range []cube.Pos{pos.Add(cube.Pos{-1, 1, 0}), pos.Add(cube.Pos{1, -1, 0})} { + if side.OutOfBounds(tx.Range()) { + continue + } + if side[1] < pos[1] && !redstoneTestCanTransmitDown(tx, pos) { + continue + } + neighbours = append(neighbours, side) + } + return neighbours +} +func (b redstoneVerticalRelayer) EncodeBlock() (string, map[string]any) { + return "test:redstone_vertical_relayer", map[string]any{"power": int32(b.Power)} +} +func (b redstoneVerticalRelayer) Hash() (uint64, uint64) { + return 1 << 49, uint64(b.Power) +} +func (redstoneVerticalRelayer) Model() BlockModel { return redstoneCancellationModel{} } + +type redstoneAttachmentTorch struct { + Facing cube.Face + Lit bool +} + +func (t redstoneAttachmentTorch) RedstonePower(cube.Pos, *Tx, cube.Face) int { + if t.Lit { + return 15 + } + return 0 +} +func (t redstoneAttachmentTorch) RedstonePowerAction(pos cube.Pos, tx *Tx, _, _ int) { + if t.Lit == !t.attachmentPowered(pos, tx) { + return + } + t.Lit = !t.Lit + tx.SetBlock(pos, t, nil) +} +func (t redstoneAttachmentTorch) attachmentPowered(pos cube.Pos, tx *Tx) bool { + attached := pos.Side(t.Facing) + attachedBlock := tx.Block(attached) + if !redstoneStrongPowerConductor(attached, attachedBlock, tx, t.Facing.Opposite()) { + return false + } + return tx.RedstoneConductivePower(attached) > 0 +} +func (t redstoneAttachmentTorch) EncodeBlock() (string, map[string]any) { + return "test:redstone_attachment_torch", map[string]any{"lit": t.Lit} +} +func (t redstoneAttachmentTorch) Hash() (uint64, uint64) { + if t.Lit { + return 1 << 53, 1 + } + return 1 << 53, 0 +} +func (redstoneAttachmentTorch) Model() BlockModel { return redstoneCancellationModel{} } + +type redstoneLadderGlowstone struct{} + +func (redstoneLadderGlowstone) RedstoneNonConductive() {} +func (redstoneLadderGlowstone) EncodeBlock() (string, map[string]any) { + return "test:redstone_ladder_glowstone", nil +} +func (redstoneLadderGlowstone) Hash() (uint64, uint64) { return 1 << 50, 0 } +func (redstoneLadderGlowstone) Model() BlockModel { return redstoneSolidModel{} } + +type redstoneLadderGlass struct{} + +func (redstoneLadderGlass) LightDiffusionLevel() uint8 { return 0 } +func (redstoneLadderGlass) EncodeBlock() (string, map[string]any) { + return "test:redstone_ladder_glass", nil +} +func (redstoneLadderGlass) Hash() (uint64, uint64) { return 1 << 51, 0 } +func (redstoneLadderGlass) Model() BlockModel { return redstoneSolidModel{} } + +func redstoneTestCanTransmitDown(tx *Tx, pos cube.Pos) bool { + supportPos := pos.Side(cube.FaceDown) + support, ok := tx.World().blockLoaded(supportPos) + if !ok || !support.Model().FaceSolid(supportPos, cube.FaceUp, tx) { + return false + } + if _, ok := support.(RedstoneNonConductive); ok { + return false + } + return true +} + +type redstoneSolidBlock struct{} + +func (redstoneSolidBlock) EncodeBlock() (string, map[string]any) { return "test:solid_block", nil } +func (redstoneSolidBlock) Hash() (uint64, uint64) { return 1 << 48, 0 } +func (redstoneSolidBlock) Model() BlockModel { return redstoneSolidModel{} } + +type redstoneNonConductiveSolidBlock struct { + redstoneSolidBlock +} + +func (redstoneNonConductiveSolidBlock) RedstoneNonConductive() {} +func (redstoneNonConductiveSolidBlock) EncodeBlock() (string, map[string]any) { + return "test:non_conductive_solid_block", nil +} +func (redstoneNonConductiveSolidBlock) Hash() (uint64, uint64) { return 1 << 56, 0 } + +type redstoneSolidModel struct{} + +func (redstoneSolidModel) BBox(cube.Pos, BlockSource) []cube.BBox { return nil } +func (redstoneSolidModel) FaceSolid(cube.Pos, cube.Face, BlockSource) bool { + return true +} diff --git a/server/world/settings.go b/server/world/settings.go index 085a5c280d..10ccfa4190 100644 --- a/server/world/settings.go +++ b/server/world/settings.go @@ -1,6 +1,7 @@ package world import ( + "math/rand/v2" "sync" "sync/atomic" @@ -54,6 +55,8 @@ func defaultSettings() *Settings { Difficulty: DifficultyNormal, TimeCycle: true, WeatherCycle: true, + RainTime: int64(rand.IntN(8400)+600) * 20, + ThunderTime: int64(rand.IntN(8400)+600) * 20, TickRange: 6, } } diff --git a/server/world/sound/block.go b/server/world/sound/block.go index fc62f59ab1..02cfad5bbc 100644 --- a/server/world/sound/block.go +++ b/server/world/sound/block.go @@ -60,6 +60,12 @@ type BarrelClose struct{ sound } // Deny is a sound played when a block is placed or broken above a 'Deny' block from Education edition. type Deny struct{ sound } +// ShulkerBoxOpen is a sound played when a shulker box is opened. +type ShulkerBoxOpen struct{ sound } + +// ShulkerBoxClose is a sound played when a shulker box is closed. +type ShulkerBoxClose struct{ sound } + // DoorOpen is a sound played when a door is opened. type DoorOpen struct { // Block is the block which is being opened, for which a sound should be played. The sound played depends on the @@ -216,6 +222,12 @@ type DecoratedPotInserted struct { // DecoratedPotInsertFailed is a sound played when an item fails to be inserted into a decorated pot. type DecoratedPotInsertFailed struct{ sound } +// EnderEyePlaced is a sound played when an eye of ender is placed into an end portal frame. +type EnderEyePlaced struct{ sound } + +// EndPortalCreated is a sound played when a complete end portal frame ring is activated. +type EndPortalCreated struct{ sound } + // sound implements the world.Sound interface. type sound struct{} diff --git a/server/world/sound/custom.go b/server/world/sound/custom.go new file mode 100644 index 0000000000..27a1f6c82f --- /dev/null +++ b/server/world/sound/custom.go @@ -0,0 +1,14 @@ +package sound + +// Custom is a sound identified by name. It may be used to play sounds defined +// by a resource pack. +type Custom struct { + // Name is the identifier of the sound. + Name string + // Volume is the volume of the sound. + Volume float64 + // Pitch is the pitch of the sound. + Pitch float64 + + sound +} diff --git a/server/world/task.go b/server/world/task.go new file mode 100644 index 0000000000..e954ccff8c --- /dev/null +++ b/server/world/task.go @@ -0,0 +1,419 @@ +package world + +import ( + "context" + "errors" + "fmt" + "runtime/debug" + "sync" + "sync/atomic" + "time" +) + +var ( + // ErrWorldClosed means the task's world closed before the task could run. + ErrWorldClosed = errors.New("world: world closed") + // ErrEntityClosed means the entity closed before the task could run. + ErrEntityClosed = errors.New("world: entity closed") + // ErrEntityNotInWorld means an entity was not in the transaction's world. + ErrEntityNotInWorld = errors.New("world: entity not in this world") + // ErrTaskCancelled means the task was cancelled before it started. + ErrTaskCancelled = errors.New("world: scheduled task cancelled") + // ErrTaskPanicked means the task's callback panicked; see PanicError. + ErrTaskPanicked = errors.New("world: scheduled task panicked") + // ErrEntityType means the entity no longer had the type expected by a + // typed EntityRef when the task ran. + ErrEntityType = errors.New("world: unexpected entity type") +) + +// PanicError is the Task error for a fire-and-forget callback that panicked. It +// matches errors.Is(err, ErrTaskPanicked) and keeps the original panic value +// and stack. Synchronous Call functions re-panic with Value automatically. +type PanicError struct { + // Value is the recovered panic value. + Value any + // Stack is the stack of the panicking goroutine. + Stack []byte +} + +// Error implements the error interface. +func (e *PanicError) Error() string { + return fmt.Sprintf("world: scheduled task panicked: %v", e.Value) +} + +// Unwrap returns ErrTaskPanicked so errors.Is works. +func (e *PanicError) Unwrap() error { return ErrTaskPanicked } + +// rethrowPanic re-panics with the original panic value if err wraps a +// *PanicError. It does nothing for any other error, including nil. +func rethrowPanic(err error) { + if pe, ok := errors.AsType[*PanicError](err); ok { + panic(pe.Value) + } +} + +// callContext normalises a possibly-nil caller context and reports whether it +// was cancelled before any work was scheduled. +func callContext(ctx context.Context) (context.Context, error) { + if ctx == nil { + ctx = context.Background() + } + return ctx, ctx.Err() +} + +// executeWithRecovery runs f, recovering and logging any panic through w. +func executeWithRecovery(w *World, f func() error) (err error) { + defer func() { + if r := recover(); r != nil { + panicErr := &PanicError{Value: r, Stack: debug.Stack()} + w.conf.Log.Error("scheduled task panicked", "panic", panicErr.Value, "stack", string(panicErr.Stack)) + err = panicErr + } + }() + return f() +} + +// awaitTask waits for a task to complete or for cancellation to stop a pending +// task, returning the result stored by the callback through the result pointer. +// Once a callback starts, awaitTask waits for it to finish so synchronous calls +// preserve their result and panic semantics. +func awaitTask[T any](ctx context.Context, task *Task, result *T) (T, error) { + var zero T + completed := func() (T, error) { + if err := task.Err(); err != nil { + rethrowPanic(err) + return zero, err + } + return *result, nil + } + select { + case <-task.Done(): + return completed() + case <-ctx.Done(): + if task.Cancel() { + return zero, ctx.Err() + } + <-task.Done() + return completed() + } +} + +const ( + taskPending int32 = iota + taskRunning + taskDone + taskCancelled +) + +// Task tracks work scheduled onto a world or entity owner. Tasks are usually +// fire-and-forget: Done, Err and Wait are for code running off the owner, +// such as tests and shutdown paths. A zero-value Task behaves like a cancelled +// task. +type Task struct { + done chan struct{} + state atomic.Int32 + + errMu sync.Mutex + err error + + cancelMu sync.Mutex + onCancel func() +} + +// newTask returns a pending Task with an open done channel. +func newTask() *Task { + return &Task{done: make(chan struct{})} +} + +// NewFinishedTask returns a Task that already completed with err. +func NewFinishedTask(err error) *Task { + t := newTask() + t.failIfPending(err) + return t +} + +// closedDone is the Done channel returned for nil tasks. +var closedDone = func() <-chan struct{} { + c := make(chan struct{}) + close(c) + return c +}() + +// Done returns a channel that closes once the task has run, failed or been +// cancelled. +func (t *Task) Done() <-chan struct{} { + if t == nil || t.done == nil { + return closedDone + } + return t.done +} + +// Err returns the task's error, or nil while the task is still pending or +// after it succeeded. +func (t *Task) Err() error { + if t == nil || t.done == nil { + return ErrTaskCancelled + } + select { + case <-t.done: + t.errMu.Lock() + defer t.errMu.Unlock() + return t.err + default: + return nil + } +} + +// Wait blocks until the task finishes or ctx is cancelled. Never call it from +// a callback running on the same owner: that blocks the owner on itself, the +// deadlock Do exists to avoid. +func (t *Task) Wait(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + if t == nil || t.done == nil { + return ErrTaskCancelled + } + select { + case <-t.done: + return t.Err() + case <-ctx.Done(): + return ctx.Err() + } +} + +// OnDone calls f with the task's error on a fresh goroutine once the task +// completes. Nil and zero-value tasks call f with ErrTaskCancelled. The +// callback always runs on a fresh goroutine, including for those tasks. +func (t *Task) OnDone(f func(err error)) { + if t == nil || t.done == nil { + go f(ErrTaskCancelled) + return + } + go func() { + <-t.done + f(t.Err()) + }() +} + +// Cancel stops a task that has not started yet, reporting whether it did: +// true means the task will never run. +func (t *Task) Cancel() bool { + if t == nil || t.done == nil || !t.state.CompareAndSwap(taskPending, taskCancelled) { + return false + } + t.setErr(ErrTaskCancelled) + close(t.done) + t.runCancel() + return true +} + +// begin moves the task from pending to running, reporting whether it did. +func (t *Task) begin() bool { + return t != nil && t.state.CompareAndSwap(taskPending, taskRunning) +} + +// failIfPending completes a still-pending task with err, reporting whether it +// did. +func (t *Task) failIfPending(err error) bool { + if t == nil || !t.state.CompareAndSwap(taskPending, taskRunning) { + return false + } + t.finish(err) + return true +} + +// finish completes the task, storing err and closing the done channel. +func (t *Task) finish(err error) { + t.setErr(err) + t.state.Store(taskDone) + close(t.done) +} + +func (t *Task) setErr(err error) { + t.errMu.Lock() + t.err = err + t.errMu.Unlock() +} + +func (t *Task) pending() bool { + return t != nil && t.state.Load() == taskPending +} + +// setCancel registers a function to run if the task is cancelled. If the +// task is already cancelled when setCancel is called, f runs immediately. +func (t *Task) setCancel(f func()) { + if t == nil || f == nil { + return + } + t.cancelMu.Lock() + cancelled := t.state.Load() == taskCancelled + if !cancelled { + t.onCancel = f + } + t.cancelMu.Unlock() + if cancelled { + f() + } +} + +// runCancel invokes the registered cancel function, if any. +func (t *Task) runCancel() { + t.cancelMu.Lock() + f := t.onCancel + t.cancelMu.Unlock() + if f != nil { + f() + } +} + +// Do schedules f to run on the world owner and returns immediately; it is +// safe to call from anywhere, including owner callbacks. Tasks usually run in +// submission order, but ordering is not guaranteed between tasks scheduled +// while the queue is saturated. Use one task or Tx.Defer for strictly ordered +// work. On a synchronous World, f runs before Do returns. +func (w *World) Do(f func(tx *Tx)) *Task { + return w.scheduleTask(newTask(), func(tx *Tx) error { + f(tx) + return nil + }) +} + +// DoAfter schedules f to run on the world owner after delay. Cancelling the +// task before delay elapses stops f from being queued at all. +func (w *World) DoAfter(delay time.Duration, f func(tx *Tx)) *Task { + t := newTask() + run := func(tx *Tx) error { + f(tx) + return nil + } + if delay <= 0 { + return w.scheduleTask(t, run) + } + if w == nil || w.queue == nil || w.closed.Load() { + t.failIfPending(ErrWorldClosed) + return t + } + go func() { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-timer.C: + w.scheduleTask(t, run) + case <-t.Done(): + case <-w.closeStarted: + t.failIfPending(ErrWorldClosed) + case <-w.closing: + t.failIfPending(ErrWorldClosed) + case <-w.queueClosing: + t.failIfPending(ErrWorldClosed) + } + }() + return t +} + +// Call runs f on w's owner and waits for its typed result. It is for +// off-owner code such as tests, startup and background goroutines; if you +// already have a *world.Tx, just use it directly. Calling it from the +// owner itself (any scheduled callback or Handler event) deadlocks. If f +// panics, Call re-panics with the original value on the waiting goroutine after +// logging the original stack through the World's Logger. Context cancellation +// stops pending work, but Call waits for a callback that has already started. +func Call[T any](ctx context.Context, w *World, f func(tx *Tx) (T, error)) (T, error) { + var zero T + ctx, err := callContext(ctx) + if err != nil { + return zero, err + } + var result T + task := w.scheduleTask(newTask(), func(tx *Tx) error { + var err error + result, err = f(tx) + return err + }) + return awaitTask(ctx, task, &result) +} + +// CallEntity runs f with the EntityHandle's entity on its current world owner +// and waits for the typed result. Off-owner code only, like Call. If f panics, +// CallEntity re-panics with the original value on the waiting goroutine. +func CallEntity[T any](ctx context.Context, h *EntityHandle, f func(tx *Tx, e Entity) (T, error)) (T, error) { + return CallRef(ctx, NewEntityRef[Entity](h), f) +} + +// scheduleTask enqueues a scheduledTransaction on the world's owner queue, +// handing a full queue off to a helper goroutine rather than blocking. +func (w *World) scheduleTask(task *Task, f func(tx *Tx) error) *Task { + if task == nil { + task = newTask() + } + if w == nil || w.queue == nil || w.closed.Load() { + task.failIfPending(ErrWorldClosed) + return task + } + if !task.pending() { + return task + } + st := scheduledTransaction{task: task, f: f} + w.scheduleMu.Lock() + if w.closed.Load() { + w.scheduleMu.Unlock() + task.failIfPending(ErrWorldClosed) + return task + } + if w.conf.Synchronous { + w.scheduleMu.Unlock() + st.Run(w) + return task + } + select { + case <-w.closing: + task.failIfPending(ErrWorldClosed) + case <-w.queueClosing: + task.failIfPending(ErrWorldClosed) + case w.queue <- st: + default: + w.scheduling.Add(1) + go w.queueScheduled(st) + } + w.scheduleMu.Unlock() + return task +} + +// queueScheduled retries enqueuing st once the queue, full at schedule time, +// has room, failing the task if the world closes first. +func (w *World) queueScheduled(st scheduledTransaction) { + defer w.scheduling.Done() + if w.closed.Load() { + st.task.failIfPending(ErrWorldClosed) + return + } + select { + case <-w.closing: + st.task.failIfPending(ErrWorldClosed) + case <-w.queueClosing: + st.task.failIfPending(ErrWorldClosed) + case <-st.task.Done(): + case w.queue <- st: + } +} + +// scheduledTransaction is a queued task from Do, DoAfter or Tx.Defer: it +// runs the callback with panic recovery, drains deferred work and finishes the +// task. +type scheduledTransaction struct { + task *Task + f func(tx *Tx) error +} + +// Run executes the scheduled callback on the world goroutine. +func (st scheduledTransaction) Run(w *World) { + if !st.task.begin() { + return + } + tx := newTx(w) + err := executeWithRecovery(w, func() error { return st.f(tx) }) + tx.close() + tx.runDeferred() + st.task.finish(err) +} diff --git a/server/world/task_test.go b/server/world/task_test.go new file mode 100644 index 0000000000..44b6b98352 --- /dev/null +++ b/server/world/task_test.go @@ -0,0 +1,394 @@ +package world + +import ( + "context" + "errors" + "io" + "log/slog" + "sync/atomic" + "testing" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/go-gl/mathgl/mgl64" +) + +func TestCallRethrowsPanic(t *testing.T) { + w := Config{Log: slog.New(slog.NewTextHandler(io.Discard, nil))}.New() + t.Cleanup(func() { _ = w.Close() }) + + panicValue := &struct{ message string }{"call panic"} + defer func() { + if recovered := recover(); recovered != panicValue { + t.Fatalf("Call panic = %v, want original value %v", recovered, panicValue) + } + }() + _, _ = Call(context.Background(), w, func(*Tx) (struct{}, error) { + panic(panicValue) + }) +} + +func TestCallRefRethrowsPanic(t *testing.T) { + w := Config{Log: slog.New(slog.NewTextHandler(io.Discard, nil))}.New() + t.Cleanup(func() { _ = w.Close() }) + h := NewEntity(taskTestEntityType{}, taskTestEntityConfig{}) + if err := w.Do(func(tx *Tx) { tx.AddEntity(h) }).Wait(context.Background()); err != nil { + t.Fatalf("add entity: %v", err) + } + + panicValue := &struct{ message string }{"call ref panic"} + defer func() { + if recovered := recover(); recovered != panicValue { + t.Fatalf("CallRef panic = %v, want original value %v", recovered, panicValue) + } + }() + _, _ = CallRef(context.Background(), NewEntityRef[Entity](h), func(*Tx, Entity) (struct{}, error) { + panic(panicValue) + }) +} + +func TestCallRethrowsPanicWhenCancellationLoses(t *testing.T) { + w := Config{Log: slog.New(slog.NewTextHandler(io.Discard, nil))}.New() + t.Cleanup(func() { _ = w.Close() }) + ctx, cancel := context.WithCancel(context.Background()) + started := make(chan struct{}) + release := make(chan struct{}) + panicValue := &struct{ message string }{"call panic after cancellation"} + type outcome struct { + panicValue any + err error + } + result := make(chan outcome, 1) + go func() { + var out outcome + defer func() { + out.panicValue = recover() + result <- out + }() + _, out.err = Call(ctx, w, func(*Tx) (struct{}, error) { + close(started) + <-release + panic(panicValue) + }) + }() + + <-started + cancel() + select { + case out := <-result: + close(release) + t.Fatalf("Call returned before running callback completed: panic = %v, err = %v", out.panicValue, out.err) + case <-time.After(20 * time.Millisecond): + close(release) + } + out := <-result + if out.panicValue != panicValue { + t.Fatalf("Call panic = %v, want original value %v (err = %v)", out.panicValue, panicValue, out.err) + } +} + +func TestDoCapturesPanic(t *testing.T) { + w := Config{Synchronous: true, Log: slog.New(slog.NewTextHandler(io.Discard, nil))}.New() + t.Cleanup(func() { _ = w.Close() }) + + panicValue := &struct{ message string }{"do panic"} + task := w.Do(func(*Tx) { panic(panicValue) }) + var panicErr *PanicError + if err := task.Err(); !errors.As(err, &panicErr) { + t.Fatalf("Do error = %v, want *PanicError", err) + } + if panicErr.Value != panicValue { + t.Fatalf("PanicError.Value = %v, want original value %v", panicErr.Value, panicValue) + } +} + +func TestEntityDoCancelAfterInvalidatedWeakTransactionDoesNotPoisonHandle(t *testing.T) { + w := New() + defer w.Close() + + h := NewEntity(taskTestEntityType{}, taskTestEntityConfig{}) + <-w.exec(func(tx *Tx) { tx.AddEntity(h) }) + + started := make(chan struct{}) + release := make(chan struct{}) + w.exec(func(*Tx) { + close(started) + <-release + }) + <-started + + removeDone := w.exec(func(tx *Tx) { + e, ok := h.Entity(tx) + if !ok { + t.Error("entity missing before remove") + return + } + tx.RemoveEntity(e) + }) + task := h.Do(func(*Tx, Entity) { + t.Error("cancelled task ran") + }) + // The fast path in schedule may queue directly without a weak + // transaction. Either way, the task must still be cancellable while + // pending. + if !task.Cancel() { + t.Fatal("expected pending task to cancel") + } + close(release) + <-removeDone + if err := task.Wait(testContext(t)); !errors.Is(err, ErrTaskCancelled) { + t.Fatalf("expected ErrTaskCancelled, got %v", err) + } + + <-w.exec(func(tx *Tx) { tx.AddEntity(h) }) + task = h.Do(func(*Tx, Entity) {}) + if err := task.Wait(testContext(t)); err != nil { + t.Fatalf("handle poisoned after cancelling invalidated weak transaction: %v", err) + } +} + +func TestDoDoesNotBlockOwnerWhenQueueFull(t *testing.T) { + w := New() + defer w.Close() + + done := make(chan struct{}) + go func() { + <-w.exec(func(tx *Tx) { + for i := 0; i < cap(w.queue)+32; i++ { + w.Do(func(*Tx) {}) + tx.Defer(func(*Tx) {}) + } + }) + close(done) + }() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("Do or Context.Defer blocked the world owner") + } +} + +// TestWeakExecDoesNotBlockOwnerWhenQueueFull ensures an off-owner weak +// transaction blocked on a full queue does not hold scheduleMu, which would + +func TestWeakExecDoesNotBlockOwnerWhenQueueFull(t *testing.T) { + w := New() + + h := NewEntity(taskTestEntityType{}, taskTestEntityConfig{}) + <-w.exec(func(tx *Tx) { tx.AddEntity(h) }) + + entered := make(chan struct{}) + proceed := make(chan struct{}) + release := make(chan struct{}) + + // Occupy the world owner goroutine. + w.exec(func(tx *Tx) { + close(entered) + <-proceed + // Owner-side fire-and-forget must not block on scheduleMu. + w.Do(func(tx *Tx) {}) + close(release) + }) + <-entered + + // Fill the queue to capacity while the owner is busy. + for i := 0; i < cap(w.queue); i++ { + w.queue <- normalTransaction{c: make(chan struct{}), f: func(tx *Tx) {}} + } + + // Entity task whose weak transaction ends up in World.weakExec with the + // queue full. + task := h.Do(func(tx *Tx, e Entity) {}) + time.Sleep(200 * time.Millisecond) + + close(proceed) + select { + case <-release: + case <-time.After(3 * time.Second): + t.Fatal("owner-side World.Do deadlocked on scheduleMu held by weakExec blocked on full queue") + } + if err := task.Wait(testContext(t)); err != nil { + t.Fatalf("entity task failed after queue drained: %v", err) + } + _ = w.Close() +} + +func TestDoQueuedBeforeCloseDoesNotRunAfterHandleClose(t *testing.T) { + w := New() + + var closeHandled atomic.Bool + var ranAfterClose atomic.Bool + w.Handle(closeOrderHandler{closed: &closeHandled}) + + started := make(chan struct{}) + release := make(chan struct{}) + w.exec(func(*Tx) { + close(started) + <-release + }) + <-started + + for i := 0; i < cap(w.queue); i++ { + w.exec(func(*Tx) {}) + } + task := w.Do(func(*Tx) { + if closeHandled.Load() { + ranAfterClose.Store(true) + } + }) + + closed := make(chan struct{}) + go func() { + _ = w.Close() + close(closed) + }() + close(release) + + select { + case <-closed: + case <-time.After(5 * time.Second): + t.Fatal("world close did not complete") + } + if err := task.Wait(testContext(t)); err != nil && !errors.Is(err, ErrWorldClosed) { + t.Fatalf("scheduled task failed with unexpected error: %v", err) + } + if ranAfterClose.Load() { + t.Fatal("scheduled task ran after HandleClose") + } +} + +func TestEntityDoScheduledDuringWorldCloseRunsBeforeQueueShutdown(t *testing.T) { + var task *Task + w := New() + h := NewEntity(closeSchedulingEntityType{}, closeSchedulingEntityConfig{onClose: func(h *EntityHandle) { + task = h.Do(func(*Tx, Entity) {}) + }}) + <-w.exec(func(tx *Tx) { tx.AddEntity(h) }) + + if err := w.Close(); err != nil { + t.Fatalf("close world: %v", err) + } + if task == nil { + t.Fatal("entity close did not schedule cleanup task") + } + if err := task.Wait(testContext(t)); err != nil { + t.Fatalf("close-time entity task failed: %v", err) + } +} + +func TestEntityDoBlockedBeforeWorldCloseFailsPromptly(t *testing.T) { + w := New() + h := NewEntity(closeSchedulingEntityType{}, closeSchedulingEntityConfig{}) + <-w.exec(func(tx *Tx) { tx.AddEntity(h) }) + + h.cond.L.Lock() + h.weakTxActive = true + h.cond.L.Unlock() + task := h.Do(func(*Tx, Entity) { + t.Error("entity task ran after world close") + }) + + if err := w.Close(); err != nil { + t.Fatalf("close world: %v", err) + } + h.cond.L.Lock() + h.weakTxActive = false + h.cond.Broadcast() + h.cond.L.Unlock() + + if err := task.Wait(testContext(t)); !errors.Is(err, ErrWorldClosed) { + t.Fatalf("expected ErrWorldClosed, got %v", err) + } +} + +type taskTestEntityConfig struct{} + +type closeOrderHandler struct { + NopHandler + closed *atomic.Bool +} + +func (h closeOrderHandler) HandleClose(*Tx) { h.closed.Store(true) } + +type closeSchedulingEntityConfig struct { + onClose func(*EntityHandle) +} + +func (c closeSchedulingEntityConfig) Apply(data *EntityData) { data.Data = c.onClose } + +type closeSchedulingEntityType struct{} + +func (closeSchedulingEntityType) Open(_ *Tx, handle *EntityHandle, data *EntityData) Entity { + onClose, _ := data.Data.(func(*EntityHandle)) + return closeSchedulingEntity{h: handle, onClose: onClose} +} + +func (closeSchedulingEntityType) EncodeEntity() string { return "dragonfly:close_scheduling_entity" } + +func (closeSchedulingEntityType) BBox(Entity) cube.BBox { return cube.BBox{} } + +func (closeSchedulingEntityType) DecodeNBT(map[string]any, *EntityData) {} + +func (closeSchedulingEntityType) EncodeNBT(*EntityData) map[string]any { return nil } + +type closeSchedulingEntity struct { + h *EntityHandle + onClose func(*EntityHandle) +} + +func (e closeSchedulingEntity) Close() error { + if e.onClose != nil { + e.onClose(e.h) + } + return nil +} + +func (e closeSchedulingEntity) H() *EntityHandle { return e.h } + +func (closeSchedulingEntity) Position() mgl64.Vec3 { return mgl64.Vec3{} } + +func (closeSchedulingEntity) Rotation() cube.Rotation { return cube.Rotation{} } + +func (taskTestEntityConfig) Apply(*EntityData) {} + +type taskTestEntityType struct{} + +func (taskTestEntityType) Open(tx *Tx, handle *EntityHandle, _ *EntityData) Entity { + return taskTestEntity{h: handle, tx: tx} +} + +func (taskTestEntityType) EncodeEntity() string { return "dragonfly:test_entity" } + +func (taskTestEntityType) BBox(Entity) cube.BBox { return cube.BBox{} } + +func (taskTestEntityType) DecodeNBT(map[string]any, *EntityData) {} + +func (taskTestEntityType) EncodeNBT(*EntityData) map[string]any { return nil } + +type taskTestEntity struct { + h *EntityHandle + tx *Tx +} + +func (e taskTestEntity) Close() error { + if e.tx != nil { + if ent, ok := e.h.Entity(e.tx); ok { + e.tx.RemoveEntity(ent) + } + } + return e.h.Close() +} + +func (e taskTestEntity) H() *EntityHandle { return e.h } + +func (taskTestEntity) Position() mgl64.Vec3 { return mgl64.Vec3{} } + +func (taskTestEntity) Rotation() cube.Rotation { return cube.Rotation{} } + +func testContext(t *testing.T) context.Context { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + t.Cleanup(cancel) + return ctx +} diff --git a/server/world/tick.go b/server/world/tick.go index 1088a11565..4f0ead48ec 100644 --- a/server/world/tick.go +++ b/server/world/tick.go @@ -24,7 +24,7 @@ func (t ticker) tickLoop(w *World) { for { select { case <-tc.C: - <-w.Exec(t.tick) + <-w.exec(t.tick) case <-w.closing: // World is being closed: Stop ticking and get rid of a task. w.running.Done() @@ -33,6 +33,14 @@ func (t ticker) tickLoop(w *World) { } } +// AdvanceTick advances the World by a single tick. It is generally only useful +// for Worlds created with Config.Synchronous set: other Worlds tick +// automatically 20 times per second. Synchronous Worlds tick loaded chunks +// even when no viewers are present. +func (w *World) AdvanceTick() { + <-w.exec(ticker{}.tick) +} + // tick performs a tick on the World and updates the time, weather, blocks and // entities that require updates. func (t ticker) tick(tx *Tx) { @@ -43,10 +51,11 @@ func (t ticker) tick(tx *Tx) { if s := w.set.Spawn; s[1] > tx.Range()[1] && w.Dimension() == Overworld { // Vanilla will set the spawn position's Y value to max to indicate that // the player should spawn at the highest position in the world. - w.set.Spawn[1] = w.highestObstructingBlock(s[0], s[2]) + 1 + w.set.Spawn[1] = tx.highestObstructingBlock(s[0], s[2]) + 1 } - if len(viewers) == 0 && w.set.CurrentTick != 0 { - // Don't continue ticking if no viewers are in the world. + if len(viewers) == 0 && w.set.CurrentTick != 0 && !w.conf.Synchronous { + // Don't continue ticking if no viewers are in the world. Synchronous + // worlds only tick on explicit AdvanceTick calls, so they always tick. w.set.Unlock() return } @@ -92,6 +101,7 @@ func (t ticker) tick(tx *Tx) { w.scheduledUpdates.tick(tx, tick) t.tickBlocksRandomly(tx, loaders, tick) t.performNeighbourUpdates(tx) + w.redstone.tick(tx, tick) } // performNeighbourUpdates performs all block updates that came as a result of a neighbouring block being changed. @@ -105,7 +115,7 @@ func (t ticker) performNeighbourUpdates(tx *Tx) { if ticker, ok := tx.Block(pos).(NeighbourUpdateTicker); ok { ticker.NeighbourUpdateTick(pos, changedNeighbour, tx) } - if liquid, ok := tx.World().additionalLiquid(pos); ok { + if liquid, ok := tx.additionalLiquid(pos); ok { if ticker, ok := liquid.(NeighbourUpdateTicker); ok { ticker.NeighbourUpdateTick(pos, changedNeighbour, tx) } @@ -113,8 +123,7 @@ func (t ticker) performNeighbourUpdates(tx *Tx) { } } -// tickBlocksRandomly executes random block ticks in each sub chunk in the world that has at least one viewer -// registered from the viewers passed. +// tickBlocksRandomly executes random block ticks in loaded chunks within range of loaders. func (t ticker) tickBlocksRandomly(tx *Tx, loaders []*Loader, tick int64) { var ( r = int32(tx.World().tickRange()) @@ -128,12 +137,16 @@ func (t ticker) tickBlocksRandomly(tx *Tx, loaders []*Loader, tick int64) { } loaded := make([]ChunkPos, 0, len(loaders)) - for _, loader := range loaders { - loader.mu.RLock() - pos := loader.pos - loader.mu.RUnlock() - - loaded = append(loaded, pos) + if tx.World().conf.Synchronous { + loaded = slices.Collect(maps.Keys(tx.World().chunks)) + } else { + for _, loader := range loaders { + loader.mu.RLock() + pos := loader.pos + loader.mu.RUnlock() + + loaded = append(loaded, pos) + } } for pos, c := range tx.World().chunks { @@ -238,7 +251,7 @@ func (t ticker) tickEntities(tx *Tx, tick int64) { } } - if len(c.viewers) > 0 { + if tx.World().conf.Synchronous || len(c.viewers) > 0 { if te, ok := e.(TickerEntity); ok { te.Tick(tx, tick) } @@ -304,7 +317,7 @@ func (queue *scheduledTickQueue) tick(tx *Tx, tick int64) { b := tx.Block(t.pos) if ticker, ok := b.(ScheduledTicker); ok && w.conf.Blocks.BlockHash(b) == t.bhash { ticker.ScheduledTick(t.pos, tx, w.r) - } else if liquid, ok := tx.World().additionalLiquid(t.pos); ok && w.conf.Blocks.BlockHash(liquid) == t.bhash { + } else if liquid, ok := tx.additionalLiquid(t.pos); ok && w.conf.Blocks.BlockHash(liquid) == t.bhash { if ticker, ok := liquid.(ScheduledTicker); ok { ticker.ScheduledTick(t.pos, tx, w.r) } @@ -327,10 +340,7 @@ func (queue *scheduledTickQueue) tick(tx *Tx, tick int64) { func (queue *scheduledTickQueue) schedule(br BlockRegistry, pos cube.Pos, b Block, delay time.Duration) { resTick := queue.currentTick + int64(max(delay/(time.Second/20), 1)) index := scheduledTickIndex{pos: pos, hash: br.BlockHash(b)} - if t, ok := queue.furthestTicks[index]; ok && t >= resTick { - // Already have a tick scheduled for this position that will occur after - // the delay passed. Block updates can only be scheduled if they are - // after any currently scheduled updates. + if t, ok := queue.furthestTicks[index]; ok && t >= resTick && t > queue.currentTick { return } queue.furthestTicks[index] = resTick @@ -353,6 +363,9 @@ func (queue *scheduledTickQueue) removeChunk(pos ChunkPos) { queue.ticks = slices.DeleteFunc(queue.ticks, func(tick scheduledTick) bool { return chunkPosFromBlockPos(tick.pos) == pos }) + maps.DeleteFunc(queue.furthestTicks, func(index scheduledTickIndex, _ int64) bool { + return chunkPosFromBlockPos(index.pos) == pos + }) } // add adds a slice of scheduled ticks to the queue. It assumes no duplicate @@ -362,10 +375,9 @@ func (queue *scheduledTickQueue) add(ticks []scheduledTick) { for _, t := range ticks { index := scheduledTickIndex{pos: t.pos, hash: t.bhash} if existing, ok := queue.furthestTicks[index]; ok { - // Make sure we find the furthest tick for each of the ticks added. - // Some ticks may have the same block and position, in which case we - // need to set the furthest tick. queue.furthestTicks[index] = max(existing, t.t) + } else { + queue.furthestTicks[index] = t.t } } } diff --git a/server/world/tx.go b/server/world/tx.go index 61655893a6..abb25fc689 100644 --- a/server/world/tx.go +++ b/server/world/tx.go @@ -3,27 +3,66 @@ package world import ( "iter" "sync" - "sync/atomic" "time" "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/player/chat" - "github.com/df-mc/dragonfly/server/world/redstone" "github.com/go-gl/mathgl/mgl64" ) -// Tx represents a synchronised transaction performed on a World. Most -// operations on a World can only be called through a transaction. Tx is not -// safe for use by multiple goroutines concurrently. +// Tx is the owner transaction handle passed to world callbacks. It is the +// only way to perform world operations and is valid only during its callback. type Tx struct { - w *World - closed bool + w *World + closed bool + deferred []scheduledTransaction +} + +// Context is a cancellable event scope passed to Handler events. It embeds the +// owner transaction, so world operations are available directly on it. +type Context struct { + *Tx + cancel bool +} + +// newTx returns a fresh transaction on World w. +func newTx(w *World) *Tx { + return &Tx{w: w} +} + +// Event returns a fresh Context for dispatching one Handler event on this +// transaction, so cancelling one event cannot affect another. +func (tx *Tx) Event() *Context { + return &Context{Tx: tx} +} + +// Cancelled returns whether the Context has been cancelled by an event handler. +func (ctx *Context) Cancelled() bool { return ctx.cancel } + +// Cancel cancels the Context. It is used by event handlers to signal that the +// default behaviour of the event should not run. +func (ctx *Context) Cancel() { ctx.cancel = true } + +// Defer schedules f to run on the owner after the current callback completes +// and before the parent Task completes. Deferred callbacks run FIFO in +// registration order, unlike Go defer's LIFO order. +func (tx *Tx) Defer(f func(tx *Tx)) *Task { + return tx.DeferErr(func(tx *Tx) error { + f(tx) + return nil + }) +} + +// DeferErr schedules f to run on the owner after the current callback +// completes, recording any returned error on the Task. +func (tx *Tx) DeferErr(f func(tx *Tx) error) *Task { + return tx.deferTask(f) } // Range returns the lower and upper bounds of the World that the Tx is // operating on. func (tx *Tx) Range() cube.Range { - return tx.w.ra + return tx.World().ra } // SetBlock writes a block to the position passed. If a chunk is not yet loaded @@ -41,21 +80,41 @@ func (tx *Tx) Range() cube.Range { // needing to set a lot of blocks to the world. BuildStructure may be used // instead. func (tx *Tx) SetBlock(pos cube.Pos, b Block, opts *SetOpts) { - tx.World().setBlock(pos, b, opts) + tx.setBlock(pos, b, opts) +} + +// SetBlockEntity updates block entity data without notifying viewers, updating +// neighbouring blocks or invalidating redstone. The chunk is marked modified. +// It falls back to SetBlock if b has no block entity data or its state differs. +func (tx *Tx) SetBlockEntity(pos cube.Pos, b Block) { + tx.setBlockEntity(pos, b) } // Block reads a block from the position passed. If a chunk is not yet loaded // at that position, the chunk is loaded, or generated if it could not be found // in the world save, and the block returned. func (tx *Tx) Block(pos cube.Pos) Block { - return tx.World().block(pos) + return tx.block(pos) +} + +// BlockLoaded returns the block at the position passed if the chunk containing it is already loaded. It returns false +// without loading or generating the chunk when the block is unavailable. +func (tx *Tx) BlockLoaded(pos cube.Pos) (Block, bool) { + return tx.World().blockLoaded(pos) +} + +// BlocksWithin returns an iterator over the positions of blocks matching any of the block states passed, within a +// horizontal square radius around pos. Chunks not in memory are read from the world save; missing chunks are +// skipped, not generated. Only the primary block layer is searched and blocks are matched by their state alone. +func (tx *Tx) BlocksWithin(pos cube.Pos, radius int, blocks ...Block) iter.Seq[cube.Pos] { + return tx.World().blocksWithin(pos, radius, blocks...) } // Liquid attempts to return a Liquid block at the position passed. This // Liquid may be in the foreground or in any other layer. If found, the Liquid // is returned. If not, the bool returned is false. func (tx *Tx) Liquid(pos cube.Pos) (Liquid, bool) { - return tx.World().liquid(pos) + return tx.liquid(pos) } // SetLiquid sets a Liquid at a specific position in the World. Unlike @@ -65,7 +124,7 @@ func (tx *Tx) Liquid(pos cube.Pos) (Liquid, bool) { // overwritten. If nil is passed for the Liquid, any Liquid currently present // will be removed. func (tx *Tx) SetLiquid(pos cube.Pos, b Liquid) { - tx.World().setLiquid(pos, b) + tx.setLiquid(pos, b) } // BuildStructure builds a Structure passed at a specific position in the @@ -76,7 +135,7 @@ func (tx *Tx) SetLiquid(pos cube.Pos, b Liquid) { // method operates on a per-chunk basis, setting all blocks within a single // chunk part of the Structure before moving on to the next chunk. func (tx *Tx) BuildStructure(pos cube.Pos, s Structure) { - tx.World().buildStructure(pos, s) + tx.buildStructure(pos, s) } // ScheduleBlockUpdate schedules a block update at the position passed for the @@ -92,50 +151,52 @@ func (tx *Tx) ScheduleBlockUpdate(pos cube.Pos, b Block, delay time.Duration) { // HighestLightBlocker gets the Y value of the highest fully light blocking // block at the x and z values passed in the World. func (tx *Tx) HighestLightBlocker(x, z int) int { - return tx.World().HighestLightBlocker(x, z) + return tx.highestLightBlocker(x, z) } // HighestBlock looks up the highest non-air block in the World at a specific x // and z. The y value of the highest block is returned, or 0 if no blocks were // present in the column. func (tx *Tx) HighestBlock(x, z int) int { - return tx.World().highestBlock(x, z) + return tx.highestBlock(x, z) } // Light returns the light level at the position passed. This is the highest of // the sky- and block light. The light value returned is a value in the range // 0-15, where 0 means there is no light present, whereas 15 means the block is -// fully lit. +// fully lit. Light does not load chunks: 0 is returned for positions in chunks +// that are not currently loaded. func (tx *Tx) Light(pos cube.Pos) uint8 { - return tx.World().light(pos) + return tx.light(pos) } // SkyLight returns the skylight level at the position passed. This light level // is not influenced by blocks that emit light, such as torches. The light // value, similarly to Light, is a value in the range 0-15, where 0 means no -// light is present. +// light is present. Unlike Light, SkyLight loads or generates the chunk at the +// position if it is not currently loaded. func (tx *Tx) SkyLight(pos cube.Pos) uint8 { - return tx.World().skyLight(pos) + return tx.skyLight(pos) } // SetBiome sets the Biome at the position passed. If a chunk is not yet loaded // at that position, the chunk is first loaded or generated if it could not be // found in the world save. func (tx *Tx) SetBiome(pos cube.Pos, b Biome) { - tx.World().setBiome(pos, b) + tx.setBiome(pos, b) } // Biome reads the Biome at the position passed. If a chunk is not yet loaded // at that position, the chunk is loaded, or generated if it could not be found // in the world save, and the Biome returned. func (tx *Tx) Biome(pos cube.Pos) Biome { - return tx.World().biome(pos) + return tx.biome(pos) } // Temperature returns the temperature in the World at a specific position. // Higher altitudes and different biomes influence the temperature returned. func (tx *Tx) Temperature(pos cube.Pos) float64 { - return tx.World().temperature(pos) + return tx.temperature(pos) } // RainingAt checks if it is raining at a specific cube.Pos in the World. True @@ -143,21 +204,21 @@ func (tx *Tx) Temperature(pos cube.Pos) float64 { // for it not to be snow and if the block is above the top-most obstructing // block. func (tx *Tx) RainingAt(pos cube.Pos) bool { - return tx.World().rainingAt(pos) + return tx.rainingAt(pos) } // SnowingAt checks if it is snowing at a specific cube.Pos in the World. True // is returned if the temperature in the Biome at that position is sufficiently // low, if it is raining and if it's above the top-most obstructing block. func (tx *Tx) SnowingAt(pos cube.Pos) bool { - return tx.World().snowingAt(pos) + return tx.snowingAt(pos) } // ThunderingAt checks if it is thundering at a specific cube.Pos in the World. // True is returned if RainingAt returns true and if it is thundering in the // world. func (tx *Tx) ThunderingAt(pos cube.Pos) bool { - return tx.World().thunderingAt(pos) + return tx.thunderingAt(pos) } // Raining checks if it is raining anywhere in the World. @@ -199,6 +260,13 @@ func (tx *Tx) AddEntity(e *EntityHandle) Entity { return tx.World().addEntity(tx, e) } +// AddEntityAt adds an EntityHandle to a World at the position passed. The Entity will be visible to all viewers of +// the World that have the chunk at the position passed. AddEntityAt panics if the EntityHandle is already in a world. +// AddEntityAt returns the Entity created by the EntityHandle. +func (tx *Tx) AddEntityAt(e *EntityHandle, pos mgl64.Vec3) Entity { + return tx.World().addEntityAt(tx, e, pos) +} + // RemoveEntity removes an Entity from the World that is currently present in // it. Any viewers of the Entity will no longer be able to see it. // RemoveEntity returns the EntityHandle of the Entity. After removing an Entity @@ -278,46 +346,19 @@ func (tx *Tx) BroadcastSleepingReminder(sleeper Sleeper) { } } -// RedstonePower returns the redstone power emitted by the block at pos toward a neighbouring receiver. -// The face argument is relative to the receiving block. -func (tx *Tx) RedstonePower(pos cube.Pos, face cube.Face, accountForDust bool) (power int) { - b := tx.Block(pos) - if c, ok := b.(Conductor); ok { - return c.WeakPower(pos, face, tx, accountForDust) - } - // The wiki states that in the future some blocks may be transparent but still relay redstone. - // If a block implements RedstonePowerRelayer, it should always be prioritised over lightDiffuser. - if r, ok := b.(RedstonePowerRelayer); ok { - if !r.RelaysRedstonePowerThrough() { - return 0 - } - } else if d, ok := b.(lightDiffuser); ok && d.LightDiffusionLevel() != 15 { - return 0 - } - for _, f := range cube.Faces() { - if !b.Model().FaceSolid(pos, f, tx) { - return 0 - } - } - for _, f := range cube.Faces() { - c, ok := tx.Block(pos.Side(f)).(Conductor) - if !ok { - continue - } - sourcePos := pos.Side(f) - power = max(power, c.StrongPower(sourcePos, f, tx, accountForDust)) - if !accountForDust { - continue - } - if weakBlockPowerer, ok := c.(WeakBlockPowerer); ok && weakBlockPowerer.WeaklyPowersBlocks() { - power = max(power, c.WeakPower(sourcePos, f, tx, accountForDust)) - } +func (tx *Tx) deferTask(f func(tx *Tx) error) *Task { + if tx.closed { + panic("world.Tx: use of transaction after transaction finishes is not permitted") } - return power + task := newTask() + tx.deferred = append(tx.deferred, scheduledTransaction{task: task, f: f}) + return task } -// World returns the World of the Tx. It panics if the transaction was already -// marked complete. +// World returns the Tx's World. It panics once the callback has +// completed. Treat the result as the off-owner handle: blocking calls like +// Save and Close deadlock from inside the callback, so do world operations +// through the Tx instead. func (tx *Tx) World() *World { if tx.closed { panic("world.Tx: use of transaction after transaction finishes is not permitted") @@ -333,18 +374,23 @@ func (tx *Tx) CurrentTick() int64 { return w.set.CurrentTick } -// Redstone returns the transient redstone runtime state owned by the transaction's world. -func (tx *Tx) Redstone() *redstone.State { - return &tx.World().redstone -} - // close finishes the Tx, causing any following call on the Tx to panic. func (tx *Tx) close() { tx.closed = true } +func (tx *Tx) runDeferred() { + for len(tx.deferred) > 0 { + deferred := tx.deferred + tx.deferred = nil + for _, st := range deferred { + st.Run(tx.w) + } + } +} + // normalTransaction is added to the transaction queue for transactions created -// using World.Exec(). +// using World.exec(). type normalTransaction struct { c chan struct{} f func(tx *Tx) @@ -353,30 +399,32 @@ type normalTransaction struct { // Run creates a *Tx, calls ntx.f, closes the transaction and finally closes // ntx.c. func (ntx normalTransaction) Run(w *World) { - tx := &Tx{w: w} + tx := newTx(w) ntx.f(tx) tx.close() + tx.runDeferred() close(ntx.c) } -// weakTransaction is a transaction that may be cancelled by setting its invalid -// bool to false before the transaction is run. +// weakTransaction is a transaction that may be cancelled by its validity +// predicate before the transaction is run. type weakTransaction struct { - c chan bool - f func(tx *Tx) - invalid *atomic.Bool - cond *sync.Cond + c chan bool + f func(tx *Tx) + valid func() bool + cond *sync.Cond } -// Run runs the transaction, first checking if its invalid bool is false and -// creating a *Tx if so. Afterwards, a bool indicating if the transaction was -// run is added to wtx.c. Finally, wtx.cond.Broadcast() is called. +// Run runs the transaction, first checking if it is still valid and creating a +// *Tx if so. Afterwards, a bool indicating if the transaction was run is added +// to wtx.c. Finally, wtx.cond.Broadcast() is called. func (wtx weakTransaction) Run(w *World) { - valid := !wtx.invalid.Load() + valid := wtx.valid == nil || wtx.valid() if valid { - tx := &Tx{w: w} + tx := newTx(w) wtx.f(tx) tx.close() + tx.runDeferred() } // We have to acquire a lock on wtx.cond.L here to make sure cond.Wait() // has been called before we call cond.Broadcast(). If not, we might @@ -387,3 +435,12 @@ func (wtx weakTransaction) Run(w *World) { wtx.c <- valid wtx.cond.Broadcast() } + +// fail delivers false to a weak transaction that will never run, using the +// same condition handshake as Run so a waiter in cond.Wait is woken. +func (wtx weakTransaction) fail() { + wtx.cond.L.Lock() + defer wtx.cond.L.Unlock() + wtx.c <- false + wtx.cond.Broadcast() +} diff --git a/server/world/tx_redstone.go b/server/world/tx_redstone.go new file mode 100644 index 0000000000..0bc164d994 --- /dev/null +++ b/server/world/tx_redstone.go @@ -0,0 +1,97 @@ +package world + +import "github.com/df-mc/dragonfly/server/block/cube" + +// Redstone returns a transaction-scoped handle for redstone engine operations. +func (tx *Tx) Redstone() RedstoneTransaction { + return RedstoneTransaction{tx: tx} +} + +// RedstoneTransaction provides access to redstone engine operations within a transaction. +type RedstoneTransaction struct { + tx *Tx +} + +// ScheduleUpdate marks pos and its neighbours for re-evaluation during the next redstone phase. +func (r RedstoneTransaction) ScheduleUpdate(pos cube.Pos) { + r.tx.World().redstone.invalidateAround(pos, pos, RedstoneUpdateCauseScheduledTick, r.tx.Range()) +} + +// Torch returns a transaction-scoped handle for transient redstone torch state at pos. +func (r RedstoneTransaction) Torch(pos cube.Pos) RedstoneTorchTransaction { + return RedstoneTorchTransaction{tx: r.tx, pos: pos} +} + +// RedstoneTorchTransaction provides access to transient redstone torch state within a transaction. +type RedstoneTorchTransaction struct { + tx *Tx + pos cube.Pos +} + +// BurnoutStatus returns the transient burnout state for the redstone torch. +func (t RedstoneTorchTransaction) BurnoutStatus() (burnedOut, recoverable bool) { + return t.tx.World().redstone.torchBurnoutStatus(t.pos, t.tx.CurrentTick()) +} + +// RecordTurnOff records that the redstone torch was forced off. +func (t RedstoneTorchTransaction) RecordTurnOff() (burnsOut bool) { + return t.tx.World().redstone.recordTorchTurnOff(t.pos, t.tx.CurrentTick()) +} + +// MarkSelfTriggered records that the next turn-off was caused by the torch's own output. +func (t RedstoneTorchTransaction) MarkSelfTriggered() { + t.tx.World().redstone.markTorchSelfTriggered(t.pos) +} + +// ConsumeSelfTriggered reports and clears whether the next turn-off was self-triggered. +func (t RedstoneTorchTransaction) ConsumeSelfTriggered() bool { + return t.tx.World().redstone.consumeTorchSelfTriggered(t.pos) +} + +// ClearBurnout removes transient burnout state for the redstone torch. +func (t RedstoneTorchTransaction) ClearBurnout() { + t.tx.World().redstone.clearTorchBurnout(t.pos) +} + +// RedstonePower returns the strongest redstone power currently applied to the position passed. Custom redstone block +// implementations may use this method to query the transaction's current redstone state. +func (tx *Tx) RedstonePower(pos cube.Pos) int { + return tx.World().redstone.powerTo(pos, tx) +} + +// RedstoneDirectPower returns the strongest direct redstone power currently applied to the position passed, excluding +// power conducted through solid blocks. Custom redstone block implementations may use this method to query the +// transaction's current redstone state. +func (tx *Tx) RedstoneDirectPower(pos cube.Pos) int { + return tx.World().redstone.directPower(pos, tx) +} + +// RedstoneStrongPower returns the strongest strong redstone power currently applied to the position passed. Custom +// redstone block implementations may use this method to query the transaction's current redstone state. +func (tx *Tx) RedstoneStrongPower(pos cube.Pos) int { + return tx.World().redstone.strongPower(pos, tx) +} + +// RedstoneConductivePower returns the power held by pos as a conductive block, excluding direct component activation. +// Custom redstone block implementations may use this method to query the transaction's current redstone state. +func (tx *Tx) RedstoneConductivePower(pos cube.Pos) int { + return tx.World().redstone.conductivePowerTo(pos, tx) +} + +// RedstonePowerFrom returns the strongest redstone power reaching pos from the side passed. Custom redstone block +// implementations may use this method to query the transaction's current redstone state. +func (tx *Tx) RedstonePowerFrom(pos cube.Pos, face cube.Face) int { + return tx.World().redstone.powerFrom(pos, tx, face) +} + +// RedstoneDirectPowerFrom returns the strongest direct redstone power reaching pos from the side passed. Custom +// redstone block implementations may use this method to query the transaction's current redstone state. +func (tx *Tx) RedstoneDirectPowerFrom(pos cube.Pos, face cube.Face) int { + return tx.World().redstone.directPowerFrom(pos, tx, face) +} + +// RedstoneStrongPowerFrom returns the strongest strong redstone power reaching pos from the side passed. Custom +// redstone block implementations may use this method to query the transaction's current redstone state. +func (tx *Tx) RedstoneStrongPowerFrom(pos cube.Pos, face cube.Face) int { + return tx.World().redstone.strongPowerFrom(pos, tx, face) +} diff --git a/server/world/vanilla_items.nbt b/server/world/vanilla_items.nbt index 52af4d9e1d..ec858bab04 100644 Binary files a/server/world/vanilla_items.nbt and b/server/world/vanilla_items.nbt differ diff --git a/server/world/view_layer.go b/server/world/view_layer.go index cb9098f1b6..16cb8a193a 100644 --- a/server/world/view_layer.go +++ b/server/world/view_layer.go @@ -8,9 +8,10 @@ import ( // layer stores the appearance overrides that a ViewLayer applies to an entity. type layer struct { - nameTag *string - scoreTag *string - visibility VisibilityLevel + nameTag *string + alwaysShowNameTag *bool + scoreTag *string + visibility VisibilityLevel } // ViewLayerUpdater handles immediate updates after a ViewLayer changes how an entity is viewed. @@ -75,6 +76,34 @@ func (v *ViewLayer) NameTag(entity Entity) (string, bool) { return *nameTag, true } +// ViewAlwaysShowNameTag overwrites whether the public name tag of the entity is shown at all distances and +// allows this ViewLayer to view it at a different distance. +func (v *ViewLayer) ViewAlwaysShowNameTag(entity Entity, alwaysShow bool) { + v.update(entity, func(l *layer) { + l.alwaysShowNameTag = &alwaysShow + }) +} + +// ViewPublicAlwaysShowNameTag removes the always show name tag override from the entity, causing the public +// always show state to be viewed again. +func (v *ViewLayer) ViewPublicAlwaysShowNameTag(entity Entity) { + v.update(entity, func(l *layer) { + l.alwaysShowNameTag = nil + }) +} + +// AlwaysShowNameTag returns the overwritten always show state of the entity and whether an override was set. +func (v *ViewLayer) AlwaysShowNameTag(entity Entity) (bool, bool) { + v.mu.RLock() + defer v.mu.RUnlock() + + alwaysShow := v.entities[entity.H()].alwaysShowNameTag + if alwaysShow == nil { + return false, false + } + return *alwaysShow, true +} + // ViewScoreTag overwrites the public score tag of the entity and allows this ViewLayer to view a different score tag. // Passing an empty score tag removes the score tag for this ViewLayer. func (v *ViewLayer) ViewScoreTag(entity Entity, scoreTag string) { @@ -167,7 +196,7 @@ func (v *ViewLayer) Close() error { // empty checks if the layer does not override any public entity metadata. func (l layer) empty() bool { - return l.nameTag == nil && l.scoreTag == nil && l.visibility == PublicVisibility() + return l.nameTag == nil && l.alwaysShowNameTag == nil && l.scoreTag == nil && l.visibility == PublicVisibility() } func (v *ViewLayer) refresh(entity Entity) { diff --git a/server/world/viewer.go b/server/world/viewer.go index 316f534506..1893c93251 100644 --- a/server/world/viewer.go +++ b/server/world/viewer.go @@ -24,6 +24,9 @@ type Viewer interface { // ViewEntityMovement views the movement of an Entity. The Entity is moved with a delta position, yaw and // pitch, which, when applied to the respective values of the Entity, will result in the final values. ViewEntityMovement(e Entity, pos mgl64.Vec3, rot cube.Rotation, onGround bool) + // ViewEntityDisplacement views server-authoritative movement of an Entity to an absolute position. Unlike regular + // movement, displacement must also be sent to the controlling session. + ViewEntityDisplacement(e Entity, pos mgl64.Vec3, rot cube.Rotation, onGround bool) // ViewEntityVelocity views the velocity of an Entity. It is called right before a call to // ViewEntityMovement so that the Viewer may interpolate the movement itself. ViewEntityVelocity(e Entity, vel mgl64.Vec3) @@ -88,6 +91,7 @@ func (NopViewer) ViewEntity(Entity) func (NopViewer) HideEntity(Entity) {} func (NopViewer) ViewEntityGameMode(Entity) {} func (NopViewer) ViewEntityMovement(Entity, mgl64.Vec3, cube.Rotation, bool) {} +func (NopViewer) ViewEntityDisplacement(Entity, mgl64.Vec3, cube.Rotation, bool) {} func (NopViewer) ViewEntityVelocity(Entity, mgl64.Vec3) {} func (NopViewer) ViewEntityTeleport(Entity, mgl64.Vec3) {} func (NopViewer) ViewChunk(ChunkPos, Dimension, map[cube.Pos]Block, *chunk.Chunk) {} diff --git a/server/world/weather.go b/server/world/weather.go index a93a7efaaa..d3287dacaf 100644 --- a/server/world/weather.go +++ b/server/world/weather.go @@ -24,45 +24,48 @@ func (w weather) StartWeatherCycle() { // snowingAt checks if it is snowing at a specific cube.Pos in the World. True // is returned if the temperature in the Biome at that position is sufficiently // low, if it is raining and if it's above the top-most obstructing block. -func (w weather) snowingAt(pos cube.Pos) bool { - if w.w == nil || !w.w.Dimension().WeatherCycle() { +func (tx *Tx) snowingAt(pos cube.Pos) bool { + w := tx.World() + if w == nil || !w.Dimension().WeatherCycle() { return false } - if b := w.w.biome(pos); b.Rainfall() == 0 || w.w.temperature(pos) > 0.15 { + if b := tx.biome(pos); b.Rainfall() == 0 || tx.temperature(pos) > 0.15 { return false } - w.w.set.Lock() - raining := w.w.set.Raining - w.w.set.Unlock() - return raining && w.w.highestObstructingBlock(pos[0], pos[2]) < pos[1] + w.set.Lock() + raining := w.set.Raining + w.set.Unlock() + return raining && tx.highestObstructingBlock(pos[0], pos[2]) < pos[1] } // rainingAt checks if it is raining at a specific cube.Pos in the World. True // is returned if it is raining, if the temperature is high enough in the biome // for it not to be snow and if the block is above the top-most obstructing // block. -func (w weather) rainingAt(pos cube.Pos) bool { - if w.w == nil || !w.w.Dimension().WeatherCycle() { +func (tx *Tx) rainingAt(pos cube.Pos) bool { + w := tx.World() + if w == nil || !w.Dimension().WeatherCycle() { return false } - if b := w.w.biome(pos); b.Rainfall() == 0 || w.w.temperature(pos) <= 0.15 { + if b := tx.biome(pos); b.Rainfall() == 0 || tx.temperature(pos) <= 0.15 { return false } - w.w.set.Lock() - a := w.w.set.Raining - w.w.set.Unlock() - return a && w.w.highestObstructingBlock(pos[0], pos[2]) < pos[1] + w.set.Lock() + a := w.set.Raining + w.set.Unlock() + return a && tx.highestObstructingBlock(pos[0], pos[2]) < pos[1] } // thunderingAt checks if it is thundering at a specific cube.Pos in the World. // True is returned if rainingAt returns true and if it is thundering in the // world. -func (w weather) thunderingAt(pos cube.Pos) bool { - raining := w.rainingAt(pos) - w.w.set.Lock() - a := w.w.set.Thundering && raining - w.w.set.Unlock() - return a && w.w.highestObstructingBlock(pos[0], pos[2]) < pos[1] +func (tx *Tx) thunderingAt(pos cube.Pos) bool { + w := tx.World() + raining := tx.rainingAt(pos) + w.set.Lock() + a := w.set.Thundering && raining + w.set.Unlock() + return a && tx.highestObstructingBlock(pos[0], pos[2]) < pos[1] } // raining checks if it is raining anywhere in the World. @@ -207,6 +210,9 @@ func (w weather) tickLightning(tx *Tx) { // near the lightning strike. If there is no rain at the final position // selected, the lightning strike will fail. func (w weather) strikeLightning(tx *Tx, c ChunkPos) { + if w.w.conf.Entities.Config().Lightning == nil { + return + } if pos := w.lightningPosition(tx, c); tx.ThunderingAt(cube.PosFromVec3(pos)) { tx.AddEntity(w.w.conf.Entities.Config().Lightning(EntitySpawnOpts{Position: pos})) } @@ -243,7 +249,7 @@ func (w weather) adjustPositionToEntities(tx *Tx, vec mgl64.Vec3) mgl64.Vec3 { // block at its position is eligible to be struck by lightning. We // first save all entity positions where this is the case. pos := cube.PosFromVec3(e.Position()) - if tx.HighestBlock(pos[0], pos[1]) < pos[2] { + if tx.HighestBlock(pos[0], pos[2]) < pos[1] { list = append(list, e.Position()) } } diff --git a/server/world/world.go b/server/world/world.go index 649186b4af..742130e454 100644 --- a/server/world/world.go +++ b/server/world/world.go @@ -1,6 +1,7 @@ package world import ( + "context" "encoding/binary" "errors" "fmt" @@ -13,10 +14,8 @@ import ( "time" "github.com/df-mc/dragonfly/server/block/cube" - "github.com/df-mc/dragonfly/server/event" "github.com/df-mc/dragonfly/server/internal/sliceutil" "github.com/df-mc/dragonfly/server/world/chunk" - "github.com/df-mc/dragonfly/server/world/redstone" "github.com/df-mc/goleveldb/leveldb" "github.com/go-gl/mathgl/mgl64" "github.com/google/uuid" @@ -35,6 +34,16 @@ type World struct { queueClosing chan struct{} queueing sync.WaitGroup + // scheduleMu serialises task scheduling against the close transitions + // below. scheduling counts in-flight scheduled work that close must drain. + scheduleMu sync.Mutex + scheduling sync.WaitGroup + // closed flips once close starts; new tasks fail with ErrWorldClosed. + // closeAcceptingEntityTasks is true only during the close transaction, when + // entity Close methods may still schedule final work that close drains. + closed atomic.Bool + closeAcceptingEntityTasks atomic.Bool + // advance is a bool that specifies if this World should advance the current // tick, time and weather saved in the Settings struct held by the World. advance bool @@ -46,12 +55,17 @@ type World struct { weather - closing chan struct{} - running sync.WaitGroup + // closeStarted closes as soon as World.Close begins, before the close + // transaction runs; closing closes once the world stops ticking. + closeStarted chan struct{} + closing chan struct{} + running sync.WaitGroup // chunks holds a cache of chunks currently loaded. These chunks are cleared // from this map after some time of not being used. - chunks map[ChunkPos]*Column + chunks map[ChunkPos]*Column + chunkRequests map[ChunkPos]*chunkRequest + chunkWorkers *chunkWorkerPool // entities holds a map of entities currently loaded and the last ChunkPos // that the Entity was in. These are tracked so that a call to RemoveEntity @@ -65,10 +79,9 @@ type World struct { // tick value passed, the block update will be performed and the entry will // be removed from the map. scheduledUpdates *scheduledTickQueue + redstone *redstoneEngine neighbourUpdates []neighbourUpdate - redstone redstone.State - viewerMu sync.Mutex viewers map[*Loader]Viewer } @@ -116,20 +129,65 @@ func (w *World) BlockRegistry() BlockRegistry { return w.conf.Blocks } -// ExecFunc is a function that performs a synchronised transaction on a World. -type ExecFunc func(tx *Tx) +// execFunc is a function that performs a synchronised transaction on a World. +type execFunc func(tx *Tx) -// Exec performs a synchronised transaction f on a World. Exec returns a channel -// that is closed once the transaction is complete. -func (w *World) Exec(f ExecFunc) <-chan struct{} { +// exec runs f on the World, bypassing the closed check that Do/DoAfter/Call +// apply — reserved for the World's own machinery (ticking, saving, chunk +// unload, the close transaction), which must queue work after close begins. +// The returned channel closes when done; waiting on it from the owner deadlocks. +func (w *World) exec(f execFunc) <-chan struct{} { c := make(chan struct{}) - w.queue <- normalTransaction{c: c, f: f} + ntx := normalTransaction{c: c, f: f} + if w.conf.Synchronous { + ntx.Run(w) + return c + } + w.queue <- ntx return c } -func (w *World) weakExec(invalid *atomic.Bool, cond *sync.Cond, f ExecFunc) <-chan bool { +func (w *World) weakExec(valid func() bool, cond *sync.Cond, f execFunc, allowClosed bool) <-chan bool { c := make(chan bool, 1) - w.queue <- weakTransaction{c: c, f: f, invalid: invalid, cond: cond} + if w.conf.Synchronous { + run := valid == nil || valid() + if run { + // As in weakTransaction.Run, f must not run under cond.L: it may + // relock it, e.g. through RemoveEntity. + cond.L.Unlock() + tx := newTx(w) + f(tx) + tx.close() + tx.runDeferred() + cond.L.Lock() + } + c <- run + return c + } + w.scheduleMu.Lock() + if w.closed.Load() && !w.closeAcceptingEntityTasks.Load() && !allowClosed { + w.scheduleMu.Unlock() + c <- false + return c + } + wtx := weakTransaction{c: c, f: f, valid: valid, cond: cond} + select { + case w.queue <- wtx: + w.scheduleMu.Unlock() + default: + w.scheduling.Add(1) + w.scheduleMu.Unlock() + go func() { + defer w.scheduling.Done() + select { + case w.queue <- wtx: + case <-w.closing: + wtx.fail() + case <-w.queueClosing: + wtx.fail() + } + }() + } return c } @@ -156,8 +214,26 @@ func (w *World) EntityRegistry() EntityRegistry { // block reads a block from the position passed. If a chunk is not yet loaded // at that position, the chunk is loaded, or generated if it could not be found // in the world save, and the block returned. -func (w *World) block(pos cube.Pos) Block { - return w.blockInChunk(w.chunk(chunkPosFromBlockPos(pos)), pos) +func (tx *Tx) block(pos cube.Pos) Block { + return tx.World().blockInChunk(tx.chunk(chunkPosFromBlockPos(pos)), pos) +} + +// blockLoaded reads a block from a position only if its chunk is already loaded. +func (w *World) blockLoaded(pos cube.Pos) (Block, bool) { + if pos.OutOfBounds(w.ra) { + return w.conf.Blocks.Air(), false + } + c, ok := w.chunks[chunkPosFromBlockPos(pos)] + if !ok { + return w.conf.Blocks.Air(), false + } + rid := c.Block(uint8(pos[0]), int16(pos[1]), uint8(pos[2]), 0) + if w.conf.Blocks.NBTBlock(rid) { + if b, ok := c.BlockEntities[pos]; ok { + return b, true + } + } + return w.conf.Blocks.BlockByRuntimeIDOrAir(rid), true } // blockInChunk reads a block from a chunk at the position passed. The block @@ -188,45 +264,56 @@ func (w *World) blockInChunk(c *Column, pos cube.Pos) Block { // biome reads the Biome at the position passed. If a chunk is not yet loaded // at that position, the chunk is loaded, or generated if it could not be found // in the world save, and the Biome returned. -func (w *World) biome(pos cube.Pos) Biome { - if pos.OutOfBounds(w.Range()) { +func (tx *Tx) biome(pos cube.Pos) Biome { + if pos.OutOfBounds(tx.Range()) { // Fast way out. return ocean() } - id := int(w.chunk(chunkPosFromBlockPos(pos)).Biome(uint8(pos[0]), int16(pos[1]), uint8(pos[2]))) - b, ok := w.conf.Biomes.BiomeByID(id) + id := int(tx.chunk(chunkPosFromBlockPos(pos)).Biome(uint8(pos[0]), int16(pos[1]), uint8(pos[2]))) + b, ok := tx.World().conf.Biomes.BiomeByID(id) if !ok { - w.conf.Log.Error("biome not found by ID", "ID", id) + tx.World().conf.Log.Error("biome not found by ID", "ID", id) + return unknownBiome{id: id} } return b } // HighestLightBlocker gets the Y value of the highest fully light blocking -// block at the x and z values passed in the World. +// block at the x and z values passed in the World. It must not be called from +// within a transaction; use Tx.HighestLightBlocker instead. func (w *World) HighestLightBlocker(x, z int) int { - return int(w.chunk(ChunkPos{int32(x >> 4), int32(z >> 4)}).HighestLightBlocker(uint8(x), uint8(z))) + y, _ := Call(context.Background(), w, func(tx *Tx) (int, error) { + return tx.highestLightBlocker(x, z), nil + }) + return y +} + +// highestLightBlocker gets the Y value of the highest fully light blocking +// block at the x and z values passed in the World. +func (tx *Tx) highestLightBlocker(x, z int) int { + return int(tx.chunk(ChunkPos{int32(x >> 4), int32(z >> 4)}).HighestLightBlocker(uint8(x), uint8(z))) } // highestBlock looks up the highest non-air block in the World at a specific x // and z The y value of the highest block is returned, or 0 if no blocks were // present in the column. -func (w *World) highestBlock(x, z int) int { - return int(w.chunk(ChunkPos{int32(x >> 4), int32(z >> 4)}).HighestBlock(uint8(x), uint8(z))) +func (tx *Tx) highestBlock(x, z int) int { + return int(tx.chunk(ChunkPos{int32(x >> 4), int32(z >> 4)}).HighestBlock(uint8(x), uint8(z))) } // highestObstructingBlock returns the highest block in the World at a given x // and z that has at least a solid top or bottom face. -func (w *World) highestObstructingBlock(x, z int) int { - yHigh := w.highestBlock(x, z) - src := worldSource{w: w} - for y := yHigh; y >= w.Range()[0]; y-- { +func (tx *Tx) highestObstructingBlock(x, z int) int { + yHigh := tx.highestBlock(x, z) + src := worldSource{tx: tx} + for y := yHigh; y >= tx.Range()[0]; y-- { pos := cube.Pos{x, y, z} - m := w.block(pos).Model() + m := tx.block(pos).Model() if m.FaceSolid(pos, cube.FaceUp, src) || m.FaceSolid(pos, cube.FaceDown, src) { return y } } - return w.Range()[0] + return tx.Range()[0] } // SetOpts holds several parameters that may be set to disable updates in the @@ -241,6 +328,10 @@ type SetOpts struct { // performance is very important, or where it is known no liquid can be // present anyway. DisableLiquidDisplacement bool + // DisableRedstoneUpdates makes SetBlock not invalidate the redstone engine + // around the changed block. This is used by the redstone engine while + // applying its own block-state updates to avoid duplicate same-tick work. + DisableRedstoneUpdates bool } // setBlock writes a block to the position passed. If a chunk is not yet loaded @@ -257,7 +348,8 @@ type SetOpts struct { // setBlock should be avoided in situations where performance is critical when // needing to set a lot of blocks to the world. BuildStructure may be used // instead. -func (w *World) setBlock(pos cube.Pos, b Block, opts *SetOpts) { +func (tx *Tx) setBlock(pos cube.Pos, b Block, opts *SetOpts) { + w := tx.World() if pos.OutOfBounds(w.Range()) { // Fast way out. return @@ -267,13 +359,30 @@ func (w *World) setBlock(pos cube.Pos, b Block, opts *SetOpts) { } x, y, z := uint8(pos[0]), int16(pos[1]), uint8(pos[2]) - c := w.chunk(chunkPosFromBlockPos(pos)) + c := tx.chunk(chunkPosFromBlockPos(pos)) rid := w.conf.Blocks.BlockRuntimeID(b) + redstoneAfterRelevant := isRedstoneRelevant(b) + needOldBlock := !opts.DisableRedstoneUpdates || !redstoneAfterRelevant + needOldRID := needOldBlock || (rid != w.conf.Blocks.AirRuntimeID() && !opts.DisableLiquidDisplacement) + + var oldRID uint32 + if needOldRID { + oldRID = c.Block(x, y, z, 0) + } + var oldBlock Block + if needOldBlock { + oldBlock = w.conf.Blocks.BlockByRuntimeIDOrAir(oldRID) + if w.conf.Blocks.NBTBlock(oldRID) { + if blockEntity, ok := c.BlockEntities[pos]; ok { + oldBlock = blockEntity + } + } + } var before uint32 if rid != w.conf.Blocks.AirRuntimeID() && !opts.DisableLiquidDisplacement { - before = c.Block(x, y, z, 0) + before = oldRID } c.modified = true @@ -317,6 +426,10 @@ func (w *World) setBlock(pos cube.Pos, b Block, opts *SetOpts) { } } + if redstoneAfterRelevant || (needOldBlock && isRedstoneRelevant(oldBlock)) { + w.redstone.forget(pos) + } + for _, viewer := range viewers { viewer.ViewBlockUpdate(pos, b, 0) } @@ -324,17 +437,38 @@ func (w *World) setBlock(pos cube.Pos, b Block, opts *SetOpts) { if !opts.DisableBlockUpdates { w.doBlockUpdatesAround(pos) } + if !opts.DisableRedstoneUpdates { + w.redstone.invalidateAroundBlockChange(pos, oldBlock, b, RedstoneUpdateCauseBlockUpdate, w.Range()) + } +} + +// setBlockEntity updates block entity data without triggering block updates. +func (tx *Tx) setBlockEntity(pos cube.Pos, b Block) { + w := tx.World() + if pos.OutOfBounds(w.Range()) { + // Fast way out. + return + } + c := tx.chunk(chunkPosFromBlockPos(pos)) + + rid := w.conf.Blocks.BlockRuntimeID(b) + if !w.conf.Blocks.NBTBlock(rid) || c.Block(uint8(pos[0]), int16(pos[1]), uint8(pos[2]), 0) != rid { + tx.setBlock(pos, b, nil) + return + } + c.BlockEntities[pos] = b + c.modified = true } // setBiome sets the Biome at the position passed. If a chunk is not yet loaded // at that position, the chunk is first loaded or generated if it could not be // found in the world save. -func (w *World) setBiome(pos cube.Pos, b Biome) { - if pos.OutOfBounds(w.Range()) { +func (tx *Tx) setBiome(pos cube.Pos, b Biome) { + if pos.OutOfBounds(tx.Range()) { // Fast way out. return } - c := w.chunk(chunkPosFromBlockPos(pos)) + c := tx.chunk(chunkPosFromBlockPos(pos)) c.modified = true c.SetBiome(uint8(pos[0]), int16(pos[1]), uint8(pos[2]), uint32(b.EncodeBiome())) } @@ -346,12 +480,13 @@ func (w *World) setBiome(pos cube.Pos, b Biome) { // will do so within much less time than separate setBlock calls would. The // method operates on a per-chunk basis, setting all blocks within a single // chunk part of the Structure before moving on to the next chunk. -func (w *World) buildStructure(pos cube.Pos, s Structure) { +func (tx *Tx) buildStructure(pos cube.Pos, s Structure) { + w := tx.World() dim := s.Dimensions() width, height, length := dim[0], dim[1], dim[2] maxX, maxY, maxZ := pos[0]+width, pos[1]+height, pos[2]+length f := func(x, y, z int) Block { - return w.block(cube.Pos{pos[0] + x, pos[1] + y, pos[2] + z}) + return tx.block(cube.Pos{pos[0] + x, pos[1] + y, pos[2] + z}) } // We approach this on a per-chunk basis, so that we can keep only one chunk @@ -361,7 +496,7 @@ func (w *World) buildStructure(pos cube.Pos, s Structure) { for chunkX := pos[0] >> 4; chunkX <= maxX>>4; chunkX++ { for chunkZ := pos[2] >> 4; chunkZ <= maxZ>>4; chunkZ++ { chunkPos := ChunkPos{int32(chunkX), int32(chunkZ)} - c := w.chunk(chunkPos) + c := tx.chunk(chunkPos) baseX, baseZ := chunkX<<4, chunkZ<<4 for i, sub := range c.Sub() { @@ -428,12 +563,13 @@ func (w *World) buildStructure(pos cube.Pos, s Structure) { // liquid attempts to return a Liquid block at the position passed. This // Liquid may be in the foreground or in any other layer. If found, the Liquid // is returned. If not, the bool returned is false. -func (w *World) liquid(pos cube.Pos) (Liquid, bool) { +func (tx *Tx) liquid(pos cube.Pos) (Liquid, bool) { + w := tx.World() if pos.OutOfBounds(w.Range()) { // Fast way out. return nil, false } - c := w.chunk(chunkPosFromBlockPos(pos)) + c := tx.chunk(chunkPosFromBlockPos(pos)) x, y, z := uint8(pos[0]), int16(pos[1]), uint8(pos[2]) id := c.Block(x, y, z, 0) @@ -462,16 +598,18 @@ func (w *World) liquid(pos cube.Pos) (Liquid, bool) { // there already is a Liquid at that position, in which case it will be // overwritten. If nil is passed for the Liquid, any Liquid currently present // will be removed. -func (w *World) setLiquid(pos cube.Pos, b Liquid) { +func (tx *Tx) setLiquid(pos cube.Pos, b Liquid) { + w := tx.World() if pos.OutOfBounds(w.Range()) { // Fast way out. return } chunkPos := chunkPosFromBlockPos(pos) - c := w.chunk(chunkPos) + c := tx.chunk(chunkPos) if b == nil { w.removeLiquids(c, pos) w.doBlockUpdatesAround(pos) + w.redstone.invalidateAround(pos, pos, RedstoneUpdateCauseBlockUpdate, w.Range()) return } x, y, z := uint8(pos[0]), int16(pos[1]), uint8(pos[2]) @@ -495,6 +633,7 @@ func (w *World) setLiquid(pos cube.Pos, b Liquid) { c.modified = true w.doBlockUpdatesAround(pos) + w.redstone.invalidateAround(pos, pos, RedstoneUpdateCauseBlockUpdate, w.Range()) } // removeLiquids removes any liquid blocks that may be present at a specific @@ -541,12 +680,13 @@ func (w *World) removeLiquidOnLayer(c *chunk.Chunk, x uint8, y int16, z, layer u // additionalLiquid checks if the block at a position has additional liquid on // another layer and returns the liquid if so. -func (w *World) additionalLiquid(pos cube.Pos) (Liquid, bool) { +func (tx *Tx) additionalLiquid(pos cube.Pos) (Liquid, bool) { + w := tx.World() if pos.OutOfBounds(w.Range()) { // Fast way out. return nil, false } - c := w.chunk(chunkPosFromBlockPos(pos)) + c := tx.chunk(chunkPosFromBlockPos(pos)) id := c.Block(uint8(pos[0]), int16(pos[1]), uint8(pos[2]), 1) b, ok := w.conf.Blocks.BlockByRuntimeID(id) @@ -562,7 +702,8 @@ func (w *World) additionalLiquid(pos cube.Pos) (Liquid, bool) { // the sky and block light. The light value returned is a value in the range // 0-15, where 0 means there is no light present, whereas 15 means the block is // fully lit. -func (w *World) light(pos cube.Pos) uint8 { +func (tx *Tx) light(pos cube.Pos) uint8 { + w := tx.World() if pos[1] < w.ra[0] { // Fast way out. return 0 @@ -571,14 +712,19 @@ func (w *World) light(pos cube.Pos) uint8 { // Above the rest of the world, so full skylight. return 15 } - return w.chunk(chunkPosFromBlockPos(pos)).Light(uint8(pos[0]), int16(pos[1]), uint8(pos[2])) + c, ok := w.loadedChunk(chunkPosFromBlockPos(pos)) + if !ok { + return 0 + } + return c.Light(uint8(pos[0]), int16(pos[1]), uint8(pos[2])) } // skyLight returns the skylight level at the position passed. This light level // is not influenced by blocks that emit light, such as torches. The light // value, similarly to light, is a value in the range 0-15, where 0 means no // light is present. -func (w *World) skyLight(pos cube.Pos) uint8 { +func (tx *Tx) skyLight(pos cube.Pos) uint8 { + w := tx.World() if pos[1] < w.ra[0] { // Fast way out. return 0 @@ -587,7 +733,7 @@ func (w *World) skyLight(pos cube.Pos) uint8 { // Above the rest of the world, so full skylight. return 15 } - return w.chunk(chunkPosFromBlockPos(pos)).SkyLight(uint8(pos[0]), int16(pos[1]), uint8(pos[2])) + return tx.chunk(chunkPosFromBlockPos(pos)).SkyLight(uint8(pos[0]), int16(pos[1]), uint8(pos[2])) } // Time returns the current time of the world. The time is incremented every @@ -657,13 +803,13 @@ func (w *World) enableTimeCycle(v bool) { // temperature returns the temperature in the World at a specific position. // Higher altitudes and different biomes influence the temperature returned. -func (w *World) temperature(pos cube.Pos) float64 { +func (tx *Tx) temperature(pos cube.Pos) float64 { const ( tempDrop = 1.0 / 600 seaLevel = 64 ) diff := max(pos[1]-seaLevel, 0) - return w.biome(pos).Temperature() - float64(diff)*tempDrop + return tx.biome(pos).Temperature() - float64(diff)*tempDrop } // addParticle spawns a Particle at a given position in the World. Viewers that @@ -678,7 +824,7 @@ func (w *World) addParticle(pos mgl64.Vec3, p Particle) { // playSound plays a sound at a specific position in the World. Viewers of that // position will be able to hear the sound if they are close enough. func (w *World) playSound(tx *Tx, pos mgl64.Vec3, s Sound) { - ctx := event.C(tx) + ctx := tx.Event() if w.Handler().HandleSound(ctx, s, pos); ctx.Cancelled() { return } @@ -694,11 +840,16 @@ func (w *World) playSound(tx *Tx, pos mgl64.Vec3, s Sound) { // loaded. addEntity panics if the EntityHandle is already in a world. // addEntity returns the Entity created by the EntityHandle. func (w *World) addEntity(tx *Tx, handle *EntityHandle) Entity { - handle.setAndUnlockWorld(w) - pos := chunkPosFromVec3(handle.data.Pos) - w.entities[handle] = pos + return w.addEntityAt(tx, handle, handle.data.Pos) +} - c := w.chunk(pos) +// addEntityAt adds an EntityHandle to a World at the position passed. +func (w *World) addEntityAt(tx *Tx, handle *EntityHandle, pos mgl64.Vec3) Entity { + handle.setAndUnlockWorldAt(w, pos) + chunkPos := chunkPosFromVec3(handle.data.Pos) + w.entities[handle] = chunkPos + + c := tx.chunk(chunkPos) c.Entities, c.modified = append(c.Entities, handle), true e := handle.mustEntity(tx) @@ -707,6 +858,7 @@ func (w *World) addEntity(tx *Tx, handle *EntityHandle) Entity { showEntity(e, v) } w.Handler().HandleEntitySpawn(tx, e) + handle.markWorldReady(w) return e } @@ -723,7 +875,7 @@ func (w *World) removeEntity(e Entity, tx *Tx) *EntityHandle { } w.Handler().HandleEntityDespawn(tx, e) - c := w.chunk(pos) + c := tx.chunk(pos) c.Entities, c.modified = sliceutil.DeleteVal(c.Entities, handle), true w.removeEntityFromViewLayers(e) @@ -1021,11 +1173,11 @@ func (w *World) PortalDestination(dim Dimension) *World { // Save saves the World to the provider. func (w *World) Save() { - <-w.Exec(w.save(w.saveChunk)) + <-w.exec(w.save(w.saveChunk)) } // save saves all loaded chunks to the World's provider. -func (w *World) save(f func(*Tx, ChunkPos, *Column)) ExecFunc { +func (w *World) save(f func(*Tx, ChunkPos, *Column)) execFunc { return func(tx *Tx) { if w.conf.ReadOnly { return @@ -1055,6 +1207,7 @@ func (w *World) saveChunk(_ *Tx, pos ChunkPos, c *Column) { func (w *World) closeChunk(tx *Tx, pos ChunkPos, c *Column) { w.saveChunk(tx, pos, c) w.scheduledUpdates.removeChunk(pos) + w.redstone.removeChunk(pos) // Note: We close c.Entities here because some entities may remove // themselves from the world in their Close method, which can lead to // unexpected conditions. @@ -1074,16 +1227,31 @@ func (w *World) Close() error { // close stops the World from ticking, saves all chunks to the Provider and // updates the world's settings. func (w *World) close() { - <-w.Exec(func(tx *Tx) { + w.scheduleMu.Lock() + w.closed.Store(true) + close(w.closeStarted) + w.scheduleMu.Unlock() + + w.scheduling.Wait() + w.scheduleMu.Lock() + w.closeAcceptingEntityTasks.Store(true) + w.scheduleMu.Unlock() + <-w.exec(func(tx *Tx) { // Let user code run anything that needs to be finished before closing. w.Handler().HandleClose(tx) + tx.runDeferred() w.Handle(NopHandler{}) w.save(w.closeChunk)(tx) }) + w.scheduleMu.Lock() + w.closeAcceptingEntityTasks.Store(false) + w.scheduleMu.Unlock() + w.scheduling.Wait() close(w.closing) w.running.Wait() + w.chunkWorkers.wg.Wait() close(w.queueClosing) w.queueing.Wait() @@ -1177,48 +1345,106 @@ func showEntity(e Entity, viewer Viewer) { viewer.ViewEntityArmour(e) } +// loadedChunk returns chunk & true only if chunk at position passed is loaded. +func (w *World) loadedChunk(pos ChunkPos) (*Column, bool) { + c, ok := w.chunks[pos] + return c, ok +} + // chunk reads a chunk from the position passed. If a chunk at that position is // not yet loaded, the chunk is loaded from the provider, or generated if it // did not yet exist. Additionally, chunks newly loaded have the light in them // calculated before they are returned. -func (w *World) chunk(pos ChunkPos) *Column { +func (tx *Tx) chunk(pos ChunkPos) *Column { + w := tx.World() c, ok := w.chunks[pos] if ok { return c } - c, err := w.loadChunk(pos) - chunk.LightArea([]*chunk.Chunk{c.Chunk}, int(pos[0]), int(pos[1])).Fill() + c, ok = w.chunkFromAsyncPool(tx, pos) + if ok { + return c + } + col, err := w.loadChunk(pos) if err != nil { w.conf.Log.Error("load chunk: "+err.Error(), "X", pos[0], "Z", pos[1]) - return c } - w.calculateLight(pos) - return c + if col == nil { + return w.emptyColumn() + } + return w.addChunk(pos, col) } -// loadChunk attempts to load a chunk from the provider, or generates a chunk -// if one doesn't currently exist. -func (w *World) loadChunk(pos ChunkPos) (*Column, error) { +// loadChunk loads a chunk from the provider, or generates a chunk if one +// doesn't currently exist, and calculates the light within it. +func (w *World) loadChunk(pos ChunkPos) (*chunk.Column, error) { column, err := w.conf.Provider.LoadColumn(pos, w.conf.Dim) - switch { - case err == nil: - col := w.columnFrom(column, pos) - w.chunks[pos] = col - for _, e := range col.Entities { - w.entities[e] = pos - e.w = w + if err != nil { + if !errors.Is(err, leveldb.ErrNotFound) { + return nil, err } - return col, nil - case errors.Is(err, leveldb.ErrNotFound): - // The provider doesn't have a chunk saved at this position, so we generate a new one. - col := newColumn(chunk.New(w.conf.Blocks, w.Range())) - w.chunks[pos] = col - - w.conf.Generator.GenerateChunk(pos, col.Chunk) - return col, nil - default: - return newColumn(chunk.New(w.conf.Blocks, w.Range())), err + ch := chunk.New(w.conf.Blocks, w.Range()) + w.conf.Generator.GenerateChunk(pos, ch) + column = &chunk.Column{Chunk: ch} + } + chunk.LightArea([]*chunk.Chunk{column.Chunk}, int(pos[0]), int(pos[1])).Fill() + return column, nil +} + +// emptyColumn returns an empty column, used as a stand-in when a chunk could +// not be loaded. +func (w *World) emptyColumn() *Column { + return w.columnFrom(&chunk.Column{Chunk: chunk.New(w.conf.Blocks, w.Range())}, ChunkPos{}) +} + +// loadChunkAsync loads or generates the chunk at pos in the background, +// calling callback once ready. It returns false if it could not be scheduled. +func (w *World) loadChunkAsync(tx *Tx, pos ChunkPos, callback chunkCallback) bool { + if c, ok := w.chunks[pos]; ok { + callback(tx, c) + return true + } + if w.conf.Synchronous { + // Synchronous worlds have no chunk workers; load on the calling goroutine. + callback(tx, tx.chunk(pos)) + return true + } + if req, ok := w.chunkRequests[pos]; ok { + req.callbacks = append(req.callbacks, callback) + return true + } + req := &chunkRequest{pos: pos, done: make(chan struct{}), callbacks: []chunkCallback{callback}} + if !w.chunkWorkers.schedule(req) { + return false } + w.chunkRequests[pos] = req + return true +} + +// addChunk adds a loaded or generated chunk to the world, spawning saved +// entities and spreading light to neighbouring chunks. The chunk passed must +// already have its own light calculated. +func (w *World) addChunk(pos ChunkPos, c *chunk.Column) *Column { + column := w.columnFrom(c, pos) + w.chunks[pos] = column + for _, e := range column.Entities { + w.entities[e] = pos + e.setAndUnlockWorld(w) + e.markWorldReady(w) + } + w.calculateLight(pos) + return column +} + +// chunkFromAsyncPool waits for a pending background load of the chunk at pos, +// returning false if none was underway. +func (w *World) chunkFromAsyncPool(tx *Tx, pos ChunkPos) (*Column, bool) { + req, ok := w.chunkRequests[pos] + if ok { + c := req.doImmediate(tx) + return c, c != nil + } + return nil, false } // calculateLight calculates the light in the chunk passed and spreads the @@ -1271,7 +1497,7 @@ func (w *World) autoSave() { for { select { case <-closeUnused.C: - <-w.Exec(w.closeUnusedChunks) + <-w.exec(w.closeUnusedChunks) case <-save.C: w.Save() case <-w.closing: @@ -1303,11 +1529,6 @@ type Column struct { loaders []*Loader } -// newColumn returns a new Column wrapper around the chunk.Chunk passed. -func newColumn(c *chunk.Chunk) *Column { - return &Column{Chunk: c, BlockEntities: map[cube.Pos]Block{}} -} - // columnTo converts a Column to a chunk.Column so that it can be written to // a provider. func (w *World) columnTo(col *Column, pos ChunkPos) *chunk.Column { diff --git a/server/world/world_test.go b/server/world/world_test.go new file mode 100644 index 0000000000..5ca43933ac --- /dev/null +++ b/server/world/world_test.go @@ -0,0 +1,259 @@ +package world + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/go-gl/mathgl/mgl64" +) + +// TestSynchronousWorldDo verifies that Do on a synchronous World runs the task +// on the calling goroutine and returns a completed task. +func TestSynchronousWorldDo(t *testing.T) { + w := Config{Synchronous: true}.New() + defer w.Close() + + var ran bool + task := w.Do(func(tx *Tx) { ran = true }) + if !ran { + t.Fatal("expected task to have run when Do returned") + } + select { + case <-task.Done(): + default: + t.Fatal("expected task returned by Do to be done when Do returned") + } +} + +// TestSynchronousWorldAdvanceTick verifies that a synchronous World does not +// tick on its own and that AdvanceTick advances the current tick exactly once +// per call, even without any viewers. +func TestSynchronousWorldAdvanceTick(t *testing.T) { + w := Config{Synchronous: true}.New() + defer w.Close() + + current := func() int64 { + w.set.Lock() + defer w.set.Unlock() + return w.set.CurrentTick + } + start := current() + time.Sleep(time.Second / 10) + if got := current(); got != start { + t.Fatalf("expected no automatic ticking, tick advanced from %v to %v", start, got) + } + for range 5 { + w.AdvanceTick() + } + if got := current(); got != start+5 { + t.Fatalf("expected current tick %v after 5 AdvanceTick calls, got %v", start+5, got) + } +} + +func TestSynchronousEntityDoCanRemoveEntity(t *testing.T) { + w := Config{Synchronous: true}.New() + defer w.Close() + + h := EntitySpawnOpts{Position: mgl64.Vec3{0, 4, 0}}.New(testEntityType{}, testEntityConfig{}) + <-w.exec(func(tx *Tx) { + tx.AddEntity(h) + }) + + task := h.Do(func(tx *Tx, e Entity) { + tx.RemoveEntity(e) + }) + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + if err := task.Wait(ctx); err != nil { + t.Fatalf("entity Do self-removal did not complete: %v", err) + } +} + +func TestSynchronousEntityDoWaitsForAddEntityToFinish(t *testing.T) { + w := Config{Synchronous: true}.New() + defer w.Close() + + state := &blockingOpenState{ + firstOpen: make(chan struct{}), + secondOpen: make(chan struct{}), + release: make(chan struct{}), + } + h := EntitySpawnOpts{}.New(blockingOpenType{}, blockingOpenConfig{state: state}) + task := h.Do(func(*Tx, Entity) {}) + added := make(chan struct{}) + go func() { + w.Do(func(tx *Tx) { tx.AddEntity(h) }) + close(added) + }() + <-state.firstOpen + + premature := false + select { + case <-state.secondOpen: + premature = true + case <-time.After(time.Millisecond * 50): + } + close(state.release) + <-added + if err := task.Wait(context.Background()); err != nil { + t.Fatalf("entity Do failed: %v", err) + } + if premature { + t.Fatal("entity callback opened before AddEntity completed") + } +} + +func TestSynchronousAdvanceTickTicksViewerlessEntities(t *testing.T) { + w := Config{Synchronous: true}.New() + defer w.Close() + + h := EntitySpawnOpts{Position: mgl64.Vec3{0, 4, 0}}.New(testEntityType{}, testEntityConfig{}) + <-w.exec(func(tx *Tx) { + tx.AddEntity(h) + }) + + start := h.data.Pos + for range 3 { + w.AdvanceTick() + } + if got := h.data.Pos; got == start { + t.Fatalf("expected entity position to change after ticking, got %v", got) + } +} + +func TestSynchronousAdvanceTickTicksViewerlessBlockEntities(t *testing.T) { + w := Config{Synchronous: true}.New() + defer w.Close() + + pos := cube.Pos{0, 4, 0} + tb := &testTickerBlock{} + <-w.exec(func(tx *Tx) { + col := tx.chunk(chunkPosFromBlockPos(pos)) + chest, ok := tx.World().conf.Blocks.BlockByName("minecraft:chest", map[string]any{"minecraft:cardinal_direction": "north"}) + if !ok { + t.Fatal("expected chest block to be registered") + } + col.SetBlock(uint8(pos[0]), int16(pos[1]), uint8(pos[2]), 0, tx.World().conf.Blocks.BlockRuntimeID(chest)) + col.BlockEntities[pos] = tb + }) + + w.AdvanceTick() + if tb.ticks == 0 { + t.Fatal("expected block entity to tick") + } +} + +type testEntityConfig struct{} + +func (testEntityConfig) Apply(*EntityData) {} + +type testEntityType struct{} + +func (testEntityType) Open(_ *Tx, handle *EntityHandle, data *EntityData) Entity { + return &testEntity{handle: handle, data: data} +} + +func (testEntityType) EncodeEntity() string { + return "dragonfly:test_entity" +} + +func (testEntityType) BBox(Entity) cube.BBox { + return cube.Box(0, 0, 0, 1, 1, 1) +} + +func (testEntityType) DecodeNBT(map[string]any, *EntityData) {} + +func (testEntityType) EncodeNBT(*EntityData) map[string]any { + return nil +} + +type testEntity struct { + handle *EntityHandle + data *EntityData +} + +func (e *testEntity) Close() error { + return nil +} + +func (e *testEntity) H() *EntityHandle { + return e.handle +} + +func (e *testEntity) Position() mgl64.Vec3 { + return e.data.Pos +} + +func (e *testEntity) Rotation() cube.Rotation { + return e.data.Rot +} + +func (e *testEntity) Tick(*Tx, int64) { + e.data.Pos = e.data.Pos.Add(mgl64.Vec3{0, -0.1, 0}) +} + +type testTickerBlock struct { + ticks int +} + +type blockingOpenState struct { + opens atomic.Int32 + firstOpen chan struct{} + secondOpen chan struct{} + release chan struct{} +} + +type blockingOpenConfig struct { + state *blockingOpenState +} + +func (c blockingOpenConfig) Apply(data *EntityData) { data.Data = c.state } + +type blockingOpenType struct{} + +func (blockingOpenType) Open(_ *Tx, handle *EntityHandle, data *EntityData) Entity { + state := data.Data.(*blockingOpenState) + switch state.opens.Add(1) { + case 1: + close(state.firstOpen) + <-state.release + case 2: + close(state.secondOpen) + } + return &testEntity{handle: handle, data: data} +} + +func (blockingOpenType) EncodeEntity() string { return "dragonfly:blocking_open" } + +func (blockingOpenType) BBox(Entity) cube.BBox { return cube.BBox{} } + +func (blockingOpenType) DecodeNBT(map[string]any, *EntityData) {} + +func (blockingOpenType) EncodeNBT(*EntityData) map[string]any { return nil } + +func (*testTickerBlock) EncodeBlock() (string, map[string]any) { + return "dragonfly:test_ticker", nil +} + +func (*testTickerBlock) Hash() (uint64, uint64) { + return 1<<32 - 1, 0 +} + +func (*testTickerBlock) Model() BlockModel { + return unknownModel{} +} + +func (*testTickerBlock) DecodeNBT(map[string]any) any { + return &testTickerBlock{} +} + +func (*testTickerBlock) EncodeNBT() map[string]any { + return nil +} + +func (b *testTickerBlock) Tick(int64, cube.Pos, *Tx) { + b.ticks++ +}