diff --git a/config.toml b/config.toml index d2cd5ea61..b6bace6c3 100644 --- a/config.toml +++ b/config.toml @@ -8,23 +8,16 @@ # The name as it shows up in the server list. Minecraft colour codes may be used in this name to format the # name of the server. Name = "Dragonfly Server" - # The message shown to players when the server is shutting down. The message may be left empty to direct - # players to the server list directly. - ShutdownMessage = "Server closed." - # AuthEnabled controls whether or not players must be connected to Xbox Live in order to join the server. + # AuthEnabled controls whether players must be connected to Xbox Live in order to join the server. AuthEnabled = true - # JoinMessage is the message that appears when a player joins the server. Leave this empty to disable it. - # %v is the placeholder for the username of the player. Set this to "" to disable. - JoinMessage = "%v has joined the game" - # QuitMessage is the message that appears when a player leaves the server. Leave this empty to disable it. - # %v is the placeholder for the username of the player. Set this to "" to disable. - QuitMessage = "%v has left the game" + # DisableJoinQuitMessages specifies if join/quit messages should be broadcast when players join the server. + DisableJoinQuitMessages = false [World] # The folder that the world files (will) reside in, relative to the working directory. If not currently # present, the folder will be made. Folder = "world" - # Whether or not the worlds' data will be saved and loaded. If true, the server will use the + # Whether the worlds' data will be saved and loaded. If true, the server will use the # default LevelDB data provider and if false, an empty provider will be used. To use your # own provider, turn this value to false, as you will still be able to pass your own provider. SaveData = true @@ -36,7 +29,7 @@ # The maximum chunk radius that players may set in their settings. If they try to set it above this number, # it will be capped and set to the max. MaximumChunkRadius = 32 - # Whether or not a player's data will be saved and loaded. If true, the server will use the + # Whether a player's data will be saved and loaded. If true, the server will use the # default LevelDB data provider and if false, an empty provider will be used. To use your # own provider, turn this value to false, as you will still be able to pass your own provider. SaveData = true @@ -49,5 +42,5 @@ AutoBuildPack = true # Folder configures the directory used by the server to load resource packs. Folder = "resources" - # Required configures whether or not the server will require players to have a resource pack to join. + # Required configures whether the server will require players to have a resource pack to join. Required = true diff --git a/server/cmd/argument.go b/server/cmd/argument.go index 7eb515614..1d97b0e42 100644 --- a/server/cmd/argument.go +++ b/server/cmd/argument.go @@ -4,10 +4,12 @@ import ( "errors" "fmt" "github.com/df-mc/dragonfly/server/internal/sliceutil" + "github.com/df-mc/dragonfly/server/player/chat" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" "math/rand" "reflect" + "slices" "sort" "strconv" "strings" @@ -17,9 +19,22 @@ import ( // command. It is a convenience wrapper around a string slice. type Line struct { args []string + seen []string src Source } +// SyntaxError returns a translated syntax error. +func (line *Line) SyntaxError() error { + if len(line.args) == 0 { + return chat.MessageCommandSyntax.F(line.seen, "", "") + } + next := strings.Join(line.args[1:], " ") + if next != "" { + next = " " + next + } + return chat.MessageCommandSyntax.F(strings.Join(line.seen, " ")+" ", line.args[0], next) +} + // Next reads the next argument from the command line and returns it. If there were no more arguments to // consume, false is returned. func (line *Line) Next() (string, bool) { @@ -51,6 +66,7 @@ func (line *Line) RemoveN(n int) { line.args = nil return } + line.seen = append(line.seen, line.args[:n]...) line.args = line.args[n:] } @@ -77,6 +93,13 @@ type parser struct { func (p parser) parseArgument(line *Line, v reflect.Value, optional bool, name string, source Source, tx *world.Tx) (error, bool) { var err error i := v.Interface() + if line.Len() == 0 && optional { + // The command run didn't have enough arguments for this parameter, but + // it was optional, so it does not matter. Make sure to clear the value + // though. + v.Set(reflect.Zero(v.Type())) + return nil, false + } switch i.(type) { case int, int8, int16, int32, int64: err = p.int(line, v) @@ -110,11 +133,6 @@ func (p parser) parseArgument(line *Line, v reflect.Value, optional bool, name s if err == nil { // The argument was parsed successfully, so it needs to be removed from the command line. line.RemoveNext() - } else if errors.Is(err, ErrInsufficientArgs) && optional { - // The command ran didn't have enough arguments for this parameter, but it was optional, so it does - // not matter. Make sure to clear the value though. - v.Set(reflect.Zero(v.Type())) - return nil, false } return err, err == nil } @@ -127,11 +145,11 @@ var ErrInsufficientArgs = errors.New("not enough arguments for command") func (p parser) int(line *Line, v reflect.Value) error { arg, ok := line.Next() if !ok { - return ErrInsufficientArgs + return line.SyntaxError() } value, err := strconv.ParseInt(arg, 10, v.Type().Bits()) if err != nil { - return fmt.Errorf(`cannot parse argument "%v" as type %v for argument "%v"`, arg, v.Kind(), p.currentField) + return line.SyntaxError() } v.SetInt(value) return nil @@ -141,11 +159,11 @@ func (p parser) int(line *Line, v reflect.Value) error { func (p parser) uint(line *Line, v reflect.Value) error { arg, ok := line.Next() if !ok { - return ErrInsufficientArgs + return line.SyntaxError() } value, err := strconv.ParseUint(arg, 10, v.Type().Bits()) if err != nil { - return fmt.Errorf(`cannot parse argument "%v" as type %v for argument "%v"`, arg, v.Kind(), p.currentField) + return line.SyntaxError() } v.SetUint(value) return nil @@ -155,11 +173,11 @@ func (p parser) uint(line *Line, v reflect.Value) error { func (p parser) float(line *Line, v reflect.Value) error { arg, ok := line.Next() if !ok { - return ErrInsufficientArgs + return line.SyntaxError() } value, err := strconv.ParseFloat(arg, v.Type().Bits()) if err != nil { - return fmt.Errorf(`cannot parse argument "%v" as type %v for argument "%v"`, arg, v.Kind(), p.currentField) + return line.SyntaxError() } v.SetFloat(value) return nil @@ -169,7 +187,7 @@ func (p parser) float(line *Line, v reflect.Value) error { func (p parser) string(line *Line, v reflect.Value) error { arg, ok := line.Next() if !ok { - return ErrInsufficientArgs + return line.SyntaxError() } v.SetString(arg) return nil @@ -179,11 +197,11 @@ func (p parser) string(line *Line, v reflect.Value) error { func (p parser) bool(line *Line, v reflect.Value) error { arg, ok := line.Next() if !ok { - return ErrInsufficientArgs + return line.SyntaxError() } value, err := strconv.ParseBool(arg) if err != nil { - return fmt.Errorf(`cannot parse argument "%v" as type bool for argument "%v"`, arg, p.currentField) + return line.SyntaxError() } v.SetBool(value) return nil @@ -193,18 +211,16 @@ func (p parser) bool(line *Line, v reflect.Value) error { func (p parser) enum(line *Line, val reflect.Value, v Enum, source Source) error { arg, ok := line.Next() if !ok { - return ErrInsufficientArgs - } - found := "" - for _, option := range v.Options(source) { - if strings.EqualFold(option, arg) { - found = option - } + return line.SyntaxError() } - if found == "" { - return fmt.Errorf(`invalid argument "%v" for enum parameter "%v"`, arg, v.Type()) + opts := v.Options(source) + ind := slices.IndexFunc(opts, func(s string) bool { + return strings.EqualFold(s, arg) + }) + if ind < 0 { + return line.SyntaxError() } - val.SetString(found) + val.SetString(opts[ind]) return nil } @@ -212,12 +228,12 @@ func (p parser) enum(line *Line, val reflect.Value, v Enum, source Source) error func (p parser) sub(line *Line, name string) error { arg, ok := line.Next() if !ok { - return ErrInsufficientArgs + return line.SyntaxError() } if strings.EqualFold(name, arg) { return nil } - return fmt.Errorf(`invalid argument "%v" for sub command "%v"`, arg, name) + return line.SyntaxError() } // vec3 ... @@ -246,7 +262,7 @@ func (p parser) targets(line *Line, v reflect.Value, tx *world.Tx) error { return err } if len(targets) == 0 { - return fmt.Errorf("no targets found") + return chat.MessageCommandNoTargets.F() } v.Set(reflect.ValueOf(targets)) return nil @@ -257,7 +273,7 @@ func (p parser) parseTargets(line *Line, tx *world.Tx) ([]Target, error) { entities, players := targets(tx) first, ok := line.Next() if !ok { - return nil, ErrInsufficientArgs + return nil, line.SyntaxError() } switch first { case "@p": @@ -285,21 +301,27 @@ func (p parser) parseTargets(line *Line, tx *world.Tx) ([]Target, error) { } return []Target{players[rand.Intn(len(players))]}, nil default: - target, err := p.parsePlayer(players, first) - return []Target{target}, err + target, ok := p.parsePlayer(line, players) + if ok { + return []Target{target}, nil + } + return nil, nil } } -// parsePlayer parses one Player from the Line, reading more arguments if necessary to find a valid player -// from the players Target list. -func (p parser) parsePlayer(players []NamedTarget, name string) (Target, error) { - for _, p := range players { - if strings.EqualFold(p.Name(), name) { - // We found a match for this amount of arguments. Following arguments may still be a better - // match though (subset in the name, such as 'Hello' vs 'Hello World' as name), so keep going - // until we saturate the command line or pass 15 characters. - return p, nil +// parsePlayer parses one Player from the Line, consuming multiple arguments +// from Line if necessary. +func (p parser) parsePlayer(line *Line, players []NamedTarget) (Target, bool) { + name := "" + for i := 0; i < line.Len(); i++ { + name += line.args[0] + if ind := slices.IndexFunc(players, func(target NamedTarget) bool { + return strings.EqualFold(target.Name(), name) + }); ind != -1 { + return players[ind], true } + name += " " + line.RemoveNext() } - return nil, fmt.Errorf("player with name '%v' not found", name) + return nil, false } diff --git a/server/cmd/command.go b/server/cmd/command.go index af1da1d14..a7d46a374 100644 --- a/server/cmd/command.go +++ b/server/cmd/command.go @@ -3,6 +3,7 @@ package cmd import ( "encoding/csv" "fmt" + "github.com/df-mc/dragonfly/server/player/chat" "github.com/df-mc/dragonfly/server/world" "go/ast" "reflect" @@ -221,24 +222,24 @@ func (cmd Command) String() string { // leftover command line. func (cmd Command) executeRunnable(v reflect.Value, args string, source Source, output *Output, tx *world.Tx) (*Line, error) { if a, ok := v.Interface().(Allower); ok && !a.Allow(source) { - //lint:ignore ST1005 Error string is capitalised because it is shown to the player. - //goland:noinspection GoErrorStringFormat - return nil, fmt.Errorf("You cannot execute this command.") + return nil, chat.MessageCommandUnknown.F(cmd.name) } var argFrags []string if args != "" { r := csv.NewReader(strings.NewReader(args)) - r.Comma = ' ' - r.LazyQuotes = true + r.Comma, r.LazyQuotes = ' ', true record, err := r.Read() if err != nil { - return nil, fmt.Errorf("error parsing command string: %w", err) + // When LazyQuotes is enabled, this really never appears to return + // an error when we read only one line. Just in case it does though, + // we return the command usage. + return nil, chat.MessageCommandUsage.F(cmd.Usage()) } argFrags = record } parser := parser{} - arguments := &Line{args: argFrags, src: source} + arguments := &Line{args: argFrags, src: source, seen: []string{"/" + cmd.name}} // We iterate over all the fields of the struct: Each of the fields will have an argument parsed to // produce its value. @@ -255,7 +256,8 @@ func (cmd Command) executeRunnable(v reflect.Value, args string, source Source, err, success := parser.parseArgument(arguments, val, opt, name(t), source, tx) if err != nil { - // Parsing was not successful, we return immediately as we don't need to call the Runnable. + // Parsing was not successful, we return immediately as we don't + // need to call the Runnable. return arguments, err } if success && opt { @@ -263,7 +265,7 @@ func (cmd Command) executeRunnable(v reflect.Value, args string, source Source, } } if arguments.Len() != 0 { - return arguments, fmt.Errorf("unexpected '%v'", strings.Join(arguments.args, " ")) + return arguments, arguments.SyntaxError() } v.Interface().(Runnable).Run(source, output, tx) diff --git a/server/cmd/output.go b/server/cmd/output.go index cc8be47b2..cf5bed416 100644 --- a/server/cmd/output.go +++ b/server/cmd/output.go @@ -3,13 +3,14 @@ package cmd import ( "errors" "fmt" + "github.com/df-mc/dragonfly/server/player/chat" ) -// Output holds the output of a command execution. It holds success messages and error messages, which the -// source of a command execution gets sent. +// Output holds the output of a command execution. It holds success messages +// and error messages, which the source of a command execution gets sent. type Output struct { errors []error - messages []string + messages []fmt.Stringer } // Errorf formats an error message and adds it to the command output. @@ -19,21 +20,40 @@ func (o *Output) Errorf(format string, a ...any) { // Error formats an error message and adds it to the command output. func (o *Output) Error(a ...any) { + if len(a) == 1 { + if err, ok := a[0].(error); ok { + o.errors = append(o.errors, err) + return + } + } o.errors = append(o.errors, errors.New(fmt.Sprint(a...))) } +// Errort adds a translation as an error message and parameterises it using the +// arguments passed. Errort panics if the number of arguments is incorrect. +func (o *Output) Errort(t chat.Translation, a ...any) { + o.errors = append(o.errors, t.F(a...)) +} + // Printf formats a (success) message and adds it to the command output. func (o *Output) Printf(format string, a ...any) { - o.messages = append(o.messages, fmt.Sprintf(format, a...)) + o.messages = append(o.messages, stringer(fmt.Sprintf(format, a...))) } // Print formats a (success) message and adds it to the command output. func (o *Output) Print(a ...any) { - o.messages = append(o.messages, fmt.Sprint(a...)) + o.messages = append(o.messages, stringer(fmt.Sprint(a...))) +} + +// Printt adds a translation as a (success) message and parameterises it using +// the arguments passed. Printt panics if the number of arguments is incorrect. +func (o *Output) Printt(t chat.Translation, a ...any) { + o.messages = append(o.messages, t.F(a...)) } -// Errors returns a list of all errors added to the command output. Usually only one error message is set: -// After one error message, execution of a command typically terminates. +// Errors returns a list of all errors added to the command output. Usually +// only one error message is set: After one error message, execution of a +// command typically terminates. func (o *Output) Errors() []error { return o.errors } @@ -43,13 +63,18 @@ func (o *Output) ErrorCount() int { return len(o.errors) } -// Messages returns a list of all messages added to the command output. The amount of messages present depends -// on the command called. -func (o *Output) Messages() []string { +// Messages returns a list of all messages added to the command output. The +// amount of messages present depends on the command called. +func (o *Output) Messages() []fmt.Stringer { return o.messages } -// MessageCount returns the count of (success) messages that the command output has. +// MessageCount returns the count of (success) messages that the command output +// has. func (o *Output) MessageCount() int { return len(o.messages) } + +type stringer string + +func (s stringer) String() string { return string(s) } diff --git a/server/conf.go b/server/conf.go index 77bd2e733..f0e1c027d 100644 --- a/server/conf.go +++ b/server/conf.go @@ -6,6 +6,7 @@ import ( "github.com/df-mc/dragonfly/server/entity" "github.com/df-mc/dragonfly/server/internal/packbuilder" "github.com/df-mc/dragonfly/server/player" + "github.com/df-mc/dragonfly/server/player/chat" "github.com/df-mc/dragonfly/server/player/playerdb" "github.com/df-mc/dragonfly/server/world" "github.com/df-mc/dragonfly/server/world/biome" @@ -64,10 +65,11 @@ type Config struct { MaxChunkRadius int // JoinMessage, QuitMessage and ShutdownMessage are the messages to send for // when a player joins or quits the server and when the server shuts down, - // kicking all online players. JoinMessage and QuitMessage may have a '%v' - // argument, which will be replaced with the name of the player joining or - // quitting. - JoinMessage, QuitMessage, ShutdownMessage string + // kicking all online players. If set, JoinMessage and QuitMessage must have + // exactly 1 argument, which will be replaced with the name of the player + // joining or quitting. + // ShutdownMessage is set to chat.MessageServerDisconnect if empty. + JoinMessage, QuitMessage, ShutdownMessage chat.Translation // StatusProvider provides the server status shown to players in the server // list. By default, StatusProvider will show the server name from the Name // field and the current player count and maximum players. @@ -133,6 +135,9 @@ func (conf Config) New() *Server { if conf.MaxChunkRadius == 0 { conf.MaxChunkRadius = 12 } + if conf.ShutdownMessage.Zero() { + conf.ShutdownMessage = chat.MessageServerDisconnect + } if len(conf.Entities.Types()) == 0 { conf.Entities = entity.DefaultRegistry } @@ -184,21 +189,12 @@ type UserConfig struct { Server struct { // Name is the name of the server as it shows up in the server list. Name string - // ShutdownMessage is the message shown to players when the server shuts - // down. If empty, players will be directed to the menu screen right - // away. - ShutdownMessage string // AuthEnabled controls whether players must be connected to Xbox Live // in order to join the server. AuthEnabled bool - // JoinMessage is the message that appears when a player joins the - // server. Leave this empty to disable it. %v is the placeholder for the - // username of the player - JoinMessage string - // QuitMessage is the message that appears when a player leaves the - // server. Leave this empty to disable it. %v is the placeholder for the - // username of the player - QuitMessage string + // DisableJoinQuitMessages specifies if default join and quit messages + // for players should be disabled. + DisableJoinQuitMessages bool } World struct { // SaveData controls whether a world's data will be saved and loaded. @@ -254,11 +250,11 @@ func (uc UserConfig) Config(log *slog.Logger) (Config, error) { AuthDisabled: !uc.Server.AuthEnabled, MaxPlayers: uc.Players.MaxCount, MaxChunkRadius: uc.Players.MaximumChunkRadius, - JoinMessage: uc.Server.JoinMessage, - QuitMessage: uc.Server.QuitMessage, - ShutdownMessage: uc.Server.ShutdownMessage, DisableResourceBuilding: !uc.Resources.AutoBuildPack, } + if !uc.Server.DisableJoinQuitMessages { + conf.JoinMessage, conf.QuitMessage = chat.MessageJoin, chat.MessageQuit + } if uc.World.SaveData { conf.WorldProvider, err = mcdb.Config{Log: log}.Open(uc.World.Folder) if err != nil { @@ -317,10 +313,7 @@ func DefaultConfig() UserConfig { c := UserConfig{} c.Network.Address = ":19132" c.Server.Name = "Dragonfly Server" - c.Server.ShutdownMessage = "Server closed." c.Server.AuthEnabled = true - c.Server.JoinMessage = "%v has joined the game" - c.Server.QuitMessage = "%v has left the game" c.World.SaveData = true c.World.Folder = "world" c.Players.MaximumChunkRadius = 32 diff --git a/server/player/chat/chat.go b/server/player/chat/chat.go index 867948790..fb6bf5269 100644 --- a/server/player/chat/chat.go +++ b/server/player/chat/chat.go @@ -40,6 +40,22 @@ func (chat *Chat) WriteString(s string) (n int, err error) { return len(s), nil } +// Writet writes a Translation message to a Chat, parameterising the message +// using the arguments passed. Messages are translated according to the locale +// of subscribers if they implement Translator. Subscribers that do not +// implement Translator have the fallback message sent. +func (chat *Chat) Writet(t Translation, a ...any) { + chat.m.Lock() + defer chat.m.Unlock() + for _, subscriber := range chat.subscribers { + if translator, ok := subscriber.(Translator); ok { + translator.Messaget(t, a...) + continue + } + subscriber.Message(t.F(a...).String()) + } +} + // Subscribe adds a subscriber to the chat, sending it every message written to // the chat. In order to remove it again, use Chat.Unsubscribe(). func (chat *Chat) Subscribe(s Subscriber) { diff --git a/server/player/chat/subscriber.go b/server/player/chat/subscriber.go index c68a91a15..a45c02546 100644 --- a/server/player/chat/subscriber.go +++ b/server/player/chat/subscriber.go @@ -17,6 +17,14 @@ type Subscriber interface { Message(a ...any) } +// Translator is a Subscriber that is able to translate messages to their own +// locale. +type Translator interface { + // Messaget sends a Translation message to the Translator, using the + // arguments passed to fill out any translation parameters. + Messaget(t Translation, a ...any) +} + // StdoutSubscriber is an implementation of Subscriber that forwards messages // sent to the chat to the stdout. type StdoutSubscriber struct{} diff --git a/server/player/chat/translate.go b/server/player/chat/translate.go new file mode 100644 index 000000000..4beae2c16 --- /dev/null +++ b/server/player/chat/translate.go @@ -0,0 +1,119 @@ +package chat + +import ( + "fmt" + "github.com/sandertv/gophertunnel/minecraft/text" + "golang.org/x/text/language" +) + +// https://github.com/Mojang/bedrock-samples/blob/main/resource_pack/texts/en_GB.lang + +var MessageJoin = Translate(str("%multiplayer.player.joined"), 1, `%v joined the game`).Enc("%v") +var MessageQuit = Translate(str("%multiplayer.player.left"), 1, `%v left the game`).Enc("%v") +var MessageServerDisconnect = Translate(str("%disconnect.disconnected"), 0, `Disconnected by Server`).Enc("%v") + +var MessageCommandSyntax = Translate(str("%commands.generic.syntax"), 3, `Syntax error: unexpected value: at "%v>>%v<<%v"`) +var MessageCommandUsage = Translate(str("%commands.generic.usage"), 1, `Usage: %v`) +var MessageCommandUnknown = Translate(str("%commands.generic.unknown"), 1, `Unknown command: "%v": Please check that the command exists and that you have permission to use it.`) +var MessageCommandNoTargets = Translate(str("%commands.generic.noTargetMatch"), 0, `No targets matched selector`) + +type str string + +// Resolve returns the translation identifier as a string. +func (s str) Resolve(language.Tag) string { return string(s) } + +// TranslationString is a value that can resolve a translated version of itself +// for a language.Tag passed. +type TranslationString interface { + // Resolve finds a suitable translated version for a translation string for + // a specific language.Tag. + Resolve(l language.Tag) string +} + +// Translate returns a Translation for a TranslationString. The required number +// of parameters specifies how many arguments may be passed to Translation.F. +// The fallback string should be a 'standard' translation of the string, which +// is used when translation.String is called on the translation that results +// from a call to Translation.F. This fallback string should have as many +// formatting identifiers (like in fmt.Sprintf) as the number of params. +func Translate(str TranslationString, params int, fallback string) Translation { + return Translation{str: str, params: params, fallback: fallback, format: "%v"} +} + +// Translation represents a TranslationString with additional formatting, that +// may be filled out by calling F on it with a list of arguments for the +// translation. +type Translation struct { + str TranslationString + format string + params int + fallback string +} + +// Zero returns false if a Translation was not created using Translate or +// Untranslated. +func (t Translation) Zero() bool { + return t.format == "" +} + +// Enc encapsulates the translation string into the format passed. This format +// should have exactly one formatting identifier, %v, to specify where the +// translation string should go, such as 'translation: %v'. +// Enc accepts colouring formats parsed by text.Colourf. +func (t Translation) Enc(format string) Translation { + t.format = format + return t +} + +// Resolve passes 0 arguments to the translation and resolves the translation +// string for the language passed. It is equal to calling t.F().Resolve(l). +// Resolve panics if the Translation requires at least 1 argument. +func (t Translation) Resolve(l language.Tag) string { + return t.F().Resolve(l) +} + +// F takes arguments for a translation string passed and returns a filled out +// translation that may be sent to players. The number of arguments passed must +// be exactly equal to the number specified in Translate. If not, F will panic. +func (t Translation) F(a ...any) translation { + if len(a) != t.params { + panic(fmt.Sprintf("translation '%v' requires exactly %v parameters, got %v", t.format, t.params, len(a))) + } + params := make([]string, len(a)) + for i, arg := range a { + params[i] = fmt.Sprint(arg) + } + return translation{t: t, params: params, fallbackParams: a} +} + +// translation is a translation string with its arguments filled out. Resolve may +// be called to obtain the translated version of the translation string and +// Params may be called to obtain the parameters passed in Translation.F. +// translation implements the fmt.Stringer and error interfaces. +type translation struct { + t Translation + params []string + fallbackParams []any +} + +// Resolve translates the TranslationString of the translation to the language +// passed and returns it. +func (t translation) Resolve(l language.Tag) string { + return text.Colourf(t.t.format, t.t.str.Resolve(l)) +} + +// Params returns a slice of values that are used to parameterise the +// translation returned by Resolve. +func (t translation) Params() []string { + return t.params +} + +// String formats and returns the fallback value of the translation. +func (t translation) String() string { + return fmt.Sprintf(text.Colourf(t.t.format, t.t.fallback), t.fallbackParams...) +} + +// Error formats and returns the fallback value of the translation. +func (t translation) Error() string { + return t.String() +} diff --git a/server/player/player.go b/server/player/player.go index 8bad13a09..bbfb003ba 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -223,6 +223,13 @@ func (p *Player) Messagef(f string, a ...any) { p.session().SendMessage(fmt.Sprintf(f, a...)) } +// Messaget sends a translatable message to a player and parameterises it using +// the arguments passed. Messaget panics if an incorrect amount of arguments +// is passed. +func (p *Player) Messaget(t chat.Translation, a ...any) { + p.session().SendTranslation(t, p.locale, a) +} + // SendPopup sends a formatted popup to the player. The popup is shown above the hotbar of the player and // overwrites/is overwritten by the name of the item equipped. // The popup is formatted following the rules of fmt.Sprintln without a newline at the end. @@ -323,7 +330,7 @@ func (p *Player) ExecuteCommand(commandLine string) { command, ok := cmd.ByAlias(args[0][1:]) if !ok { o := &cmd.Output{} - o.Errorf("Unknown command: %v. Please check that the command exists and that you have permission to use it.", args[0]) + o.Errort(chat.MessageCommandUnknown, args[0]) p.SendCommandOutput(o) return } @@ -352,7 +359,7 @@ func (p *Player) Transfer(address string) error { // SendCommandOutput sends the output of a command to the player. func (p *Player) SendCommandOutput(output *cmd.Output) { - p.session().SendCommandOutput(output) + p.session().SendCommandOutput(output, p.locale) } // SendDialogue sends an NPC dialogue to the player, using the entity passed as the entity that the dialogue diff --git a/server/server.go b/server/server.go index c21c9bf93..0a92bb074 100644 --- a/server/server.go +++ b/server/server.go @@ -11,6 +11,7 @@ import ( "github.com/df-mc/dragonfly/server/internal/sliceutil" _ "github.com/df-mc/dragonfly/server/item" // Imported for maintaining correct initialisation order. "github.com/df-mc/dragonfly/server/player" + "github.com/df-mc/dragonfly/server/player/chat" "github.com/df-mc/dragonfly/server/player/skin" "github.com/df-mc/dragonfly/server/session" "github.com/df-mc/dragonfly/server/world" @@ -22,7 +23,6 @@ import ( "github.com/sandertv/gophertunnel/minecraft/protocol" "github.com/sandertv/gophertunnel/minecraft/protocol/login" "github.com/sandertv/gophertunnel/minecraft/protocol/packet" - "github.com/sandertv/gophertunnel/minecraft/text" "golang.org/x/exp/maps" "golang.org/x/text/language" "iter" @@ -293,7 +293,7 @@ func (srv *Server) close() { srv.conf.Log.Debug("Disconnecting players...") for p := range srv.Players(nil) { - p.Disconnect(text.Colourf("%v", srv.conf.ShutdownMessage)) + p.Disconnect(chat.MessageServerDisconnect.Resolve(p.Locale())) } srv.pwg.Wait() diff --git a/server/session/command.go b/server/session/command.go index 39eabc751..b2c24702b 100644 --- a/server/session/command.go +++ b/server/session/command.go @@ -5,27 +5,30 @@ import ( "github.com/go-gl/mathgl/mgl64" "github.com/sandertv/gophertunnel/minecraft/protocol" "github.com/sandertv/gophertunnel/minecraft/protocol/packet" + "golang.org/x/text/language" "math" ) // SendCommandOutput sends the output of a command to the player. It will be shown to the caller of the // command, which might be the player or a websocket server. -func (s *Session) SendCommandOutput(output *cmd.Output) { +func (s *Session) SendCommandOutput(output *cmd.Output, l language.Tag) { if s == Nop { return } messages := make([]protocol.CommandOutputMessage, 0, output.MessageCount()+output.ErrorCount()) for _, message := range output.Messages() { - messages = append(messages, protocol.CommandOutputMessage{ - Success: true, - Message: message, - }) + om := protocol.CommandOutputMessage{Success: true, Message: message.String()} + if t, ok := message.(translation); ok { + om.Message, om.Parameters = t.Resolve(l), t.Params() + } + messages = append(messages, om) } for _, err := range output.Errors() { - messages = append(messages, protocol.CommandOutputMessage{ - Success: false, - Message: err.Error(), - }) + om := protocol.CommandOutputMessage{Message: err.Error()} + if t, ok := err.(translation); ok { + om.Message, om.Parameters = t.Resolve(l), t.Params() + } + messages = append(messages, om) } s.writePacket(&packet.CommandOutput{ @@ -36,6 +39,11 @@ func (s *Session) SendCommandOutput(output *cmd.Output) { }) } +type translation interface { + Resolve(l language.Tag) string + Params() []string +} + // sendAvailableCommands sends all available commands of the server. Once sent, they will be visible in the // /help list and will be auto-completed. func (s *Session) sendAvailableCommands(co Controllable) map[string]map[int]cmd.Runnable { diff --git a/server/session/session.go b/server/session/session.go index 3b94c6b62..1c0214d98 100644 --- a/server/session/session.go +++ b/server/session/session.go @@ -18,7 +18,6 @@ import ( "github.com/sandertv/gophertunnel/minecraft/protocol" "github.com/sandertv/gophertunnel/minecraft/protocol/login" "github.com/sandertv/gophertunnel/minecraft/protocol/packet" - "github.com/sandertv/gophertunnel/minecraft/text" "io" "log/slog" "net" @@ -83,8 +82,6 @@ type Session struct { openChunkTransactions []map[uint64]struct{} invOpened bool - joinMessage, quitMessage string - closeBackground chan struct{} } @@ -133,7 +130,7 @@ type Config struct { MaxChunkRadius int - JoinMessage, QuitMessage string + JoinMessage, QuitMessage chat.Translation HandleStop func(*world.Tx, Controllable) } @@ -163,8 +160,6 @@ func (conf Config) New(conn Conn) *Session { conn: conn, currentEntityRuntimeID: 1, heldSlot: new(uint32), - joinMessage: conf.JoinMessage, - quitMessage: conf.QuitMessage, recipes: make(map[uint32]recipe.Recipe), conf: conf, } @@ -224,8 +219,8 @@ func (s *Session) Spawn(c Controllable, tx *world.Tx) { s.sendInv(s.armour.Inventory(), protocol.WindowIDArmour) chat.Global.Subscribe(c) - if s.joinMessage != "" { - _, _ = fmt.Fprintln(chat.Global, text.Colourf("%v", fmt.Sprintf(s.joinMessage, s.conn.IdentityData().DisplayName))) + if !s.conf.JoinMessage.Zero() { + chat.Global.Writet(s.conf.JoinMessage, s.conn.IdentityData().DisplayName) } go s.background() @@ -255,8 +250,8 @@ func (s *Session) close(tx *world.Tx, c Controllable) { s.chunkLoader.Close(tx) - if s.quitMessage != "" { - _, _ = fmt.Fprintln(chat.Global, text.Colourf("%v", fmt.Sprintf(s.quitMessage, s.conn.IdentityData().DisplayName))) + if !s.conf.QuitMessage.Zero() { + chat.Global.Writet(s.conf.QuitMessage, s.conn.IdentityData().DisplayName) } chat.Global.Unsubscribe(c) diff --git a/server/session/text.go b/server/session/text.go index 240957f9b..fa59b189e 100644 --- a/server/session/text.go +++ b/server/session/text.go @@ -1,9 +1,11 @@ package session import ( + "github.com/df-mc/dragonfly/server/player/chat" "github.com/df-mc/dragonfly/server/player/scoreboard" "github.com/sandertv/gophertunnel/minecraft/protocol" "github.com/sandertv/gophertunnel/minecraft/protocol/packet" + "golang.org/x/text/language" "time" ) @@ -15,6 +17,17 @@ func (s *Session) SendMessage(message string) { }) } +// SendTranslation sends a translation localised for a specific language.Tag. +func (s *Session) SendTranslation(t chat.Translation, l language.Tag, a []any) { + tr := t.F(a...) + s.writePacket(&packet.Text{ + TextType: packet.TextTypeTranslation, + NeedsTranslation: true, + Message: tr.Resolve(l), + Parameters: tr.Params(), + }) +} + // SendTip ... func (s *Session) SendTip(message string) { s.writePacket(&packet.Text{