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{