diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d41b822aee..592cc5a0df 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -23,7 +23,7 @@ jobs: - name: Verify generated code run: | - go generate ./server/block/ + go generate ./server/block/ ./server/item/component/ git diff --exit-code - name: Build deployment artifact diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index b4c2019275..ace04b35e6 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -23,7 +23,7 @@ jobs: - name: Verify generated code run: | - go generate ./server/block/ + go generate ./server/block/ ./server/item/component/ git diff --exit-code deploy: diff --git a/cmd/generate/componentgen/generate/components.go b/cmd/generate/componentgen/generate/components.go new file mode 100644 index 0000000000..73223d0b80 --- /dev/null +++ b/cmd/generate/componentgen/generate/components.go @@ -0,0 +1,311 @@ +package generate + +import ( + "bytes" + "fmt" + "go/format" + "os" + "path/filepath" + "strings" +) + +// generateComponentImplementations writes a single file containing every component +// struct definition plus its ComponentName() and Encode() methods, sourced from +// the manifest. Helper transform functions used by the encoders are emitted once. +func (g *Generator) generateComponentImplementations() error { + var buf bytes.Buffer + buf.WriteString("// Code generated by componentgen. DO NOT EDIT.\n") + buf.WriteString("package component\n\n") + + // Collect nested types (name -> fields) referenced by slice/nested codecs. + nested, encoderTypes := collectNestedTypes() + + // Emit each top-level component type and its methods. + for _, spec := range manifest { + emitStruct(&buf, spec.Struct, spec.Comment, spec.Fields) + emitComponentMethods(&buf, spec) + emitConstants(&buf, spec) + } + + // Emit nested types (sorted for determinism). + for _, name := range sortedNestedNames(nested) { + emitStruct(&buf, name, commentsFor(nested, name), nested[name]) + } + + // Emit encode() functions only for types actually encoded via + // slice/nested codecs (avoid unused functions for NameOrTags-only types). + for _, name := range sortedNestedNames(encoderTypes) { + emitNestedEncoder(&buf, name, encoderTypes[name]) + } + + // Emit shared helper functions. + emitHelpers(&buf) + + return writeFormattedFile(filepath.Join(g.outputDir, "components_gen.go"), buf.Bytes()) +} + +// writeFormattedFile writes Go source formatted with gofmt so generated files +// are always gofmt-clean. +func writeFormattedFile(path string, src []byte) error { + formatted, err := format.Source(src) + if err != nil { + return err + } + return os.WriteFile(path, formatted, 0o644) +} + +// collectNestedTypes builds name -> fields for every nested struct referenced by +// slice/nested codecs in the manifest, including nested-of-nested. It returns the +// full set of nested types plus the subset that require an encode() function +// (those used via slice/nested codecs, not NameOrTags). +func collectNestedTypes() (map[string][]Field, map[string][]Field) { + nested := map[string][]Field{} + encoderTypes := map[string][]Field{} + var walk func([]Field, bool) + walk = func(fields []Field, needsEncoder bool) { + for _, f := range fields { + var nestedName string + childNeedsEncoder := false + switch f.Codec { + case CodecSlice, CodecNested: + nestedName = f.SliceElement + childNeedsEncoder = true + case CodecNameOrTags: + nestedName = f.SliceElement + } + if nestedName != "" && !isScalarType(nestedName) && !isBuiltin(nestedName) { + if _, ok := nested[nestedName]; !ok && len(f.Fields) > 0 { + nested[nestedName] = f.Fields + walk(f.Fields, childNeedsEncoder) + } + if childNeedsEncoder { + encoderTypes[nestedName] = f.Fields + } + } + } + } + for _, spec := range manifest { + walk(spec.Fields, false) + } + return nested, encoderTypes +} + +func sortedNestedNames(nested map[string][]Field) []string { + names := make([]string, 0, len(nested)) + for n := range nested { + names = append(names, n) + } + sortStrings(names) + return names +} + +func commentsFor(nested map[string][]Field, name string) string { + return name + " represents a nested structure used by a component." +} + +// emitStruct writes a struct declaration. +func emitStruct(buf *bytes.Buffer, name, comment string, fields []Field) { + if comment != "" { + fmt.Fprintf(buf, "// %s\n", comment) + } + fmt.Fprintf(buf, "type %s struct {\n", name) + for _, f := range fields { + fmt.Fprintf(buf, "\t%s %s\n", f.GoName, f.Type) + } + buf.WriteString("}\n\n") +} + +// emitComponentMethods writes ComponentName() and Encode() for a component spec. +func emitComponentMethods(buf *bytes.Buffer, spec Spec) { + receiver := strings.ToLower(spec.Struct[:1]) + + fmt.Fprintf(buf, "func (%s %s) ComponentName() string { return %q }\n\n", receiver, spec.Struct, spec.Name) + + fmt.Fprintf(buf, "func (%s %s) Encode() (map[string]any, error) {\n", receiver, spec.Struct) + buf.WriteString("\tdata := map[string]any{}\n") + for _, f := range spec.Fields { + emitFieldEncode(buf, receiver, f, 1) + } + buf.WriteString("\treturn data, nil\n") + buf.WriteString("}\n\n") +} + +// emitConstants writes the named constant type (if any) and the constant group. +func emitConstants(buf *bytes.Buffer, spec Spec) { + if len(spec.ConstantGroup) == 0 { + return + } + if spec.ConstantType != "" { + fmt.Fprintf(buf, "// %s is a named string type for the %q slot values.\n", spec.ConstantType, spec.Name) + fmt.Fprintf(buf, "type %s string\n\n", spec.ConstantType) + } + buf.WriteString("const (\n") + for _, c := range spec.ConstantGroup { + fmt.Fprintf(buf, "\t%s\n", c) + } + buf.WriteString(")\n\n") +} + +// emitFieldEncode writes the assignment for a single field into data. +func emitFieldEncode(buf *bytes.Buffer, receiver string, f Field, indent int) { + key := f.NBTName + if key == "" { + return + } + + if f.OmitEmpty { + fmt.Fprintf(buf, "%sif %s {\n", strings.Repeat("\t", indent), omitCondition(receiver, f)) + indent++ + } + fmt.Fprintf(buf, "%sdata[%q] = %s\n", strings.Repeat("\t", indent), key, encodeExpr(receiver, f)) + if f.OmitEmpty { + fmt.Fprintf(buf, "%s}\n", strings.Repeat("\t", indent-1)) + } +} + +// omitCondition returns the Go expression that determines whether a field is omitted. +func omitCondition(receiver string, f Field) string { + if strings.HasPrefix(f.Type, "[]") { + return "len(" + receiver + "." + f.GoName + ") > 0" + } + return receiver + "." + f.GoName + " != " + zeroValue(f.Type) +} + +// encodeExpr returns the Go expression producing the encoded value for a field. +func encodeExpr(receiver string, f Field) string { + ref := receiver + "." + f.GoName + switch f.Codec { + case CodecStringSlice: + return "stringSlice(" + ref + ")" + case CodecRange: + return "rangeData(" + ref + ")" + case CodecRangeInt: + return "rangeIntData(" + ref + ")" + case CodecList3: + return "list3Data(" + ref + ")" + case CodecBanned: + return "bannedItemsData(" + ref + ")" + case CodecScalarSlice: + return "scalarSlice(" + ref + ")" + case CodecSlice: + if f.SliceElement == "" || isScalarType(f.SliceElement) { + return "scalarSlice(" + ref + ")" + } + return "encodeSlice(" + ref + ", encode" + f.SliceElement + ")" + case CodecNested: + return "encode" + f.SliceElement + "(" + ref + ")" + case CodecNameOrTags: + return "encodeNameOrTags(" + ref + ")" + default: + return ref + } +} + +// zeroValue returns the zero value literal for the given type. +func zeroValue(t string) string { + switch { + case strings.HasPrefix(t, "["): + return "(" + t + "{})" + case strings.HasPrefix(t, "[]"): + return "nil" + case t == "string": + return "\"\"" + case t == "bool": + return "false" + default: + return "0" + } +} + +// isScalarType reports whether t is a primitive Go type. +func isScalarType(t string) bool { + switch t { + case "string", "int", "int32", "int64", "float32", "float64", "bool", "byte": + return true + } + return false +} + +// isBuiltin reports whether a type is a builtin composite (array/slice/map). +func isBuiltin(t string) bool { + return strings.HasPrefix(t, "[") || strings.HasPrefix(t, "[]") || strings.HasPrefix(t, "map[") +} + +// emitNestedEncoder writes an encode() function. +func emitNestedEncoder(buf *bytes.Buffer, name string, fields []Field) { + fmt.Fprintf(buf, "func encode%s(v %s) map[string]any {\n", name, name) + buf.WriteString("\tdata := map[string]any{}\n") + for _, f := range fields { + emitFieldEncode(buf, "v", f, 1) + } + buf.WriteString("\treturn data\n") + buf.WriteString("}\n\n") +} + +// emitHelpers writes the transform helper functions referenced by the encoders. +func emitHelpers(buf *bytes.Buffer) { + buf.WriteString("func stringSlice(s []string) []any {\n") + buf.WriteString("\tr := make([]any, len(s))\n") + buf.WriteString("\tfor i, v := range s {\n") + buf.WriteString("\t\tr[i] = v\n") + buf.WriteString("\t}\n") + buf.WriteString("\treturn r\n") + buf.WriteString("}\n\n") + + buf.WriteString("func scalarSlice[T any](s []T) []any {\n") + buf.WriteString("\tr := make([]any, len(s))\n") + buf.WriteString("\tfor i, v := range s {\n") + buf.WriteString("\t\tr[i] = v\n") + buf.WriteString("\t}\n") + buf.WriteString("\treturn r\n") + buf.WriteString("}\n\n") + + buf.WriteString("func encodeSlice[V any](vs []V, enc func(V) map[string]any) []any {\n") + buf.WriteString("\tr := make([]any, len(vs))\n") + buf.WriteString("\tfor i, v := range vs {\n") + buf.WriteString("\t\tr[i] = enc(v)\n") + buf.WriteString("\t}\n") + buf.WriteString("\treturn r\n") + buf.WriteString("}\n\n") + + buf.WriteString("func rangeData(r [2]float32) map[string]any {\n") + buf.WriteString("\treturn map[string]any{\"min\": r[0], \"max\": r[1]}\n") + buf.WriteString("}\n\n") + + buf.WriteString("func rangeIntData(r [2]int32) map[string]any {\n") + buf.WriteString("\treturn map[string]any{\"min\": r[0], \"max\": r[1]}\n") + buf.WriteString("}\n\n") + + buf.WriteString("func list3Data(c [3]int32) []any {\n") + buf.WriteString("\treturn []any{c[0], c[1], c[2]}\n") + buf.WriteString("}\n\n") + + buf.WriteString("func bannedItemsData(items []string) []any {\n") + buf.WriteString("\tbanned := make([]any, len(items))\n") + buf.WriteString("\tfor i, b := range items {\n") + buf.WriteString("\t\tbanned[i] = map[string]any{\"name\": b}\n") + buf.WriteString("\t}\n") + buf.WriteString("\treturn banned\n") + buf.WriteString("}\n\n") + + buf.WriteString("func encodeNameOrTags(items []RepairItemEntry) []any {\n") + buf.WriteString("\tr := make([]any, len(items))\n") + buf.WriteString("\tfor i, item := range items {\n") + buf.WriteString("\t\tif item.Name != \"\" {\n") + buf.WriteString("\t\t\tr[i] = map[string]any{\"name\": item.Name}\n") + buf.WriteString("\t\t} else {\n") + buf.WriteString("\t\t\tr[i] = map[string]any{\"tags\": stringSlice(item.Tags)}\n") + buf.WriteString("\t\t}\n") + buf.WriteString("\t}\n") + buf.WriteString("\treturn r\n") + buf.WriteString("}\n\n") +} + +// sortStrings sorts a string slice in place. +func sortStrings(s []string) { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && s[j-1] > s[j]; j-- { + s[j-1], s[j] = s[j], s[j-1] + } + } +} diff --git a/cmd/generate/componentgen/generate/generator.go b/cmd/generate/componentgen/generate/generator.go new file mode 100644 index 0000000000..707f4ebcd3 --- /dev/null +++ b/cmd/generate/componentgen/generate/generator.go @@ -0,0 +1,203 @@ +package generate + +import ( + "bytes" + "fmt" + "path/filepath" + "slices" + "sort" + "strings" + "text/template" + "unicode" + + "github.com/df-mc/dragonfly/cmd/generate/componentgen/parse" +) + +type Generator struct { + outputDir string + schemas map[string]parse.ComponentSchema +} + +func NewGenerator(outputDir string, schemas map[string]parse.ComponentSchema) *Generator { + // Filter to only known components that we have typed implementations for + filtered := parse.FilterKnownComponents(schemas) + return &Generator{ + outputDir: outputDir, + schemas: filtered, + } +} + +func (g *Generator) Generate() error { + // Generate component type implementations (structs + ComponentName + Encode). + if err := g.generateComponentImplementations(); err != nil { + return fmt.Errorf("generate component implementations: %w", err) + } + + // Generate constants.go + if err := g.generateConstants(); err != nil { + return fmt.Errorf("generate constants: %w", err) + } + + // Generate validation.go + if err := g.generateValidation(); err != nil { + return fmt.Errorf("generate validation: %w", err) + } + + return nil +} + +func (g *Generator) generateConstants() error { + tmpl := template.Must(template.New("constants").Parse(constantsTemplate)) + + type Comp struct { + Name string + ConstName string + StructName string + } + + var components []Comp + for _, spec := range manifest { + components = append(components, Comp{ + Name: spec.Name, + ConstName: componentNameToConst(spec.Name), + StructName: spec.Struct, + }) + } + + data := map[string]any{ + "Components": components, + } + + path := filepath.Join(g.outputDir, "constants.go") + return renderAndWrite(path, tmpl, data) +} + +func (g *Generator) generateValidation() error { + tmpl := template.Must(template.New("validation").Parse(validationTemplate)) + + type Comp struct { + Name string + StructName string + } + + var components []Comp + for _, name := range sortedSchemaNames(g.schemas) { + structName := componentNameToStruct(name) + components = append(components, Comp{ + Name: name, + StructName: structName, + }) + } + + data := map[string]any{ + "Components": components, + } + + path := filepath.Join(g.outputDir, "validation.go") + return renderAndWrite(path, tmpl, data) +} + +// renderAndWrite renders tmpl with data, gofmt-formats the result and writes it +// to path so generated files are always gofmt-clean. +func renderAndWrite(path string, tmpl *template.Template, data any) error { + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return err + } + return writeFormattedFile(path, buf.Bytes()) +} + +// sortedSchemaNames returns the keys of schemas in sorted order so that generated +// files are deterministic regardless of Go map iteration order. +func sortedSchemaNames(schemas map[string]parse.ComponentSchema) []string { + names := make([]string, 0, len(schemas)) + for name := range schemas { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func componentNameToStruct(name string) string { + // Remove "minecraft:" prefix + name = strings.TrimPrefix(name, "minecraft:") + // Convert snake_case to PascalCase + parts := strings.Split(name, "_") + for i, p := range parts { + if len(p) > 0 { + parts[i] = strings.ToUpper(p[:1]) + p[1:] + } + } + return strings.Join(parts, "") +} + +func componentNameToConst(name string) string { + // Remove "minecraft:" prefix and split parts. + parts := strings.Split(strings.TrimPrefix(name, "minecraft:"), "_") + + parts = slices.DeleteFunc(parts, func(w string) bool { return w == "" }) // Remove empty parts + + for i, word := range parts { + if len(word) == 0 { + continue + } + // Capitalize first letter, lowercase the rest + parts[i] = string(unicode.ToUpper(rune(word[0]))) + strings.ToLower(word[1:]) + } + + return strings.Join(parts, "") +} + +const constantsTemplate = `// Code generated by componentgen. DO NOT EDIT. +package component + +// Component name constants +const ( +{{- range .Components }} + Component{{ .ConstName }} = "{{ .Name }}" +{{- end }} +) +` + +const validationTemplate = `// Code generated by componentgen. DO NOT EDIT. +package component + +import "fmt" + +// ValidationRules contains cross-component validation rules derived from BDS. +var ValidationRules = map[string][]string{ + "minecraft:kinetic_weapon": {"minecraft:use_modifiers"}, + "minecraft:bundle_interaction": {"minecraft:storage_item"}, + "minecraft:shooter": {"minecraft:use_modifiers"}, +} + +// ValidateComponents checks cross-component constraints. +func ValidateComponents(components map[string]any) error { + // kinetic_weapon requires use_modifiers + if _, ok := components["minecraft:kinetic_weapon"]; ok { + if _, ok := components["minecraft:use_modifiers"]; !ok { + return fmt.Errorf("minecraft:kinetic_weapon requires minecraft:use_modifiers component") + } + } + + // bundle_interaction requires storage_item + if _, ok := components["minecraft:bundle_interaction"]; ok { + if _, ok := components["minecraft:storage_item"]; !ok { + return fmt.Errorf("minecraft:bundle_interaction requires minecraft:storage_item component") + } + } + + // shooter requires use_modifiers with non-zero use_duration + if _, ok := components["minecraft:shooter"]; ok { + if um, ok := components["minecraft:use_modifiers"].(map[string]any); ok { + if ud, ok := um["use_duration"].(float32); !ok || ud == 0 { + return fmt.Errorf("minecraft:shooter requires non-zero use_duration in minecraft:use_modifiers") + } + } else { + return fmt.Errorf("minecraft:shooter requires minecraft:use_modifiers component with non-zero use_duration") + } + } + + return nil +} +` diff --git a/cmd/generate/componentgen/generate/manifest.go b/cmd/generate/componentgen/generate/manifest.go new file mode 100644 index 0000000000..e1ff5b4514 --- /dev/null +++ b/cmd/generate/componentgen/generate/manifest.go @@ -0,0 +1,501 @@ +package generate + +// Codec describes how a component field is encoded to its NBT representation. +type Codec string + +const ( + // CodecDirect encodes the field's value directly under its NBT name. + CodecDirect Codec = "direct" + // CodecStringSlice encodes a []string as a []any. + CodecStringSlice Codec = "string_slice" + // CodecList3 encodes a [3]int32 as a []any. + CodecList3 Codec = "list3" + // CodecRange encodes a [2]float32 as a {"min","max"} compound. + CodecRange Codec = "range" + // CodecRangeInt encodes a [2]int32 as a {"min","max"} compound. + CodecRangeInt Codec = "range_int" + // CodecSlice encodes a []Nested struct as a []any of encoded compounds. + CodecSlice Codec = "slice" + // CodecScalarSlice encodes a []T where T is a scalar as a []any of values. + CodecScalarSlice Codec = "scalar_slice" + // CodecNested encodes a single Nested struct as a compound. + CodecNested Codec = "nested" + // CodecBanned encodes a []string as a []any of {"name"} compounds. + CodecBanned Codec = "banned" + // CodecNameOrTags encodes a []RepairItemEntry as {"name"} or {"tags"} compounds. + CodecNameOrTags Codec = "name_or_tags" +) + +// Field describes a single struct field of a component (or nested type). +type Field struct { + // GoName is the exported Go field name (e.g. "MaxDurability"). + GoName string + // NBTName is the wire key (e.g. "max_durability"). Empty when not emitted directly. + NBTName string + // Type is the Go field type (e.g. "int32", "[]string", "WeaponConditions"). + Type string + // Codec selects the encode behaviour. Defaults to CodecDirect. + Codec Codec + // SliceElement, when Codec is slice/nested/name_or_tags, names the nested type + // whose fields are declared in Fields. + SliceElement string + // Fields holds the nested type's fields for slice/nested/name_or_tags codecs. + Fields []Field + // OmitEmpty omits the key when the value is the zero value of its type. + OmitEmpty bool +} + +// Spec describes a single generated component type. +type Spec struct { + // Struct is the exported Go struct name (e.g. "Food"). + Struct string + // Name is the namespaced component name (e.g. "minecraft:food"). + Name string + // Comment is the doc comment for the struct. + Comment string + // Fields lists the struct fields in declaration order. + Fields []Field + // ConstantGroup is a set of package-level constants emitted alongside the struct. + ConstantGroup []string + // ConstantType, when non-empty, names a new named string type for the + // ConstantGroup constants (e.g. "SlotArmor"). The type declaration is emitted + // before the constants, which then declare `Name ConstantType = "value"`. + ConstantType string +} + +// manifest lists every component that should be generated. +var manifest = []Spec{ + { + Struct: "Wearable", + Name: "minecraft:wearable", + Comment: "Wearable represents the minecraft:wearable component.", + Fields: []Field{ + {GoName: "Slot", NBTName: "slot", Type: "SlotArmor"}, + {GoName: "Protection", NBTName: "protection", Type: "int32"}, + {GoName: "HidesPlayerLocation", NBTName: "hides_player_location", Type: "bool"}, + {GoName: "Dispensable", NBTName: "dispensable", Type: "bool"}, + }, + ConstantType: "SlotArmor", + ConstantGroup: []string{ + "SlotArmorHead SlotArmor = \"slot.armor.head\"", + "SlotArmorChest SlotArmor = \"slot.armor.chest\"", + "SlotArmorLegs SlotArmor = \"slot.armor.legs\"", + "SlotArmorFeet SlotArmor = \"slot.armor.feet\"", + }, + }, + { + Struct: "Food", + Name: "minecraft:food", + Comment: "Food represents the minecraft:food component.", + Fields: []Field{ + {GoName: "Nutrition", NBTName: "nutrition", Type: "int32"}, + {GoName: "SaturationModifier", NBTName: "saturation_modifier", Type: "float32"}, + {GoName: "CanAlwaysEat", NBTName: "can_always_eat", Type: "bool"}, + {GoName: "UsingConvertsTo", NBTName: "using_converts_to", Type: "string", OmitEmpty: true}, + {GoName: "OnUseAction", NBTName: "on_use_action", Type: "int32", OmitEmpty: true}, + {GoName: "CooldownTime", NBTName: "cooldown_time", Type: "int32", OmitEmpty: true}, + {GoName: "CooldownType", NBTName: "cooldown_type", Type: "string", OmitEmpty: true}, + {GoName: "Effects", NBTName: "effects", Type: "[]FoodEffect", Codec: CodecSlice, SliceElement: "FoodEffect", OmitEmpty: true, + Fields: []Field{ + {GoName: "ID", NBTName: "id", Type: "int32"}, + {GoName: "Duration", NBTName: "duration", Type: "int32"}, + {GoName: "Amplifier", NBTName: "amplifier", Type: "int32"}, + {GoName: "Chance", NBTName: "chance", Type: "float32"}, + {GoName: "Name", NBTName: "name", Type: "string", OmitEmpty: true}, + }}, + {GoName: "RemoveEffects", NBTName: "remove_effects", Type: "[]int32", Codec: CodecScalarSlice, OmitEmpty: true}, + }, + }, + { + Struct: "Durability", + Name: "minecraft:durability", + Comment: "Durability represents the minecraft:durability component.", + Fields: []Field{ + {GoName: "MaxDurability", NBTName: "max_durability", Type: "int32"}, + {GoName: "DamageChance", NBTName: "damage_chance", Type: "[2]int32", Codec: CodecRangeInt}, + }, + }, + { + Struct: "KineticWeapon", + Name: "minecraft:kinetic_weapon", + Comment: "KineticWeapon represents the minecraft:kinetic_weapon component.", + Fields: []Field{ + {GoName: "CreativeReach", NBTName: "creative_reach", Type: "[2]float32", Codec: CodecRange}, + {GoName: "DamageConditions", NBTName: "damage_conditions", Type: "WeaponConditions", Codec: CodecNested, SliceElement: "WeaponConditions", + Fields: []Field{ + {GoName: "MaxDuration", NBTName: "max_duration", Type: "int32"}, + {GoName: "MinSpeed", NBTName: "min_speed", Type: "float32"}, + {GoName: "MinRelativeSpeed", NBTName: "min_relative_speed", Type: "float32"}, + }}, + {GoName: "DamageModifier", NBTName: "damage_modifier", Type: "float32"}, + {GoName: "DamageMultiplier", NBTName: "damage_multiplier", Type: "float32"}, + {GoName: "Delay", NBTName: "delay", Type: "int32"}, + {GoName: "DismountConditions", NBTName: "dismount_conditions", Type: "WeaponConditions", Codec: CodecNested, SliceElement: "WeaponConditions", + Fields: []Field{ + {GoName: "MaxDuration", NBTName: "max_duration", Type: "int32"}, + {GoName: "MinSpeed", NBTName: "min_speed", Type: "float32"}, + {GoName: "MinRelativeSpeed", NBTName: "min_relative_speed", Type: "float32"}, + }}, + {GoName: "HitboxMargin", NBTName: "hitbox_margin", Type: "float32"}, + {GoName: "KnockbackConditions", NBTName: "knockback_conditions", Type: "WeaponConditions", Codec: CodecNested, SliceElement: "WeaponConditions", + Fields: []Field{ + {GoName: "MaxDuration", NBTName: "max_duration", Type: "int32"}, + {GoName: "MinSpeed", NBTName: "min_speed", Type: "float32"}, + {GoName: "MinRelativeSpeed", NBTName: "min_relative_speed", Type: "float32"}, + }}, + {GoName: "Reach", NBTName: "reach", Type: "[2]float32", Codec: CodecRange}, + }, + }, + { + Struct: "UseModifiers", + Name: "minecraft:use_modifiers", + Comment: "UseModifiers represents the minecraft:use_modifiers component.", + Fields: []Field{ + {GoName: "MovementModifier", NBTName: "movement_modifier", Type: "float32"}, + {GoName: "UseDuration", NBTName: "use_duration", Type: "float32"}, + {GoName: "EmitVibrations", NBTName: "emit_vibrations", Type: "bool"}, + {GoName: "StartSound", NBTName: "start_sound", Type: "string", OmitEmpty: true}, + {GoName: "StartUsing", NBTName: "start_using", Type: "string", OmitEmpty: true}, + }, + }, + { + Struct: "StorageItem", + Name: "minecraft:storage_item", + Comment: "StorageItem represents the minecraft:storage_item component.", + Fields: []Field{ + {GoName: "AllowNestedStorageItems", NBTName: "allow_nested_storage_items", Type: "bool"}, + {GoName: "AllowedItems", NBTName: "allowed_items", Type: "[]string", Codec: CodecStringSlice}, + {GoName: "BannedItems", NBTName: "banned_items", Type: "[]string", Codec: CodecBanned}, + {GoName: "MaxSlots", NBTName: "max_slots", Type: "int32"}, + }, + }, + { + Struct: "Shooter", + Name: "minecraft:shooter", + Comment: "Shooter represents the minecraft:shooter component.", + Fields: []Field{ + {GoName: "Ammunition", NBTName: "ammunition", Type: "[]Ammunition", Codec: CodecSlice, SliceElement: "Ammunition", + Fields: []Field{ + {GoName: "Item", NBTName: "item", Type: "string"}, + {GoName: "SearchInventory", NBTName: "search_inventory", Type: "bool"}, + {GoName: "UseInCreative", NBTName: "use_in_creative", Type: "bool"}, + {GoName: "UseOffHand", NBTName: "use_offhand", Type: "bool"}, + }}, + {GoName: "ChargeOnDraw", NBTName: "charge_on_draw", Type: "bool"}, + {GoName: "MaxDrawDuration", NBTName: "max_draw_duration", Type: "float32"}, + {GoName: "ScalePowerByDrawDuration", NBTName: "scale_power_by_draw_duration", Type: "bool"}, + }, + }, + { + Struct: "Projectile", + Name: "minecraft:projectile", + Comment: "Projectile represents the minecraft:projectile component.", + Fields: []Field{ + {GoName: "MinimumCriticalPower", NBTName: "minimum_critical_power", Type: "float32"}, + {GoName: "ProjectileEntity", NBTName: "projectile_entity", Type: "string"}, + }, + }, + { + Struct: "Throwable", + Name: "minecraft:throwable", + Comment: "Throwable represents the minecraft:throwable component.", + Fields: []Field{ + {GoName: "DoSwingAnimation", NBTName: "do_swing_animation", Type: "bool"}, + {GoName: "LaunchPowerScale", NBTName: "launch_power_scale", Type: "float32", OmitEmpty: true}, + {GoName: "MaxDrawDuration", NBTName: "max_draw_duration", Type: "float32", OmitEmpty: true}, + {GoName: "MaxLaunchPower", NBTName: "max_launch_power", Type: "float32", OmitEmpty: true}, + {GoName: "MinDrawDuration", NBTName: "min_draw_duration", Type: "float32", OmitEmpty: true}, + {GoName: "ScalePowerByDrawDuration", NBTName: "scale_power_by_draw_duration", Type: "bool", OmitEmpty: true}, + }, + }, + { + Struct: "Damage", + Name: "minecraft:damage", + Comment: "Damage represents the minecraft:damage component.", + Fields: []Field{ + {GoName: "Value", NBTName: "value", Type: "float32"}, + }, + }, + { + Struct: "Cooldown", + Name: "minecraft:cooldown", + Comment: "Cooldown represents the minecraft:cooldown component.", + Fields: []Field{ + {GoName: "Category", NBTName: "category", Type: "string"}, + {GoName: "Duration", NBTName: "duration", Type: "float32"}, + {GoName: "Type", NBTName: "type", Type: "string", OmitEmpty: true}, + }, + }, + { + Struct: "Enchantable", + Name: "minecraft:enchantable", + Comment: "Enchantable represents the minecraft:enchantable component.", + Fields: []Field{ + {GoName: "Slot", NBTName: "slot", Type: "string"}, + {GoName: "Value", NBTName: "value", Type: "int32"}, + }, + }, + { + Struct: "Repairable", + Name: "minecraft:repairable", + Comment: "Repairable represents the minecraft:repairable component.", + Fields: []Field{ + {GoName: "RepairItems", NBTName: "repair_items", Type: "[]RepairEntry", Codec: CodecSlice, SliceElement: "RepairEntry", + Fields: []Field{ + {GoName: "Items", NBTName: "items", Type: "[]RepairItemEntry", Codec: CodecNameOrTags, SliceElement: "RepairItemEntry", + Fields: []Field{ + {GoName: "Name", NBTName: "name", Type: "string"}, + {GoName: "Tags", NBTName: "tags", Type: "[]string", Codec: CodecStringSlice}, + }}, + {GoName: "RepairAmount", NBTName: "repair_amount", Type: "int32"}, + }}, + }, + }, + { + Struct: "ItemTags", + Name: "minecraft:item_tags", + Comment: "ItemTags represents the minecraft:item_tags component.", + Fields: []Field{ + {GoName: "Tags", NBTName: "tags", Type: "[]string", Codec: CodecStringSlice}, + }, + }, + { + Struct: "Tags", + Name: "minecraft:tags", + Comment: "Tags represents the minecraft:tags component.", + Fields: []Field{ + {GoName: "Tags", NBTName: "tags", Type: "[]string", Codec: CodecStringSlice}, + }, + }, + { + Struct: "Seed", + Name: "minecraft:seed", + Comment: "Seed represents the minecraft:seed component.", + Fields: []Field{ + {GoName: "CropResult", NBTName: "crop_result", Type: "string"}, + {GoName: "PlantAt", NBTName: "plant_at", Type: "[]string", Codec: CodecStringSlice}, + {GoName: "PlantAtAnySolidSurface", NBTName: "plant_at_any_solid_surface", Type: "bool"}, + {GoName: "PlantAtFace", NBTName: "plant_at_face", Type: "string"}, + }, + }, + { + Struct: "Fuel", + Name: "minecraft:fuel", + Comment: "Fuel represents the minecraft:fuel component.", + Fields: []Field{ + {GoName: "Duration", NBTName: "duration", Type: "float32"}, + }, + }, + { + Struct: "FireResistant", + Name: "minecraft:fire_resistant", + Comment: "FireResistant represents the minecraft:fire_resistant component.", + Fields: []Field{ + {GoName: "Value", NBTName: "value", Type: "bool"}, + }, + }, + { + Struct: "Glint", + Name: "minecraft:glint", + Comment: "Glint represents the minecraft:glint component.", + Fields: []Field{ + {GoName: "Value", NBTName: "value", Type: "bool"}, + }, + }, + { + Struct: "SwingDuration", + Name: "minecraft:swing_duration", + Comment: "SwingDuration represents the minecraft:swing_duration component.", + Fields: []Field{ + {GoName: "Value", NBTName: "value", Type: "float32"}, + }, + }, + { + Struct: "SwingSounds", + Name: "minecraft:swing_sounds", + Comment: "SwingSounds represents the minecraft:swing_sounds component.", + Fields: []Field{ + {GoName: "AttackCriticalHit", NBTName: "attack_critical_hit", Type: "string"}, + {GoName: "AttackHit", NBTName: "attack_hit", Type: "string"}, + {GoName: "AttackMiss", NBTName: "attack_miss", Type: "string"}, + }, + }, + { + Struct: "PiercingWeapon", + Name: "minecraft:piercing_weapon", + Comment: "PiercingWeapon represents the minecraft:piercing_weapon component.", + Fields: []Field{ + {GoName: "CreativeReach", NBTName: "creative_reach", Type: "[2]float32", Codec: CodecRange}, + {GoName: "HitboxMargin", NBTName: "hitbox_margin", Type: "float32"}, + {GoName: "Reach", NBTName: "reach", Type: "[2]float32", Codec: CodecRange}, + }, + }, + { + Struct: "Camera", + Name: "minecraft:camera", + Comment: "Camera represents the minecraft:camera component.", + Fields: []Field{ + {GoName: "BlackBarsDuration", NBTName: "black_bars_duration", Type: "float32"}, + {GoName: "BlackBarsScreenRatio", NBTName: "black_bars_screen_ratio", Type: "float32"}, + {GoName: "PictureDuration", NBTName: "picture_duration", Type: "float32"}, + {GoName: "ShutterDuration", NBTName: "shutter_duration", Type: "float32"}, + {GoName: "ShutterScreenRatio", NBTName: "shutter_screen_ratio", Type: "float32"}, + {GoName: "SlideAwayDuration", NBTName: "slide_away_duration", Type: "float32"}, + {GoName: "UseDuration", NBTName: "use_duration", Type: "int32"}, + }, + }, + { + Struct: "Block", + Name: "minecraft:block", + Comment: "Block represents the minecraft:block component.", + Fields: []Field{ + {GoName: "Value", NBTName: "value", Type: "string"}, + }, + }, + { + Struct: "BlockPlacer", + Name: "minecraft:block_placer", + Comment: "BlockPlacer represents the minecraft:block_placer component.", + Fields: []Field{ + {GoName: "Block", NBTName: "block", Type: "string"}, + {GoName: "ReplaceBlockItem", NBTName: "replace_block_item", Type: "string"}, + {GoName: "AlignedPlacement", NBTName: "aligned_placement", Type: "bool"}, + {GoName: "UseOn", NBTName: "use_on", Type: "[]string", Codec: CodecStringSlice, OmitEmpty: true}, + }, + }, + { + Struct: "Compostable", + Name: "minecraft:compostable", + Comment: "Compostable represents the minecraft:compostable component.", + Fields: []Field{ + {GoName: "CompostingChance", NBTName: "composting_chance", Type: "int32"}, + }, + }, + { + Struct: "DamageAbsorption", + Name: "minecraft:damage_absorption", + Comment: "DamageAbsorption represents the minecraft:damage_absorption component.", + Fields: []Field{ + {GoName: "AbsorbableCauses", NBTName: "absorbable_causes", Type: "[]string", Codec: CodecStringSlice}, + }, + }, + { + Struct: "Digger", + Name: "minecraft:digger", + Comment: "Digger represents the minecraft:digger component.", + Fields: []Field{ + {GoName: "DestroySpeeds", NBTName: "destroy_speeds", Type: "[]DestroySpeed", Codec: CodecSlice, SliceElement: "DestroySpeed", + Fields: []Field{ + {GoName: "Block", NBTName: "block", Type: "string"}, + {GoName: "Speed", NBTName: "speed", Type: "float32"}, + }}, + {GoName: "UseEfficiency", NBTName: "use_efficiency", Type: "bool"}, + }, + }, + { + Struct: "DurabilitySensor", + Name: "minecraft:durability_sensor", + Comment: "DurabilitySensor represents the minecraft:durability_sensor component.", + Fields: []Field{ + {GoName: "SoundEvent", NBTName: "sound_event", Type: "string", OmitEmpty: true}, + {GoName: "DurabilityThresholds", NBTName: "durability_thresholds", Type: "[]DurabilityThreshold", Codec: CodecSlice, SliceElement: "DurabilityThreshold", OmitEmpty: true, + Fields: []Field{ + {GoName: "Durability", NBTName: "durability", Type: "int32"}, + {GoName: "ParticleType", NBTName: "particle_type", Type: "string", OmitEmpty: true}, + {GoName: "SoundEvent", NBTName: "sound_event", Type: "string", OmitEmpty: true}, + }}, + }, + }, + { + Struct: "Dyeable", + Name: "minecraft:dyeable", + Comment: "Dyeable represents the minecraft:dyeable component.", + Fields: []Field{ + {GoName: "DefaultColor", NBTName: "default_color", Type: "[3]int32", Codec: CodecList3}, + }, + }, + { + Struct: "EntityPlacer", + Name: "minecraft:entity_placer", + Comment: "EntityPlacer represents the minecraft:entity_placer component.", + Fields: []Field{ + {GoName: "Entity", NBTName: "entity", Type: "string"}, + {GoName: "UseOn", NBTName: "use_on", Type: "[]string", Codec: CodecStringSlice, OmitEmpty: true}, + {GoName: "DispenseOn", NBTName: "dispense_on", Type: "[]string", Codec: CodecStringSlice, OmitEmpty: true}, + }, + }, + { + Struct: "HoverTextColor", + Name: "minecraft:hover_text_color", + Comment: "HoverTextColor represents the minecraft:hover_text_color component.", + Fields: []Field{ + {GoName: "Value", NBTName: "value", Type: "int32"}, + }, + }, + { + Struct: "InteractButton", + Name: "minecraft:interact_button", + Comment: "InteractButton represents the minecraft:interact_button component.", + Fields: []Field{ + {GoName: "Value", NBTName: "value", Type: "string"}, + }, + }, + { + Struct: "LiquidClipped", + Name: "minecraft:liquid_clipped", + Comment: "LiquidClipped represents the minecraft:liquid_clipped component.", + Fields: []Field{ + {GoName: "Value", NBTName: "value", Type: "bool"}, + }, + }, + { + Struct: "Rarity", + Name: "minecraft:rarity", + Comment: "Rarity represents the minecraft:rarity component.", + Fields: []Field{ + {GoName: "Value", NBTName: "value", Type: "string"}, + }, + }, + { + Struct: "Record", + Name: "minecraft:record", + Comment: "Record represents the minecraft:record component.", + Fields: []Field{ + {GoName: "ComparatorSignal", NBTName: "comparator_signal", Type: "int32"}, + {GoName: "Duration", NBTName: "duration", Type: "float32"}, + {GoName: "SoundEvent", NBTName: "sound_event", Type: "string"}, + }, + }, + { + Struct: "ShouldDespawn", + Name: "minecraft:should_despawn", + Comment: "ShouldDespawn represents the minecraft:should_despawn component.", + Fields: []Field{ + {GoName: "Value", NBTName: "value", Type: "bool"}, + }, + }, + { + Struct: "BundleInteraction", + Name: "minecraft:bundle_interaction", + Comment: "BundleInteraction represents the minecraft:bundle_interaction component.", + Fields: []Field{ + {GoName: "NumViewableSlots", NBTName: "num_viewable_slots", Type: "int32"}, + }, + }, + { + Struct: "StorageWeightLimit", + Name: "minecraft:storage_weight_limit", + Comment: "StorageWeightLimit represents the minecraft:storage_weight_limit component.", + Fields: []Field{ + {GoName: "MaxWeightLimit", NBTName: "max_weight_limit", Type: "int32"}, + }, + }, + { + Struct: "StorageWeightModifier", + Name: "minecraft:storage_weight_modifier", + Comment: "StorageWeightModifier represents the minecraft:storage_weight_modifier component.", + Fields: []Field{ + {GoName: "WeightInStorageItem", NBTName: "weight_in_storage_item", Type: "int32"}, + }, + }, +} diff --git a/cmd/generate/componentgen/main.go b/cmd/generate/componentgen/main.go new file mode 100644 index 0000000000..0076f5ef97 --- /dev/null +++ b/cmd/generate/componentgen/main.go @@ -0,0 +1,58 @@ +package componentgen + +import ( + "context" + "flag" + "fmt" + "path/filepath" + + "github.com/df-mc/dragonfly/cmd/generate/componentgen/generate" + "github.com/df-mc/dragonfly/cmd/generate/componentgen/parse" + "github.com/df-mc/dragonfly/cmd/generate/generator" +) + +// Generator implements the generate.Generator interface for component generation. +type Generator struct { + outputDir string + vanillaNBT string +} + +func init() { + generator.Register(&Generator{}) +} + +func (g *Generator) Name() string { + return "componentgen" +} + +func (g *Generator) SetFlags(fs *flag.FlagSet) { + fs.StringVar(&g.outputDir, "output", "", "Output directory for generated files") + fs.StringVar(&g.vanillaNBT, "vanilla-nbt", "", "Path to vanilla_items.nbt") +} + +func (g *Generator) Generate(ctx context.Context) error { + if g.outputDir == "" { + g.outputDir = filepath.Join("server", "item", "component") + } + if g.vanillaNBT == "" { + g.vanillaNBT = filepath.Join("server", "world", "vanilla_items.nbt") + } + + // Parse vanilla items NBT + items, err := parse.VanillaItems(g.vanillaNBT) + if err != nil { + return fmt.Errorf("failed to parse vanilla items: %w", err) + } + + // Extract component schemas + schemas := parse.ExtractComponentSchemas(items) + + // Generate files + gen := generate.NewGenerator(g.outputDir, schemas) + if err := gen.Generate(); err != nil { + return fmt.Errorf("generation failed: %w", err) + } + + fmt.Println("Component generation complete") + return nil +} diff --git a/cmd/generate/componentgen/parse/schemas.go b/cmd/generate/componentgen/parse/schemas.go new file mode 100644 index 0000000000..f21a06d4d3 --- /dev/null +++ b/cmd/generate/componentgen/parse/schemas.go @@ -0,0 +1,122 @@ +package parse + +// ComponentSchema represents the schema of a component extracted from vanilla items. +type ComponentSchema struct { + Name string + SampleData map[string]any + ItemCount int +} + +// ExtractComponentSchemas extracts all unique component schemas from vanilla items. +func ExtractComponentSchemas(items map[string]VanillaItemEntry) map[string]ComponentSchema { + schemas := make(map[string]ComponentSchema) + + for _, item := range items { + if item.Data == nil { + continue + } + components, ok := item.Data["components"].(map[string]any) + if !ok { + continue + } + for compName, compData := range components { + if existing, ok := schemas[compName]; ok { + existing.ItemCount++ + schemas[compName] = existing + } else { + dataMap, _ := compData.(map[string]any) + schemas[compName] = ComponentSchema{ + Name: compName, + SampleData: dataMap, + ItemCount: 1, + } + } + } + } + + return schemas +} + +// AllComponentNames returns a sorted list of all component names. +func AllComponentNames(schemas map[string]ComponentSchema) []string { + names := make([]string, 0, len(schemas)) + for name := range schemas { + names = append(names, name) + } + // Simple sort + for i := 0; i < len(names); i++ { + for j := i + 1; j < len(names); j++ { + if names[i] > names[j] { + names[i], names[j] = names[j], names[i] + } + } + } + return names +} + +// ComponentSchemasToMap converts schemas to a map for template use. +func ComponentSchemasToMap(schemas map[string]ComponentSchema) map[string]any { + result := make(map[string]any) + for name, schema := range schemas { + result[name] = map[string]any{ + "name": schema.Name, + "sample_data": schema.SampleData, + "item_count": schema.ItemCount, + } + } + return result +} + +// FilterKnownComponents filters to only known components that we have typed implementations for. +func FilterKnownComponents(schemas map[string]ComponentSchema) map[string]ComponentSchema { + known := map[string]bool{ + "minecraft:wearable": true, + "minecraft:food": true, + "minecraft:durability": true, + "minecraft:kinetic_weapon": true, + "minecraft:use_modifiers": true, + "minecraft:storage_item": true, + "minecraft:shooter": true, + "minecraft:projectile": true, + "minecraft:throwable": true, + "minecraft:damage": true, + "minecraft:cooldown": true, + "minecraft:enchantable": true, + "minecraft:repairable": true, + "minecraft:item_tags": true, + "minecraft:tags": true, + "minecraft:seed": true, + "minecraft:fuel": true, + "minecraft:fire_resistant": true, + "minecraft:glint": true, + "minecraft:swing_duration": true, + "minecraft:swing_sounds": true, + "minecraft:piercing_weapon": true, + "minecraft:camera": true, + "minecraft:block": true, + "minecraft:block_placer": true, + "minecraft:compostable": true, + "minecraft:damage_absorption": true, + "minecraft:digger": true, + "minecraft:durability_sensor": true, + "minecraft:dyeable": true, + "minecraft:entity_placer": true, + "minecraft:hover_text_color": true, + "minecraft:interact_button": true, + "minecraft:liquid_clipped": true, + "minecraft:rarity": true, + "minecraft:record": true, + "minecraft:should_despawn": true, + "minecraft:bundle_interaction": true, + "minecraft:storage_weight_limit": true, + "minecraft:storage_weight_modifier": true, + } + + result := make(map[string]ComponentSchema) + for name, schema := range schemas { + if known[name] { + result[name] = schema + } + } + return result +} diff --git a/cmd/generate/componentgen/parse/vanilla.go b/cmd/generate/componentgen/parse/vanilla.go new file mode 100644 index 0000000000..81911c6df5 --- /dev/null +++ b/cmd/generate/componentgen/parse/vanilla.go @@ -0,0 +1,29 @@ +package parse + +import ( + "os" + + "github.com/sandertv/gophertunnel/minecraft/nbt" +) + +type VanillaItemEntry struct { + RuntimeID int32 `nbt:"runtime_id"` + ComponentBased bool `nbt:"component_based"` + Version int32 `nbt:"version"` + Data map[string]any `nbt:"data,omitempty"` +} + +// VanillaItems parses the vanilla_items.nbt file and returns a map of item name to entry. +func VanillaItems(path string) (map[string]VanillaItemEntry, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var items map[string]VanillaItemEntry + if err := nbt.Unmarshal(data, &items); err != nil { + return nil, err + } + + return items, nil +} diff --git a/cmd/generate/generator/runner.go b/cmd/generate/generator/runner.go new file mode 100644 index 0000000000..c4df168c28 --- /dev/null +++ b/cmd/generate/generator/runner.go @@ -0,0 +1,89 @@ +package generator + +import ( + "context" + "flag" + "fmt" + "os" +) + +// Generator is the interface that all generators must implement. +type Generator interface { + // Name returns the name of the generator. + Name() string + // Generate runs the generator. + Generate(ctx context.Context) error + // SetFlags sets the flags for the generator. + SetFlags(*flag.FlagSet) +} + +// Registry holds all registered generators. +var Registry = map[string]Generator{} + +// Register registers a generator. +func Register(g Generator) { + if _, ok := Registry[g.Name()]; ok { + panic(fmt.Sprintf("generator %q already registered", g.Name())) + } + Registry[g.Name()] = g +} + +// Run runs the specified generators. If names is empty, runs all generators. +func Run(ctx context.Context, names ...string) error { + var generators []Generator + if len(names) == 0 { + for _, g := range Registry { + generators = append(generators, g) + } + } else { + for _, name := range names { + g, ok := Registry[name] + if !ok { + return fmt.Errorf("unknown generator %q", name) + } + generators = append(generators, g) + } + } + + // Set up flags for each generator + fs := flag.NewFlagSet("generate", flag.ContinueOnError) + fs.SetOutput(os.Stdout) + for _, g := range generators { + g.SetFlags(fs) + } + // Parse all remaining args after the generator name + // names[0] is the generator name, so we need to skip it + // We'll parse from the full os.Args but skip the first two elements (binary name + generator name) + fs.Parse(os.Args[2:]) + + for _, g := range generators { + fmt.Printf("Running generator: %s\n", g.Name()) + if err := g.Generate(ctx); err != nil { + return fmt.Errorf("generator %s failed: %w", g.Name(), err) + } + } + return nil +} + +// Main is the entry point for the generate command. +// It can be called from a go:generate directive. +func Main() { + ctx := context.Background() + + // Get generator name from first arg + args := os.Args[1:] + if len(args) == 0 { + // Run all generators + if err := Run(ctx); err != nil { + fmt.Fprintf(os.Stderr, "Generation failed: %v\n", err) + os.Exit(1) + } + return + } + + // Run specific generator + if err := Run(ctx, args[0]); err != nil { + fmt.Fprintf(os.Stderr, "Generation failed: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/generate/main.go b/cmd/generate/main.go new file mode 100644 index 0000000000..35368fdf9e --- /dev/null +++ b/cmd/generate/main.go @@ -0,0 +1,10 @@ +package main + +import ( + _ "github.com/df-mc/dragonfly/cmd/generate/componentgen" + "github.com/df-mc/dragonfly/cmd/generate/generator" +) + +func main() { + generator.Main() +} diff --git a/go.mod b/go.mod index 160031cf5d..aab8fb48fe 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/pelletier/go-toml v1.9.5 github.com/sandertv/gophertunnel v1.61.0 github.com/segmentio/fasthash v1.0.3 + github.com/stretchr/testify v1.11.1 golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 golang.org/x/mod v0.32.0 golang.org/x/text v0.34.0 @@ -21,6 +22,7 @@ require ( require ( github.com/coder/websocket v1.8.14 // indirect github.com/coreos/go-oidc/v3 v3.17.0 // indirect + github.com/davecgh/go-spew v1.1.1 // 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 @@ -45,6 +47,7 @@ require ( 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/pmezard/go-difflib v1.0.0 // 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 @@ -54,4 +57,5 @@ require ( golang.org/x/sys v0.41.0 // indirect golang.org/x/time v0.14.0 // indirect gopkg.in/yaml.v2 v2.3.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index eca325328f..e822167284 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,10 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO 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/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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= @@ -124,6 +128,8 @@ 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/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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= diff --git a/main.go b/main.go index 03c5def921..8c5d988848 100644 --- a/main.go +++ b/main.go @@ -2,11 +2,12 @@ package main import ( "fmt" + "log/slog" + "os" + "github.com/df-mc/dragonfly/server" "github.com/df-mc/dragonfly/server/player/chat" "github.com/pelletier/go-toml" - "log/slog" - "os" ) func main() { @@ -20,7 +21,10 @@ func main() { srv := conf.New() srv.CloseOnProgramEnd() - srv.Listen() + if err := srv.Listen(); err != nil { + panic(err) + } + for p := range srv.Accept() { _ = p } diff --git a/server/internal/iteminternal/builder.go b/server/internal/iteminternal/builder.go index afffef0741..fe4a3da0e8 100644 --- a/server/internal/iteminternal/builder.go +++ b/server/internal/iteminternal/builder.go @@ -1,8 +1,9 @@ package iteminternal import ( - "github.com/df-mc/dragonfly/server/item/category" "maps" + + "github.com/df-mc/dragonfly/server/item/category" ) // ComponentBuilder represents a builder that can be used to construct an item components map to be sent to a client. @@ -50,13 +51,15 @@ func (builder *ComponentBuilder) Construct() map[string]any { // applyDefaultProperties applies the default properties to the provided map. It is important that this method does // not modify the builder's properties map directly otherwise Empty() will return false in future use of the builder. func (builder *ComponentBuilder) applyDefaultProperties(x map[string]any) { - x["minecraft:icon"] = map[string]any{ - "textures": map[string]any{ - "default": builder.identifier, - }, - } x["creative_group"] = builder.category.Group() x["creative_category"] = int32(builder.category.Uint8()) + if _, ok := x["minecraft:icon"]; !ok { + x["minecraft:icon"] = map[string]any{ + "textures": map[string]any{ + "default": builder.identifier, + }, + } + } if _, ok := x["max_stack_size"]; !ok { x["max_stack_size"] = int32(64) } diff --git a/server/internal/iteminternal/components.go b/server/internal/iteminternal/components.go index ed6f64d1ef..733e80aa4d 100644 --- a/server/internal/iteminternal/components.go +++ b/server/internal/iteminternal/components.go @@ -1,25 +1,47 @@ package iteminternal import ( + "fmt" + "strings" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/component" "github.com/df-mc/dragonfly/server/world" - "strings" ) // Components returns all the components of the given custom item. If the item has no components, a nil map and false // are returned. -func Components(it world.CustomItem) map[string]any { +func Components(it world.CustomItem) (map[string]any, error) { category := it.Category() identifier, _ := it.EncodeItem() - name := strings.Split(identifier, ":")[1] + + _, name, ok := strings.Cut(identifier, ":") + if !ok { + return nil, fmt.Errorf("identifier %s must contain namespace", identifier) + } + + // Check for new ComponentItem interface first + if ci, ok := it.(component.ComponentItem); ok { + comps, err := componentsFromComponentItem(ci) + if err != nil { + return nil, err + } + builder := NewComponentBuilder(it.Name(), identifier, category) + // Add the components from ComponentItem + for name, data := range comps { + builder.AddComponent(name, data) + } + result := builder.Construct() + components := result["components"].(map[string]any) + if err := ValidateComponents(components); err != nil { + return nil, fmt.Errorf("component validation failed for %s: %w", name, err) + } + return result, nil + } builder := NewComponentBuilder(it.Name(), identifier, category) if x, ok := it.(item.Armour); ok { - builder.AddComponent("minecraft:armor", map[string]any{ - "protection": int32(x.DefencePoints()), - }) - var slot string switch it.(type) { case item.HelmetType: @@ -32,7 +54,10 @@ func Components(it world.CustomItem) map[string]any { slot = "slot.armor.feet" } builder.AddComponent("minecraft:wearable", map[string]any{ - "slot": slot, + "slot": slot, + "protection": int32(x.DefencePoints()), + "hides_player_location": false, + "dispensable": false, }) } if x, ok := it.(item.Consumable); ok { @@ -56,6 +81,10 @@ func Components(it world.CustomItem) map[string]any { if x, ok := it.(item.Durable); ok { builder.AddComponent("minecraft:durability", map[string]any{ "max_durability": int32(x.DurabilityInfo().MaxDurability), + "damage_chance": map[string]any{ + "min": int32(100), + "max": int32(100), + }, }) } if x, ok := it.(item.MaxCounter); ok { @@ -64,19 +93,56 @@ func Components(it world.CustomItem) map[string]any { if x, ok := it.(item.OffHand); ok { builder.AddProperty("allow_off_hand", x.OffHand()) } - if x, ok := it.(item.Throwable); ok { + if _, ok := it.(item.Throwable); ok { // The data in minecraft:projectile is only used by vanilla server-side, but we must send at least an empty map // so the client will play the throwing animation. builder.AddComponent("minecraft:projectile", map[string]any{}) + } + if x, ok := it.(item.Throwable); ok { builder.AddComponent("minecraft:throwable", map[string]any{ "do_swing_animation": x.SwingAnimation(), }) } if x, ok := it.(item.Glinted); ok { - builder.AddProperty("foil", x.Glinted()) + builder.AddComponent("minecraft:glint", map[string]any{ + "value": x.Glinted(), + }) } if x, ok := it.(item.HandEquipped); ok { builder.AddProperty("hand_equipped", x.HandEquipped()) } - return builder.Construct() + if x, ok := it.(item.Weapon); ok { + builder.AddComponent("minecraft:damage", map[string]any{ + "value": x.AttackDamage(), + }) + } + if x, ok := it.(item.Fuel); ok { + builder.AddComponent("minecraft:fuel", map[string]any{ + "duration": float32(x.FuelInfo().Duration.Seconds()), + }) + } + if x, ok := it.(item.Compostable); ok { + builder.AddComponent("minecraft:compostable", map[string]any{ + "composting_chance": int32(x.CompostChance() * 100), + }) + } + result := builder.Construct() + components := result["components"].(map[string]any) + if err := ValidateComponents(components); err != nil { + return nil, fmt.Errorf("component validation failed for %s: %w", name, err) + } + return result, nil +} + +// componentsFromComponentItem converts a ComponentItem to a components map. +func componentsFromComponentItem(ci component.ComponentItem) (map[string]any, error) { + components := make(map[string]any) + for _, comp := range ci.ItemComponents() { + data, err := comp.Encode() + if err != nil { + return nil, err + } + components[comp.ComponentName()] = data + } + return components, nil } diff --git a/server/internal/iteminternal/golden_test.go b/server/internal/iteminternal/golden_test.go new file mode 100644 index 0000000000..611457988d --- /dev/null +++ b/server/internal/iteminternal/golden_test.go @@ -0,0 +1,82 @@ +package iteminternal + +import ( + "encoding/json" + "os" + "testing" + + "github.com/sandertv/gophertunnel/minecraft/nbt" + "github.com/stretchr/testify/require" +) + +func TestGoldenNBT(t *testing.T) { + var vanillaItems map[string]VanillaItemEntry + _ = nbt.Unmarshal(VanillaItemsData(), &vanillaItems) + + // Test a few key items that use the new component system + testItems := []string{ + "minecraft:diamond_sword", + "minecraft:diamond_spear", + "minecraft:bow", + "minecraft:crossbow", + "minecraft:bundle", + "minecraft:shield", + } + + for _, name := range testItems { + t.Run(name, func(t *testing.T) { + item, ok := vanillaItems[name] + require.True(t, ok, "item %s not found in vanilla items", name) + + if item.Data == nil { + t.Skip("item has no component data") + } + + components, ok := item.Data["components"].(map[string]any) + if !ok { + t.Skipf("item %s has no components map", name) + } + + // Verify key components are present + expectedComponents := expectedComponentsForItem(name) + for _, compName := range expectedComponents { + require.Contains(t, components, compName, "item %s missing component %s", name, compName) + } + + // Log the component structure for debugging + b, _ := json.MarshalIndent(components, "", " ") + t.Logf("Components for %s:\n%s", name, string(b)) + }) + } +} + +type VanillaItemEntry struct { + RuntimeID int32 `nbt:"runtime_id"` + ComponentBased bool `nbt:"component_based"` + Version int32 `nbt:"version"` + Data map[string]any `nbt:"data,omitempty"` +} + +func expectedComponentsForItem(name string) []string { + switch name { + case "minecraft:diamond_sword": + return []string{"minecraft:damage", "minecraft:durability", "minecraft:enchantable", "minecraft:repairable"} + case "minecraft:diamond_spear": + return []string{"minecraft:kinetic_weapon", "minecraft:use_modifiers", "minecraft:damage", "minecraft:durability", "minecraft:enchantable", "minecraft:repairable"} + case "minecraft:bow": + return []string{"minecraft:projectile", "minecraft:durability", "minecraft:enchantable", "minecraft:repairable"} + case "minecraft:crossbow": + return []string{"minecraft:projectile", "minecraft:durability", "minecraft:enchantable", "minecraft:repairable", "minecraft:cooldown"} + case "minecraft:bundle": + return []string{"minecraft:bundle_interaction", "minecraft:storage_item"} + case "minecraft:shield": + return []string{"minecraft:durability", "minecraft:enchantable", "minecraft:repairable"} + default: + return nil + } +} + +func VanillaItemsData() []byte { + data, _ := os.ReadFile("../../../server/world/vanilla_items.nbt") + return data +} diff --git a/server/internal/iteminternal/validator.go b/server/internal/iteminternal/validator.go new file mode 100644 index 0000000000..98066545f5 --- /dev/null +++ b/server/internal/iteminternal/validator.go @@ -0,0 +1,80 @@ +package iteminternal + +import ( + "fmt" +) + +// ValidateComponents checks cross-component constraints derived from BDS 1.26.40.8 +func ValidateComponents(components map[string]any) error { + // kinetic_weapon requires use_modifiers + if _, ok := components["minecraft:kinetic_weapon"]; ok { + if _, ok := components["minecraft:use_modifiers"]; !ok { + return fmt.Errorf("minecraft:kinetic_weapon requires minecraft:use_modifiers component") + } + // Validate kinetic_weapon conditions + if err := validateKineticWeapon(components["minecraft:kinetic_weapon"]); err != nil { + return err + } + } + + // bundle_interaction requires storage_item + if _, ok := components["minecraft:bundle_interaction"]; ok { + if _, ok := components["minecraft:storage_item"]; !ok { + return fmt.Errorf("minecraft:bundle_interaction requires minecraft:storage_item component") + } + } + + // shooter requires non-zero use_duration (checked via use_modifiers component) + if _, ok := components["minecraft:shooter"]; ok { + if um, ok := components["minecraft:use_modifiers"].(map[string]any); ok { + if ud, ok := um["use_duration"].(float32); !ok || ud == 0 { + return fmt.Errorf("minecraft:shooter requires non-zero use_duration in minecraft:use_modifiers") + } + } else { + return fmt.Errorf("minecraft:shooter requires minecraft:use_modifiers component with non-zero use_duration") + } + } + + // repairable validation: repair_items must be structured entries + if rep, ok := components["minecraft:repairable"].(map[string]any); ok { + if items, ok := rep["repair_items"].([]any); ok { + for _, item := range items { + if entry, ok := item.(map[string]any); ok { + if _, hasItems := entry["items"]; !hasItems { + return fmt.Errorf("minecraft:repairable repair_items must have 'items' field") + } + } + } + } + } + + return nil +} + +func validateKineticWeapon(kw any) error { + data, ok := kw.(map[string]any) + if !ok { + return fmt.Errorf("kinetic_weapon must be a compound") + } + + // At least one kinetic condition must be defined with max_duration > 0 + hasCondition := false + for _, key := range []string{"damage_conditions", "knockback_conditions", "dismount_conditions"} { + if cond, ok := data[key].(map[string]any); ok { + if maxDur, ok := cond["max_duration"].(int32); ok && maxDur > 0 { + hasCondition = true + // min_speed and min_relative_speed are mutually exclusive + _, hasMinSpeed := cond["min_speed"] + _, hasMinRelSpeed := cond["min_relative_speed"] + if hasMinSpeed && hasMinRelSpeed { + return fmt.Errorf("kinetic_weapon %s: min_speed and min_relative_speed are mutually exclusive", key) + } + } + } + } + if !hasCondition { + return fmt.Errorf("kinetic_weapon: at least one condition with max_duration > 0 required") + } + + return nil +} diff --git a/server/internal/nbtconv/read.go b/server/internal/nbtconv/read.go index 965020b2ae..617509d856 100644 --- a/server/internal/nbtconv/read.go +++ b/server/internal/nbtconv/read.go @@ -38,7 +38,7 @@ func Int32(m map[string]any, k string) int32 { return v } -// Int64 reads an int16 value from a map at key k. +// Int64 reads an int64 value from a map at key k. func Int64(m map[string]any, k string) int64 { v, _ := m[k].(int64) return v diff --git a/server/item/component/component.go b/server/item/component/component.go new file mode 100644 index 0000000000..565c948ac4 --- /dev/null +++ b/server/item/component/component.go @@ -0,0 +1,26 @@ +package component + +//go:generate go run ../../../cmd/generate componentgen -output ../../../server/item/component -vanilla-nbt ../../../server/world/vanilla_items.nbt + +// Component is an item component that can be encoded to NBT for the client. +type Component interface { + // ComponentName returns the namespaced component identifier (e.g., "minecraft:wearable"). + ComponentName() string + // Encode returns the component data as a map for NBT serialization. + Encode() (map[string]any, error) +} + +// ComponentItem is implemented by custom items that provide typed components. +// This replaces the monolithic type-assertion approach in iteminternal. +type ComponentItem interface { + ItemComponents() []Component +} + +// RawComponent is an escape hatch for components not yet modeled in the typed API. +type RawComponent struct { + Name string + Data map[string]any +} + +func (r RawComponent) ComponentName() string { return r.Name } +func (r RawComponent) Encode() (map[string]any, error) { return r.Data, nil } diff --git a/server/item/component/components_gen.go b/server/item/component/components_gen.go new file mode 100644 index 0000000000..07b856f02c --- /dev/null +++ b/server/item/component/components_gen.go @@ -0,0 +1,852 @@ +// Code generated by componentgen. DO NOT EDIT. +package component + +// Wearable represents the minecraft:wearable component. +type Wearable struct { + Slot SlotArmor + Protection int32 + HidesPlayerLocation bool + Dispensable bool +} + +func (w Wearable) ComponentName() string { return "minecraft:wearable" } + +func (w Wearable) Encode() (map[string]any, error) { + data := map[string]any{} + data["slot"] = w.Slot + data["protection"] = w.Protection + data["hides_player_location"] = w.HidesPlayerLocation + data["dispensable"] = w.Dispensable + return data, nil +} + +// SlotArmor is a named string type for the "minecraft:wearable" slot values. +type SlotArmor string + +const ( + SlotArmorHead SlotArmor = "slot.armor.head" + SlotArmorChest SlotArmor = "slot.armor.chest" + SlotArmorLegs SlotArmor = "slot.armor.legs" + SlotArmorFeet SlotArmor = "slot.armor.feet" +) + +// Food represents the minecraft:food component. +type Food struct { + Nutrition int32 + SaturationModifier float32 + CanAlwaysEat bool + UsingConvertsTo string + OnUseAction int32 + CooldownTime int32 + CooldownType string + Effects []FoodEffect + RemoveEffects []int32 +} + +func (f Food) ComponentName() string { return "minecraft:food" } + +func (f Food) Encode() (map[string]any, error) { + data := map[string]any{} + data["nutrition"] = f.Nutrition + data["saturation_modifier"] = f.SaturationModifier + data["can_always_eat"] = f.CanAlwaysEat + if f.UsingConvertsTo != "" { + data["using_converts_to"] = f.UsingConvertsTo + } + if f.OnUseAction != 0 { + data["on_use_action"] = f.OnUseAction + } + if f.CooldownTime != 0 { + data["cooldown_time"] = f.CooldownTime + } + if f.CooldownType != "" { + data["cooldown_type"] = f.CooldownType + } + if len(f.Effects) > 0 { + data["effects"] = encodeSlice(f.Effects, encodeFoodEffect) + } + if len(f.RemoveEffects) > 0 { + data["remove_effects"] = scalarSlice(f.RemoveEffects) + } + return data, nil +} + +// Durability represents the minecraft:durability component. +type Durability struct { + MaxDurability int32 + DamageChance [2]int32 +} + +func (d Durability) ComponentName() string { return "minecraft:durability" } + +func (d Durability) Encode() (map[string]any, error) { + data := map[string]any{} + data["max_durability"] = d.MaxDurability + data["damage_chance"] = rangeIntData(d.DamageChance) + return data, nil +} + +// KineticWeapon represents the minecraft:kinetic_weapon component. +type KineticWeapon struct { + CreativeReach [2]float32 + DamageConditions WeaponConditions + DamageModifier float32 + DamageMultiplier float32 + Delay int32 + DismountConditions WeaponConditions + HitboxMargin float32 + KnockbackConditions WeaponConditions + Reach [2]float32 +} + +func (k KineticWeapon) ComponentName() string { return "minecraft:kinetic_weapon" } + +func (k KineticWeapon) Encode() (map[string]any, error) { + data := map[string]any{} + data["creative_reach"] = rangeData(k.CreativeReach) + data["damage_conditions"] = encodeWeaponConditions(k.DamageConditions) + data["damage_modifier"] = k.DamageModifier + data["damage_multiplier"] = k.DamageMultiplier + data["delay"] = k.Delay + data["dismount_conditions"] = encodeWeaponConditions(k.DismountConditions) + data["hitbox_margin"] = k.HitboxMargin + data["knockback_conditions"] = encodeWeaponConditions(k.KnockbackConditions) + data["reach"] = rangeData(k.Reach) + return data, nil +} + +// UseModifiers represents the minecraft:use_modifiers component. +type UseModifiers struct { + MovementModifier float32 + UseDuration float32 + EmitVibrations bool + StartSound string + StartUsing string +} + +func (u UseModifiers) ComponentName() string { return "minecraft:use_modifiers" } + +func (u UseModifiers) Encode() (map[string]any, error) { + data := map[string]any{} + data["movement_modifier"] = u.MovementModifier + data["use_duration"] = u.UseDuration + data["emit_vibrations"] = u.EmitVibrations + if u.StartSound != "" { + data["start_sound"] = u.StartSound + } + if u.StartUsing != "" { + data["start_using"] = u.StartUsing + } + return data, nil +} + +// StorageItem represents the minecraft:storage_item component. +type StorageItem struct { + AllowNestedStorageItems bool + AllowedItems []string + BannedItems []string + MaxSlots int32 +} + +func (s StorageItem) ComponentName() string { return "minecraft:storage_item" } + +func (s StorageItem) Encode() (map[string]any, error) { + data := map[string]any{} + data["allow_nested_storage_items"] = s.AllowNestedStorageItems + data["allowed_items"] = stringSlice(s.AllowedItems) + data["banned_items"] = bannedItemsData(s.BannedItems) + data["max_slots"] = s.MaxSlots + return data, nil +} + +// Shooter represents the minecraft:shooter component. +type Shooter struct { + Ammunition []Ammunition + ChargeOnDraw bool + MaxDrawDuration float32 + ScalePowerByDrawDuration bool +} + +func (s Shooter) ComponentName() string { return "minecraft:shooter" } + +func (s Shooter) Encode() (map[string]any, error) { + data := map[string]any{} + data["ammunition"] = encodeSlice(s.Ammunition, encodeAmmunition) + data["charge_on_draw"] = s.ChargeOnDraw + data["max_draw_duration"] = s.MaxDrawDuration + data["scale_power_by_draw_duration"] = s.ScalePowerByDrawDuration + return data, nil +} + +// Projectile represents the minecraft:projectile component. +type Projectile struct { + MinimumCriticalPower float32 + ProjectileEntity string +} + +func (p Projectile) ComponentName() string { return "minecraft:projectile" } + +func (p Projectile) Encode() (map[string]any, error) { + data := map[string]any{} + data["minimum_critical_power"] = p.MinimumCriticalPower + data["projectile_entity"] = p.ProjectileEntity + return data, nil +} + +// Throwable represents the minecraft:throwable component. +type Throwable struct { + DoSwingAnimation bool + LaunchPowerScale float32 + MaxDrawDuration float32 + MaxLaunchPower float32 + MinDrawDuration float32 + ScalePowerByDrawDuration bool +} + +func (t Throwable) ComponentName() string { return "minecraft:throwable" } + +func (t Throwable) Encode() (map[string]any, error) { + data := map[string]any{} + data["do_swing_animation"] = t.DoSwingAnimation + if t.LaunchPowerScale != 0 { + data["launch_power_scale"] = t.LaunchPowerScale + } + if t.MaxDrawDuration != 0 { + data["max_draw_duration"] = t.MaxDrawDuration + } + if t.MaxLaunchPower != 0 { + data["max_launch_power"] = t.MaxLaunchPower + } + if t.MinDrawDuration != 0 { + data["min_draw_duration"] = t.MinDrawDuration + } + if t.ScalePowerByDrawDuration != false { + data["scale_power_by_draw_duration"] = t.ScalePowerByDrawDuration + } + return data, nil +} + +// Damage represents the minecraft:damage component. +type Damage struct { + Value float32 +} + +func (d Damage) ComponentName() string { return "minecraft:damage" } + +func (d Damage) Encode() (map[string]any, error) { + data := map[string]any{} + data["value"] = d.Value + return data, nil +} + +// Cooldown represents the minecraft:cooldown component. +type Cooldown struct { + Category string + Duration float32 + Type string +} + +func (c Cooldown) ComponentName() string { return "minecraft:cooldown" } + +func (c Cooldown) Encode() (map[string]any, error) { + data := map[string]any{} + data["category"] = c.Category + data["duration"] = c.Duration + if c.Type != "" { + data["type"] = c.Type + } + return data, nil +} + +// Enchantable represents the minecraft:enchantable component. +type Enchantable struct { + Slot string + Value int32 +} + +func (e Enchantable) ComponentName() string { return "minecraft:enchantable" } + +func (e Enchantable) Encode() (map[string]any, error) { + data := map[string]any{} + data["slot"] = e.Slot + data["value"] = e.Value + return data, nil +} + +// Repairable represents the minecraft:repairable component. +type Repairable struct { + RepairItems []RepairEntry +} + +func (r Repairable) ComponentName() string { return "minecraft:repairable" } + +func (r Repairable) Encode() (map[string]any, error) { + data := map[string]any{} + data["repair_items"] = encodeSlice(r.RepairItems, encodeRepairEntry) + return data, nil +} + +// ItemTags represents the minecraft:item_tags component. +type ItemTags struct { + Tags []string +} + +func (i ItemTags) ComponentName() string { return "minecraft:item_tags" } + +func (i ItemTags) Encode() (map[string]any, error) { + data := map[string]any{} + data["tags"] = stringSlice(i.Tags) + return data, nil +} + +// Tags represents the minecraft:tags component. +type Tags struct { + Tags []string +} + +func (t Tags) ComponentName() string { return "minecraft:tags" } + +func (t Tags) Encode() (map[string]any, error) { + data := map[string]any{} + data["tags"] = stringSlice(t.Tags) + return data, nil +} + +// Seed represents the minecraft:seed component. +type Seed struct { + CropResult string + PlantAt []string + PlantAtAnySolidSurface bool + PlantAtFace string +} + +func (s Seed) ComponentName() string { return "minecraft:seed" } + +func (s Seed) Encode() (map[string]any, error) { + data := map[string]any{} + data["crop_result"] = s.CropResult + data["plant_at"] = stringSlice(s.PlantAt) + data["plant_at_any_solid_surface"] = s.PlantAtAnySolidSurface + data["plant_at_face"] = s.PlantAtFace + return data, nil +} + +// Fuel represents the minecraft:fuel component. +type Fuel struct { + Duration float32 +} + +func (f Fuel) ComponentName() string { return "minecraft:fuel" } + +func (f Fuel) Encode() (map[string]any, error) { + data := map[string]any{} + data["duration"] = f.Duration + return data, nil +} + +// FireResistant represents the minecraft:fire_resistant component. +type FireResistant struct { + Value bool +} + +func (f FireResistant) ComponentName() string { return "minecraft:fire_resistant" } + +func (f FireResistant) Encode() (map[string]any, error) { + data := map[string]any{} + data["value"] = f.Value + return data, nil +} + +// Glint represents the minecraft:glint component. +type Glint struct { + Value bool +} + +func (g Glint) ComponentName() string { return "minecraft:glint" } + +func (g Glint) Encode() (map[string]any, error) { + data := map[string]any{} + data["value"] = g.Value + return data, nil +} + +// SwingDuration represents the minecraft:swing_duration component. +type SwingDuration struct { + Value float32 +} + +func (s SwingDuration) ComponentName() string { return "minecraft:swing_duration" } + +func (s SwingDuration) Encode() (map[string]any, error) { + data := map[string]any{} + data["value"] = s.Value + return data, nil +} + +// SwingSounds represents the minecraft:swing_sounds component. +type SwingSounds struct { + AttackCriticalHit string + AttackHit string + AttackMiss string +} + +func (s SwingSounds) ComponentName() string { return "minecraft:swing_sounds" } + +func (s SwingSounds) Encode() (map[string]any, error) { + data := map[string]any{} + data["attack_critical_hit"] = s.AttackCriticalHit + data["attack_hit"] = s.AttackHit + data["attack_miss"] = s.AttackMiss + return data, nil +} + +// PiercingWeapon represents the minecraft:piercing_weapon component. +type PiercingWeapon struct { + CreativeReach [2]float32 + HitboxMargin float32 + Reach [2]float32 +} + +func (p PiercingWeapon) ComponentName() string { return "minecraft:piercing_weapon" } + +func (p PiercingWeapon) Encode() (map[string]any, error) { + data := map[string]any{} + data["creative_reach"] = rangeData(p.CreativeReach) + data["hitbox_margin"] = p.HitboxMargin + data["reach"] = rangeData(p.Reach) + return data, nil +} + +// Camera represents the minecraft:camera component. +type Camera struct { + BlackBarsDuration float32 + BlackBarsScreenRatio float32 + PictureDuration float32 + ShutterDuration float32 + ShutterScreenRatio float32 + SlideAwayDuration float32 + UseDuration int32 +} + +func (c Camera) ComponentName() string { return "minecraft:camera" } + +func (c Camera) Encode() (map[string]any, error) { + data := map[string]any{} + data["black_bars_duration"] = c.BlackBarsDuration + data["black_bars_screen_ratio"] = c.BlackBarsScreenRatio + data["picture_duration"] = c.PictureDuration + data["shutter_duration"] = c.ShutterDuration + data["shutter_screen_ratio"] = c.ShutterScreenRatio + data["slide_away_duration"] = c.SlideAwayDuration + data["use_duration"] = c.UseDuration + return data, nil +} + +// Block represents the minecraft:block component. +type Block struct { + Value string +} + +func (b Block) ComponentName() string { return "minecraft:block" } + +func (b Block) Encode() (map[string]any, error) { + data := map[string]any{} + data["value"] = b.Value + return data, nil +} + +// BlockPlacer represents the minecraft:block_placer component. +type BlockPlacer struct { + Block string + ReplaceBlockItem string + AlignedPlacement bool + UseOn []string +} + +func (b BlockPlacer) ComponentName() string { return "minecraft:block_placer" } + +func (b BlockPlacer) Encode() (map[string]any, error) { + data := map[string]any{} + data["block"] = b.Block + data["replace_block_item"] = b.ReplaceBlockItem + data["aligned_placement"] = b.AlignedPlacement + if len(b.UseOn) > 0 { + data["use_on"] = stringSlice(b.UseOn) + } + return data, nil +} + +// Compostable represents the minecraft:compostable component. +type Compostable struct { + CompostingChance int32 +} + +func (c Compostable) ComponentName() string { return "minecraft:compostable" } + +func (c Compostable) Encode() (map[string]any, error) { + data := map[string]any{} + data["composting_chance"] = c.CompostingChance + return data, nil +} + +// DamageAbsorption represents the minecraft:damage_absorption component. +type DamageAbsorption struct { + AbsorbableCauses []string +} + +func (d DamageAbsorption) ComponentName() string { return "minecraft:damage_absorption" } + +func (d DamageAbsorption) Encode() (map[string]any, error) { + data := map[string]any{} + data["absorbable_causes"] = stringSlice(d.AbsorbableCauses) + return data, nil +} + +// Digger represents the minecraft:digger component. +type Digger struct { + DestroySpeeds []DestroySpeed + UseEfficiency bool +} + +func (d Digger) ComponentName() string { return "minecraft:digger" } + +func (d Digger) Encode() (map[string]any, error) { + data := map[string]any{} + data["destroy_speeds"] = encodeSlice(d.DestroySpeeds, encodeDestroySpeed) + data["use_efficiency"] = d.UseEfficiency + return data, nil +} + +// DurabilitySensor represents the minecraft:durability_sensor component. +type DurabilitySensor struct { + SoundEvent string + DurabilityThresholds []DurabilityThreshold +} + +func (d DurabilitySensor) ComponentName() string { return "minecraft:durability_sensor" } + +func (d DurabilitySensor) Encode() (map[string]any, error) { + data := map[string]any{} + if d.SoundEvent != "" { + data["sound_event"] = d.SoundEvent + } + if len(d.DurabilityThresholds) > 0 { + data["durability_thresholds"] = encodeSlice(d.DurabilityThresholds, encodeDurabilityThreshold) + } + return data, nil +} + +// Dyeable represents the minecraft:dyeable component. +type Dyeable struct { + DefaultColor [3]int32 +} + +func (d Dyeable) ComponentName() string { return "minecraft:dyeable" } + +func (d Dyeable) Encode() (map[string]any, error) { + data := map[string]any{} + data["default_color"] = list3Data(d.DefaultColor) + return data, nil +} + +// EntityPlacer represents the minecraft:entity_placer component. +type EntityPlacer struct { + Entity string + UseOn []string + DispenseOn []string +} + +func (e EntityPlacer) ComponentName() string { return "minecraft:entity_placer" } + +func (e EntityPlacer) Encode() (map[string]any, error) { + data := map[string]any{} + data["entity"] = e.Entity + if len(e.UseOn) > 0 { + data["use_on"] = stringSlice(e.UseOn) + } + if len(e.DispenseOn) > 0 { + data["dispense_on"] = stringSlice(e.DispenseOn) + } + return data, nil +} + +// HoverTextColor represents the minecraft:hover_text_color component. +type HoverTextColor struct { + Value int32 +} + +func (h HoverTextColor) ComponentName() string { return "minecraft:hover_text_color" } + +func (h HoverTextColor) Encode() (map[string]any, error) { + data := map[string]any{} + data["value"] = h.Value + return data, nil +} + +// InteractButton represents the minecraft:interact_button component. +type InteractButton struct { + Value string +} + +func (i InteractButton) ComponentName() string { return "minecraft:interact_button" } + +func (i InteractButton) Encode() (map[string]any, error) { + data := map[string]any{} + data["value"] = i.Value + return data, nil +} + +// LiquidClipped represents the minecraft:liquid_clipped component. +type LiquidClipped struct { + Value bool +} + +func (l LiquidClipped) ComponentName() string { return "minecraft:liquid_clipped" } + +func (l LiquidClipped) Encode() (map[string]any, error) { + data := map[string]any{} + data["value"] = l.Value + return data, nil +} + +// Rarity represents the minecraft:rarity component. +type Rarity struct { + Value string +} + +func (r Rarity) ComponentName() string { return "minecraft:rarity" } + +func (r Rarity) Encode() (map[string]any, error) { + data := map[string]any{} + data["value"] = r.Value + return data, nil +} + +// Record represents the minecraft:record component. +type Record struct { + ComparatorSignal int32 + Duration float32 + SoundEvent string +} + +func (r Record) ComponentName() string { return "minecraft:record" } + +func (r Record) Encode() (map[string]any, error) { + data := map[string]any{} + data["comparator_signal"] = r.ComparatorSignal + data["duration"] = r.Duration + data["sound_event"] = r.SoundEvent + return data, nil +} + +// ShouldDespawn represents the minecraft:should_despawn component. +type ShouldDespawn struct { + Value bool +} + +func (s ShouldDespawn) ComponentName() string { return "minecraft:should_despawn" } + +func (s ShouldDespawn) Encode() (map[string]any, error) { + data := map[string]any{} + data["value"] = s.Value + return data, nil +} + +// BundleInteraction represents the minecraft:bundle_interaction component. +type BundleInteraction struct { + NumViewableSlots int32 +} + +func (b BundleInteraction) ComponentName() string { return "minecraft:bundle_interaction" } + +func (b BundleInteraction) Encode() (map[string]any, error) { + data := map[string]any{} + data["num_viewable_slots"] = b.NumViewableSlots + return data, nil +} + +// StorageWeightLimit represents the minecraft:storage_weight_limit component. +type StorageWeightLimit struct { + MaxWeightLimit int32 +} + +func (s StorageWeightLimit) ComponentName() string { return "minecraft:storage_weight_limit" } + +func (s StorageWeightLimit) Encode() (map[string]any, error) { + data := map[string]any{} + data["max_weight_limit"] = s.MaxWeightLimit + return data, nil +} + +// StorageWeightModifier represents the minecraft:storage_weight_modifier component. +type StorageWeightModifier struct { + WeightInStorageItem int32 +} + +func (s StorageWeightModifier) ComponentName() string { return "minecraft:storage_weight_modifier" } + +func (s StorageWeightModifier) Encode() (map[string]any, error) { + data := map[string]any{} + data["weight_in_storage_item"] = s.WeightInStorageItem + return data, nil +} + +// Ammunition represents a nested structure used by a component. +type Ammunition struct { + Item string + SearchInventory bool + UseInCreative bool + UseOffHand bool +} + +// DestroySpeed represents a nested structure used by a component. +type DestroySpeed struct { + Block string + Speed float32 +} + +// DurabilityThreshold represents a nested structure used by a component. +type DurabilityThreshold struct { + Durability int32 + ParticleType string + SoundEvent string +} + +// FoodEffect represents a nested structure used by a component. +type FoodEffect struct { + ID int32 + Duration int32 + Amplifier int32 + Chance float32 + Name string +} + +// RepairEntry represents a nested structure used by a component. +type RepairEntry struct { + Items []RepairItemEntry + RepairAmount int32 +} + +// RepairItemEntry represents a nested structure used by a component. +type RepairItemEntry struct { + Name string + Tags []string +} + +// WeaponConditions represents a nested structure used by a component. +type WeaponConditions struct { + MaxDuration int32 + MinSpeed float32 + MinRelativeSpeed float32 +} + +func encodeAmmunition(v Ammunition) map[string]any { + data := map[string]any{} + data["item"] = v.Item + data["search_inventory"] = v.SearchInventory + data["use_in_creative"] = v.UseInCreative + data["use_offhand"] = v.UseOffHand + return data +} + +func encodeDestroySpeed(v DestroySpeed) map[string]any { + data := map[string]any{} + data["block"] = v.Block + data["speed"] = v.Speed + return data +} + +func encodeDurabilityThreshold(v DurabilityThreshold) map[string]any { + data := map[string]any{} + data["durability"] = v.Durability + if v.ParticleType != "" { + data["particle_type"] = v.ParticleType + } + if v.SoundEvent != "" { + data["sound_event"] = v.SoundEvent + } + return data +} + +func encodeFoodEffect(v FoodEffect) map[string]any { + data := map[string]any{} + data["id"] = v.ID + data["duration"] = v.Duration + data["amplifier"] = v.Amplifier + data["chance"] = v.Chance + if v.Name != "" { + data["name"] = v.Name + } + return data +} + +func encodeRepairEntry(v RepairEntry) map[string]any { + data := map[string]any{} + data["items"] = encodeNameOrTags(v.Items) + data["repair_amount"] = v.RepairAmount + return data +} + +func encodeWeaponConditions(v WeaponConditions) map[string]any { + data := map[string]any{} + data["max_duration"] = v.MaxDuration + data["min_speed"] = v.MinSpeed + data["min_relative_speed"] = v.MinRelativeSpeed + return data +} + +func stringSlice(s []string) []any { + r := make([]any, len(s)) + for i, v := range s { + r[i] = v + } + return r +} + +func scalarSlice[T any](s []T) []any { + r := make([]any, len(s)) + for i, v := range s { + r[i] = v + } + return r +} + +func encodeSlice[V any](vs []V, enc func(V) map[string]any) []any { + r := make([]any, len(vs)) + for i, v := range vs { + r[i] = enc(v) + } + return r +} + +func rangeData(r [2]float32) map[string]any { + return map[string]any{"min": r[0], "max": r[1]} +} + +func rangeIntData(r [2]int32) map[string]any { + return map[string]any{"min": r[0], "max": r[1]} +} + +func list3Data(c [3]int32) []any { + return []any{c[0], c[1], c[2]} +} + +func bannedItemsData(items []string) []any { + banned := make([]any, len(items)) + for i, b := range items { + banned[i] = map[string]any{"name": b} + } + return banned +} + +func encodeNameOrTags(items []RepairItemEntry) []any { + r := make([]any, len(items)) + for i, item := range items { + if item.Name != "" { + r[i] = map[string]any{"name": item.Name} + } else { + r[i] = map[string]any{"tags": stringSlice(item.Tags)} + } + } + return r +} diff --git a/server/item/component/constants.go b/server/item/component/constants.go new file mode 100644 index 0000000000..f34966f5a8 --- /dev/null +++ b/server/item/component/constants.go @@ -0,0 +1,46 @@ +// Code generated by componentgen. DO NOT EDIT. +package component + +// Component name constants +const ( + ComponentWearable = "minecraft:wearable" + ComponentFood = "minecraft:food" + ComponentDurability = "minecraft:durability" + ComponentKineticWeapon = "minecraft:kinetic_weapon" + ComponentUseModifiers = "minecraft:use_modifiers" + ComponentStorageItem = "minecraft:storage_item" + ComponentShooter = "minecraft:shooter" + ComponentProjectile = "minecraft:projectile" + ComponentThrowable = "minecraft:throwable" + ComponentDamage = "minecraft:damage" + ComponentCooldown = "minecraft:cooldown" + ComponentEnchantable = "minecraft:enchantable" + ComponentRepairable = "minecraft:repairable" + ComponentItemTags = "minecraft:item_tags" + ComponentTags = "minecraft:tags" + ComponentSeed = "minecraft:seed" + ComponentFuel = "minecraft:fuel" + ComponentFireResistant = "minecraft:fire_resistant" + ComponentGlint = "minecraft:glint" + ComponentSwingDuration = "minecraft:swing_duration" + ComponentSwingSounds = "minecraft:swing_sounds" + ComponentPiercingWeapon = "minecraft:piercing_weapon" + ComponentCamera = "minecraft:camera" + ComponentBlock = "minecraft:block" + ComponentBlockPlacer = "minecraft:block_placer" + ComponentCompostable = "minecraft:compostable" + ComponentDamageAbsorption = "minecraft:damage_absorption" + ComponentDigger = "minecraft:digger" + ComponentDurabilitySensor = "minecraft:durability_sensor" + ComponentDyeable = "minecraft:dyeable" + ComponentEntityPlacer = "minecraft:entity_placer" + ComponentHoverTextColor = "minecraft:hover_text_color" + ComponentInteractButton = "minecraft:interact_button" + ComponentLiquidClipped = "minecraft:liquid_clipped" + ComponentRarity = "minecraft:rarity" + ComponentRecord = "minecraft:record" + ComponentShouldDespawn = "minecraft:should_despawn" + ComponentBundleInteraction = "minecraft:bundle_interaction" + ComponentStorageWeightLimit = "minecraft:storage_weight_limit" + ComponentStorageWeightModifier = "minecraft:storage_weight_modifier" +) diff --git a/server/item/component/example_test.go b/server/item/component/example_test.go new file mode 100644 index 0000000000..f4269c8d7e --- /dev/null +++ b/server/item/component/example_test.go @@ -0,0 +1,65 @@ +package component_test + +import ( + "fmt" + "image" + + "github.com/df-mc/dragonfly/server/item/category" + "github.com/df-mc/dragonfly/server/item/component" + "github.com/df-mc/dragonfly/server/world" +) + +var _ world.CustomItem = TestHelmet{} +var _ component.ComponentItem = TestHelmet{} + +// TestHelmet is a custom item that demonstrates using the generated typed component +// structs via component.ComponentItem, with the SlotArmor named string type. +type TestHelmet struct{} + +func (TestHelmet) EncodeItem() (name string, meta int16) { + return "test:helmet", 0 +} + +func (TestHelmet) Name() string { + return "Test Helmet" +} + +func (TestHelmet) Texture() image.Image { + return image.NewRGBA(image.Rect(0, 0, 16, 16)) +} + +func (TestHelmet) Category() category.Category { + return category.Equipment() +} + +func (TestHelmet) ItemComponents() []component.Component { + return []component.Component{ + component.Wearable{ + Slot: component.SlotArmorHead, + Protection: 3, + HidesPlayerLocation: false, + Dispensable: true, + }, + component.Durability{ + MaxDurability: 200, + DamageChance: [2]int32{1, 100}, + }, + component.DamageAbsorption{ + AbsorbableCauses: []string{"fall", "explosion"}, + }, + } +} + +func ExampleWearable() { + h := TestHelmet{} + for _, c := range h.ItemComponents() { + if c.ComponentName() != "minecraft:wearable" { + continue + } + data, _ := c.Encode() + fmt.Printf("slot=%v\n", data["slot"]) + break + } + // Output: + // slot=slot.armor.head +} diff --git a/server/item/component/validation.go b/server/item/component/validation.go new file mode 100644 index 0000000000..2182658d7b --- /dev/null +++ b/server/item/component/validation.go @@ -0,0 +1,41 @@ +// Code generated by componentgen. DO NOT EDIT. +package component + +import "fmt" + +// ValidationRules contains cross-component validation rules derived from BDS. +var ValidationRules = map[string][]string{ + "minecraft:kinetic_weapon": {"minecraft:use_modifiers"}, + "minecraft:bundle_interaction": {"minecraft:storage_item"}, + "minecraft:shooter": {"minecraft:use_modifiers"}, +} + +// ValidateComponents checks cross-component constraints. +func ValidateComponents(components map[string]any) error { + // kinetic_weapon requires use_modifiers + if _, ok := components["minecraft:kinetic_weapon"]; ok { + if _, ok := components["minecraft:use_modifiers"]; !ok { + return fmt.Errorf("minecraft:kinetic_weapon requires minecraft:use_modifiers component") + } + } + + // bundle_interaction requires storage_item + if _, ok := components["minecraft:bundle_interaction"]; ok { + if _, ok := components["minecraft:storage_item"]; !ok { + return fmt.Errorf("minecraft:bundle_interaction requires minecraft:storage_item component") + } + } + + // shooter requires use_modifiers with non-zero use_duration + if _, ok := components["minecraft:shooter"]; ok { + if um, ok := components["minecraft:use_modifiers"].(map[string]any); ok { + if ud, ok := um["use_duration"].(float32); !ok || ud == 0 { + return fmt.Errorf("minecraft:shooter requires non-zero use_duration in minecraft:use_modifiers") + } + } else { + return fmt.Errorf("minecraft:shooter requires minecraft:use_modifiers component with non-zero use_duration") + } + } + + return nil +} diff --git a/server/server.go b/server/server.go index 574515bc58..819718bc9c 100644 --- a/server/server.go +++ b/server/server.go @@ -92,7 +92,7 @@ func New() *Server { // Listen starts running the server's listeners. Connections will be accepted // until the listeners are closed using a call to Close. Once Listen is called, // players may be accepted using Server.Accept(). -func (srv *Server) Listen() { +func (srv *Server) Listen() error { t := time.Now() if !srv.started.CompareAndSwap(nil, &t) { panic("start server: already started") @@ -110,8 +110,11 @@ func (srv *Server) Listen() { } srv.conf.Log.Info("Dragonfly server started.", "mc-version", protocol.CurrentVersion, "go-version", info.GoVersion, "commit", revision) - srv.startListening() + if err := srv.startListening(); err != nil { + return err + } go srv.wait() + return nil } // Accept accepts incoming players into the server, returning an iterator that @@ -381,15 +384,18 @@ func (srv *Server) listen(l Listener) { // startListening starts making the EncodeBlock listener listen, accepting new // connections from players. -func (srv *Server) startListening() { +func (srv *Server) startListening() error { srv.makeBlockEntries() - srv.makeItemComponents() + if err := srv.makeItemComponents(); err != nil { + return err + } srv.makeDimensionData() srv.wg.Add(len(srv.listeners)) for _, l := range srv.listeners { go srv.listen(l) } + return nil } // makeBlockEntries initialises the server's block components map using the @@ -411,7 +417,7 @@ func (srv *Server) makeBlockEntries() { // makeItemComponents initialises the server's item components map using the // registered custom items. It allows item components to be created only once // at startup -func (srv *Server) makeItemComponents() { +func (srv *Server) makeItemComponents() error { custom := world.CustomItems() srv.customItems = make([]protocol.ItemEntry, len(custom)) @@ -423,14 +429,21 @@ func (srv *Server) makeItemComponents() { if isCustomBlock { entryVersion = protocol.ItemEntryVersionNone } + + components, err := iteminternal.Components(it) + if err != nil { + return fmt.Errorf("failed to register components for %s: %w", name, err) + } + srv.customItems[i] = protocol.ItemEntry{ Name: name, ComponentBased: !isCustomBlock, RuntimeID: int16(rid), Version: entryVersion, - Data: iteminternal.Components(it), + Data: components, } } + return nil } // makeDimensionData initialises the server's custom dimensions list.