From 2ed4a73d73444ebd772ef243d06edbab815b0822 Mon Sep 17 00:00:00 2001 From: Ayrdrie Date: Fri, 23 Jun 2017 16:54:12 -0700 Subject: [PATCH] Add Minecraft input plugin (#2960) --- docs/LICENSE_OF_DEPENDENCIES.md | 1 + plugins/inputs/all/all.go | 1 + plugins/inputs/minecraft/README.md | 66 +++++ .../inputs/minecraft/internal/rcon/rcon.go | 200 +++++++++++++++ plugins/inputs/minecraft/minecraft.go | 142 +++++++++++ plugins/inputs/minecraft/minecraft_test.go | 228 ++++++++++++++++++ plugins/inputs/minecraft/rcon.go | 88 +++++++ plugins/inputs/minecraft/rcon_test.go | 67 +++++ 8 files changed, 793 insertions(+) create mode 100644 plugins/inputs/minecraft/README.md create mode 100644 plugins/inputs/minecraft/internal/rcon/rcon.go create mode 100644 plugins/inputs/minecraft/minecraft.go create mode 100644 plugins/inputs/minecraft/minecraft_test.go create mode 100644 plugins/inputs/minecraft/rcon.go create mode 100644 plugins/inputs/minecraft/rcon_test.go diff --git a/docs/LICENSE_OF_DEPENDENCIES.md b/docs/LICENSE_OF_DEPENDENCIES.md index c62c262c0..dfd5fc251 100644 --- a/docs/LICENSE_OF_DEPENDENCIES.md +++ b/docs/LICENSE_OF_DEPENDENCIES.md @@ -12,6 +12,7 @@ works: - github.com/boltdb/bolt [MIT](https://github.com/boltdb/bolt/blob/master/LICENSE) - github.com/bsm/sarama-cluster [MIT](https://github.com/bsm/sarama-cluster/blob/master/LICENSE) - github.com/cenkalti/backoff [MIT](https://github.com/cenkalti/backoff/blob/master/LICENSE) +- github.com/chuckpreslar/rcon [MIT](https://github.com/chuckpreslar/rcon#license) - github.com/couchbase/go-couchbase [MIT](https://github.com/couchbase/go-couchbase/blob/master/LICENSE) - github.com/couchbase/gomemcached [MIT](https://github.com/couchbase/gomemcached/blob/master/LICENSE) - github.com/couchbase/goutils [MIT](https://github.com/couchbase/go-couchbase/blob/master/LICENSE) diff --git a/plugins/inputs/all/all.go b/plugins/inputs/all/all.go index 3bf98c9d9..0796a8422 100644 --- a/plugins/inputs/all/all.go +++ b/plugins/inputs/all/all.go @@ -45,6 +45,7 @@ import ( _ "github.com/influxdata/telegraf/plugins/inputs/mailchimp" _ "github.com/influxdata/telegraf/plugins/inputs/memcached" _ "github.com/influxdata/telegraf/plugins/inputs/mesos" + _ "github.com/influxdata/telegraf/plugins/inputs/minecraft" _ "github.com/influxdata/telegraf/plugins/inputs/mongodb" _ "github.com/influxdata/telegraf/plugins/inputs/mqtt_consumer" _ "github.com/influxdata/telegraf/plugins/inputs/mysql" diff --git a/plugins/inputs/minecraft/README.md b/plugins/inputs/minecraft/README.md new file mode 100644 index 000000000..726f9a29e --- /dev/null +++ b/plugins/inputs/minecraft/README.md @@ -0,0 +1,66 @@ +# Minecraft Plugin + +This plugin uses the RCON protocol to collect [statistics](http://minecraft.gamepedia.com/Statistics) from a [scoreboard](http://minecraft.gamepedia.com/Scoreboard) on a +Minecraft server. + +To enable [RCON](http://wiki.vg/RCON) on the minecraft server, add this to your server configuration in the `server.properties` file: + +``` +enable-rcon=true +rcon.password= +rcon.port=<1-65535> +``` + +To create a new scoreboard objective called `jump` on a minecraft server tracking the `stat.jump` criteria, run this command +in the Minecraft console: + +`/scoreboard objectives add jump stat.jump` + +Stats are collected with the following RCON command, issued by the plugin: + +`/scoreboard players list *` + +### Configuration: +``` +[[inputs.minecraft]] + # server address for minecraft + server = "localhost" + # port for RCON + port = "25575" + # password RCON for mincraft server + password = "replace_me" +``` + +### Measurements & Fields: + +*This plugin uses only one measurement, titled* `minecraft` + +- The field name is the scoreboard objective name. +- The field value is the count of the scoreboard objective + +- `minecraft` + - `` (integer, count) + +### Tags: + +- The `minecraft` measurement: + - `server`: the Minecraft RCON server + - `player`: the Minecraft player + + +### Sample Queries: + +Get the number of jumps per player in the last hour: +``` +SELECT SPREAD("jump") FROM "minecraft" WHERE time > now() - 1h GROUP BY "player" +``` + +### Example Output: + +``` +$ telegraf --input-filter minecraft --test +* Plugin: inputs.minecraft, Collection 1 +> minecraft,player=notch,server=127.0.0.1:25575 jumps=178i 1498261397000000000 +> minecraft,player=dinnerbone,server=127.0.0.1:25575 deaths=1i,jumps=1999i,cow_kills=1i 1498261397000000000 +> minecraft,player=jeb,server=127.0.0.1:25575 d_pickaxe=1i,damage_dealt=80i,d_sword=2i,hunger=20i,health=20i,kills=1i,level=33i,jumps=264i,armor=15i 1498261397000000000 +``` diff --git a/plugins/inputs/minecraft/internal/rcon/rcon.go b/plugins/inputs/minecraft/internal/rcon/rcon.go new file mode 100644 index 000000000..f17163f8b --- /dev/null +++ b/plugins/inputs/minecraft/internal/rcon/rcon.go @@ -0,0 +1,200 @@ +// Package rcon implements the communication protocol for communicating +// with RCON servers. Tested and working with Valve game servers. +package rcon + +import ( + "bytes" + "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "net" + "strings" +) + +const ( + PacketPaddingSize uint8 = 2 // Size of Packet's padding. + PacketHeaderSize uint8 = 8 // Size of Packet's header. +) + +const ( + TerminationSequence = "\x00" // Null empty ASCII string suffix. +) + +// Packet type constants. +// https://developer.valvesoftware.com/wiki/Source_RCON_Protocol#Packet_Type +const ( + Exec int32 = 2 + Auth int32 = 3 + AuthResponse int32 = 2 + ResponseValue int32 = 0 +) + +// Rcon package errors. +var ( + ErrInvalidWrite = errors.New("Failed to write the payload corretly to remote connection.") + ErrInvalidRead = errors.New("Failed to read the response corretly from remote connection.") + ErrInvalidChallenge = errors.New("Server failed to mirror request challenge.") + ErrUnauthorizedRequest = errors.New("Client not authorized to remote server.") + ErrFailedAuthorization = errors.New("Failed to authorize to the remote server.") +) + +type Client struct { + Host string // The IP address of the remote server. + Port int // The Port the remote server's listening on. + Authorized bool // Has the client been authorized by the server? + Connection net.Conn // The TCP connection to the server. +} + +type Header struct { + Size int32 // The size of the payload. + Challenge int32 // The challenge ths server should mirror. + Type int32 // The type of request being sent. +} + +type Packet struct { + Header Header // Packet header. + Body string // Body of packet. +} + +// Compile converts a packets header and body into its approriate +// byte array payload, returning an error if the binary packages +// Write method fails to write the header bytes in their little +// endian byte order. +func (p Packet) Compile() (payload []byte, err error) { + var size int32 = p.Header.Size + var buffer bytes.Buffer + var padding [PacketPaddingSize]byte + + if err = binary.Write(&buffer, binary.LittleEndian, &size); nil != err { + return + } else if err = binary.Write(&buffer, binary.LittleEndian, &p.Header.Challenge); nil != err { + return + } else if err = binary.Write(&buffer, binary.LittleEndian, &p.Header.Type); nil != err { + return + } + + buffer.WriteString(p.Body) + buffer.Write(padding[:]) + + return buffer.Bytes(), nil +} + +// NewPacket returns a pointer to a new Packet type. +func NewPacket(challenge, typ int32, body string) (packet *Packet) { + size := int32(len([]byte(body)) + int(PacketHeaderSize+PacketPaddingSize)) + return &Packet{Header{size, challenge, typ}, body} +} + +// Authorize calls Send with the appropriate command type and the provided +// password. The response packet is returned if authorization is successful +// or a potential error. +func (c *Client) Authorize(password string) (response *Packet, err error) { + if response, err = c.Send(Auth, password); nil == err { + if response.Header.Type == AuthResponse { + c.Authorized = true + } else { + err = ErrFailedAuthorization + response = nil + return + } + } + + return +} + +// Execute calls Send with the appropriate command type and the provided +// command. The response packet is returned if the command executed successfully +// or a potential error. +func (c *Client) Execute(command string) (response *Packet, err error) { + return c.Send(Exec, command) +} + +// Sends accepts the commands type and its string to execute to the clients server, +// creating a packet with a random challenge id for the server to mirror, +// and compiling its payload bytes in the appropriate order. The resonse is +// decompiled from its bytes into a Packet type for return. An error is returned +// if send fails. +func (c *Client) Send(typ int32, command string) (response *Packet, err error) { + if typ != Auth && !c.Authorized { + err = ErrUnauthorizedRequest + return + } + + // Create a random challenge for the server to mirror in its response. + var challenge int32 + binary.Read(rand.Reader, binary.LittleEndian, &challenge) + + // Create the packet from the challenge, typ and command + // and compile it to its byte payload + packet := NewPacket(challenge, typ, command) + payload, err := packet.Compile() + + var n int + + if nil != err { + return + } else if n, err = c.Connection.Write(payload); nil != err { + return + } else if n != len(payload) { + err = ErrInvalidWrite + return + } + + var header Header + + if err = binary.Read(c.Connection, binary.LittleEndian, &header.Size); nil != err { + return + } else if err = binary.Read(c.Connection, binary.LittleEndian, &header.Challenge); nil != err { + return + } else if err = binary.Read(c.Connection, binary.LittleEndian, &header.Type); nil != err { + return + } + + if packet.Header.Type == Auth && header.Type == ResponseValue { + // Discard, empty SERVERDATA_RESPOSE_VALUE from authorization. + c.Connection.Read(make([]byte, header.Size-int32(PacketHeaderSize))) + + // Reread the packet header. + if err = binary.Read(c.Connection, binary.LittleEndian, &header.Size); nil != err { + return + } else if err = binary.Read(c.Connection, binary.LittleEndian, &header.Challenge); nil != err { + return + } else if err = binary.Read(c.Connection, binary.LittleEndian, &header.Type); nil != err { + return + } + } + + if header.Challenge != packet.Header.Challenge { + err = ErrInvalidChallenge + return + } + + body := make([]byte, header.Size-int32(PacketHeaderSize)) + + n, err = c.Connection.Read(body) + + if nil != err { + return + } else if n != len(body) { + err = ErrInvalidRead + return + } + + response = new(Packet) + response.Header = header + response.Body = strings.TrimRight(string(body), TerminationSequence) + + return +} + +// NewClient creates a new Client type, creating the connection +// to the server specified by the host and port arguements. If +// the connection fails, an error is returned. +func NewClient(host string, port int) (client *Client, err error) { + client = new(Client) + client.Host = host + client.Port = port + client.Connection, err = net.Dial("tcp", fmt.Sprintf("%v:%v", client.Host, client.Port)) + return +} diff --git a/plugins/inputs/minecraft/minecraft.go b/plugins/inputs/minecraft/minecraft.go new file mode 100644 index 000000000..d88f4373d --- /dev/null +++ b/plugins/inputs/minecraft/minecraft.go @@ -0,0 +1,142 @@ +package minecraft + +import ( + "fmt" + "regexp" + "strconv" + + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/plugins/inputs" +) + +const sampleConfig = ` + ## server address for minecraft + # server = "localhost" + ## port for RCON + # port = "25575" + ## password RCON for mincraft server + # password = "" +` + +var ( + playerNameRegex = regexp.MustCompile(`for\s([^:]+):-`) + scoreboardRegex = regexp.MustCompile(`(?U):\s(\d+)\s\((.*)\)`) +) + +// Client is an interface for a client which gathers data from a minecraft server +type Client interface { + Gather() ([]string, error) +} + +// Minecraft represents a connection to a minecraft server +type Minecraft struct { + Server string + Port string + Password string + client Client +} + +// Description gives a brief description. +func (s *Minecraft) Description() string { + return "Collects scores from a minecraft server's scoreboard using the RCON protocol" +} + +// SampleConfig returns our sampleConfig. +func (s *Minecraft) SampleConfig() string { + return sampleConfig +} + +// Gather uses the RCON protocal to collect player and +// scoreboard stats from a minecraft server. +func (s *Minecraft) Gather(acc telegraf.Accumulator) error { + if s.client == nil { + var err error + s.client, err = NewRCON(s.Server, s.Port, s.Password) + if err != nil { + return err + } + } + + scores, err := s.client.Gather() + if err != nil { + return err + } + + for _, score := range scores { + player, err := ParsePlayerName(score) + if err != nil { + return err + } + tags := map[string]string{ + "player": player, + "server": s.Server + ":" + s.Port, + } + + stats, err := ParseScoreboard(score) + if err != nil { + return err + } + var fields = make(map[string]interface{}, len(stats)) + for _, stat := range stats { + fields[stat.Name] = stat.Value + } + + acc.AddFields("minecraft", fields, tags) + } + + return nil +} + +// ParsePlayerName takes an input string from rcon, to parse +// the player. +func ParsePlayerName(input string) (string, error) { + playerMatches := playerNameRegex.FindAllStringSubmatch(input, -1) + if playerMatches == nil { + return "", fmt.Errorf("no player was matched") + } + return playerMatches[0][1], nil +} + +// Score is an individual tracked scoreboard stat. +type Score struct { + Name string + Value int +} + +// ParseScoreboard takes an input string from rcon, to parse +// scoreboard stats. +func ParseScoreboard(input string) ([]Score, error) { + scoreMatches := scoreboardRegex.FindAllStringSubmatch(input, -1) + if scoreMatches == nil { + return nil, fmt.Errorf("No scores found") + } + + var scores []Score + + for _, match := range scoreMatches { + number := match[1] + name := match[2] + n, err := strconv.Atoi(number) + // Not necessary in current state, because regex can only match integers, + // maybe become necessary if regex is modified to match more types of + // numbers + if err != nil { + return nil, fmt.Errorf("Failed to parse score") + } + s := Score{ + Name: name, + Value: n, + } + scores = append(scores, s) + } + return scores, nil +} + +func init() { + inputs.Add("minecraft", func() telegraf.Input { + return &Minecraft{ + Server: "localhost", + Port: "25575", + } + }) +} diff --git a/plugins/inputs/minecraft/minecraft_test.go b/plugins/inputs/minecraft/minecraft_test.go new file mode 100644 index 000000000..664ad4e81 --- /dev/null +++ b/plugins/inputs/minecraft/minecraft_test.go @@ -0,0 +1,228 @@ +package minecraft + +import ( + "fmt" + "reflect" + "testing" + + "github.com/influxdata/telegraf/testutil" +) + +// TestParsePlayerName tests different Minecraft RCON inputs for players +func TestParsePlayerName(t *testing.T) { + // Test a valid input string to ensure player is extracted + input := "1 tracked objective(s) for divislight:- jumps: 178 (jumps)" + got, err := ParsePlayerName(input) + want := "divislight" + if err != nil { + t.Fatalf("player returned error. Error: %s\n", err) + } + if got != want { + t.Errorf("got %s\nwant %s\n", got, want) + } + + // Test an invalid input string to ensure error is returned + input = "" + got, err = ParsePlayerName(input) + want = "" + if err == nil { + t.Fatal("Expected error when player not present. No error found.") + } + if got != want { + t.Errorf("got %s\n want %s\n", got, want) + } + + // Test an invalid input string to ensure error is returned + input = "1 tracked objective(s) for 😂:- jumps: 178 (jumps)" + got, err = ParsePlayerName(input) + want = "😂" + if err != nil { + t.Fatalf("player returned error. Error: %s\n", err) + } + if got != want { + t.Errorf("got %s\n want %s\n", got, want) + } +} + +// TestParseScoreboard tests different Minecraft RCON inputs for scoreboard stats. +func TestParseScoreboard(t *testing.T) { + // test a valid input string to ensure stats are parsed correctly. + input := `1 tracked objective(s) for divislight:- jumps: 178 (jumps)- sword: 5 (sword)` + got, err := ParseScoreboard(input) + if err != nil { + t.Fatal("Unexpected error") + } + + want := []Score{ + { + Name: "jumps", + Value: 178, + }, + { + Name: "sword", + Value: 5, + }, + } + + if !reflect.DeepEqual(got, want) { + t.Errorf("Got: \n%#v\nWant: %#v", got, want) + } + + // Tests a partial input string. + input = `1 tracked objective(s) for divislight:- jumps: (jumps)- sword: 5 (sword)` + got, err = ParseScoreboard(input) + + if err != nil { + t.Fatal("Unexpected error") + } + + want = []Score{ + { + Name: "sword", + Value: 5, + }, + } + + if !reflect.DeepEqual(got, want) { + t.Errorf("Got: \n%#v\nWant:\n%#v", got, want) + } + + // Tests an empty string. + input = `` + _, err = ParseScoreboard(input) + if err == nil { + t.Fatal("Expected input error, but error was nil") + } + + // Tests when a number isn't an integer. + input = `1 tracked objective(s) for divislight:- jumps: 178.5 (jumps)- sword: 5 (sword)` + got, err = ParseScoreboard(input) + if err != nil { + t.Fatal("Unexpected error") + } + + want = []Score{ + { + Name: "sword", + Value: 5, + }, + } + + if !reflect.DeepEqual(got, want) { + t.Errorf("Got: \n%#v\nWant: %#v", got, want) + } + + //Testing a real life data scenario with unicode characters + input = `7 tracked objective(s) for mauxlaim:- total_kills: 39 (total_kills)- "howdy doody": 37 (dalevel)- howdy: 37 (lvl)- jumps: 1290 (jumps)- iron_pickaxe: 284 (iron_pickaxe)- cow_kills: 1 (cow_kills)- "asdf": 37 (😂)` + got, err = ParseScoreboard(input) + if err != nil { + t.Fatal("Unexpected error") + } + + want = []Score{ + { + Name: "total_kills", + Value: 39, + }, + { + Name: "dalevel", + Value: 37, + }, + { + Name: "lvl", + Value: 37, + }, + { + Name: "jumps", + Value: 1290, + }, + { + Name: "iron_pickaxe", + Value: 284, + }, + { + Name: "cow_kills", + Value: 1, + }, + { + Name: "😂", + Value: 37, + }, + } + + if !reflect.DeepEqual(got, want) { + t.Errorf("Got: \n%#v\nWant: %#v", got, want) + } + +} + +type MockClient struct { + Result []string + Err error +} + +func (m *MockClient) Gather() ([]string, error) { + return m.Result, m.Err +} + +func TestGather(t *testing.T) { + var acc testutil.Accumulator + testConfig := Minecraft{ + Server: "biffsgang.net", + Port: "25575", + client: &MockClient{ + Result: []string{ + `1 tracked objective(s) for divislight:- jumps: 178 (jumps)`, + `7 tracked objective(s) for mauxlaim:- total_kills: 39 (total_kills)- "howdy doody": 37 (dalevel)- howdy: 37 (lvl)- jumps: 1290 (jumps)- iron_pickaxe: 284 (iron_pickaxe)- cow_kills: 1 (cow_kills)- "asdf": 37 (😂)`, + `5 tracked objective(s) for torham:- total_kills: 29 (total_kills)- "howdy doody": 33 (dalevel)- howdy: 33 (lvl)- jumps: 263 (jumps)- "asdf": 33 (😂)`, + }, + Err: nil, + }, + } + + err := testConfig.Gather(&acc) + if err != nil { + t.Fatalf("gather returned error. Error: %s\n", err) + } + + tags := map[string]string{ + "player": "divislight", + "server": "biffsgang.net:25575", + } + + assertContainsTaggedStat(t, &acc, "minecraft", "jumps", 178, tags) + tags["player"] = "mauxlaim" + assertContainsTaggedStat(t, &acc, "minecraft", "cow_kills", 1, tags) + tags["player"] = "torham" + assertContainsTaggedStat(t, &acc, "minecraft", "total_kills", 29, tags) + +} + +func assertContainsTaggedStat( + t *testing.T, + acc *testutil.Accumulator, + measurement string, + field string, + expectedValue int, + tags map[string]string, +) { + var actualValue int + for _, pt := range acc.Metrics { + if pt.Measurement == measurement && reflect.DeepEqual(pt.Tags, tags) { + for fieldname, value := range pt.Fields { + if fieldname == field { + actualValue = value.(int) + if value == expectedValue { + return + } + t.Errorf("Expected value %d\n got value %d\n", expectedValue, value) + } + } + } + } + msg := fmt.Sprintf( + "Could not find measurement \"%s\" with requested tags within %s, Actual: %d", + measurement, field, actualValue) + t.Fatal(msg) + +} diff --git a/plugins/inputs/minecraft/rcon.go b/plugins/inputs/minecraft/rcon.go new file mode 100644 index 000000000..6dbed47ad --- /dev/null +++ b/plugins/inputs/minecraft/rcon.go @@ -0,0 +1,88 @@ +package minecraft + +import ( + "strconv" + "strings" + + "github.com/influxdata/telegraf/plugins/inputs/minecraft/internal/rcon" +) + +const ( + // NoMatches is a sentinel value returned when there are no statistics defined on the + //minecraft server + NoMatches = `All matches failed` + // ScoreboardPlayerList is the command to see all player statistics + ScoreboardPlayerList = `scoreboard players list *` +) + +// RCONClient is a representation of RCON command authorizaiton and exectution +type RCONClient interface { + Authorize(password string) (*rcon.Packet, error) + Execute(command string) (*rcon.Packet, error) +} + +// RCON represents a RCON server connection +type RCON struct { + Server string + Port string + Password string + client RCONClient +} + +// NewRCON creates a new RCON +func NewRCON(server, port, password string) (*RCON, error) { + client, err := newClient(server, port) + if err != nil { + return nil, err + } + + return &RCON{ + Server: server, + Port: port, + Password: password, + client: client, + }, nil +} + +func newClient(server, port string) (*rcon.Client, error) { + p, err := strconv.Atoi(port) + if err != nil { + return nil, err + } + + return rcon.NewClient(server, p) +} + +// Gather recieves all player scoreboard information and returns it per player. +func (r *RCON) Gather() ([]string, error) { + if r.client == nil { + var err error + r.client, err = newClient(r.Server, r.Port) + if err != nil { + return nil, err + } + } + if _, err := r.client.Authorize(r.Password); err != nil { + // Potentially a network problem where the client will need to be + // re-initialized + r.client = nil + return nil, err + } + + packet, err := r.client.Execute(ScoreboardPlayerList) + if err != nil { + // Potentially a network problem where the client will need to be + // re-initialized + r.client = nil + return nil, err + } + + if !strings.Contains(packet.Body, NoMatches) { + players := strings.Split(packet.Body, "Showing") + if len(players) > 1 { + return players[1:], nil + } + } + + return []string{}, nil +} diff --git a/plugins/inputs/minecraft/rcon_test.go b/plugins/inputs/minecraft/rcon_test.go new file mode 100644 index 000000000..011939894 --- /dev/null +++ b/plugins/inputs/minecraft/rcon_test.go @@ -0,0 +1,67 @@ +package minecraft + +import ( + "testing" + + "github.com/influxdata/telegraf/plugins/inputs/minecraft/internal/rcon" +) + +type MockRCONClient struct { + Result *rcon.Packet + Err error +} + +func (m *MockRCONClient) Authorize(password string) (*rcon.Packet, error) { + return m.Result, m.Err +} +func (m *MockRCONClient) Execute(command string) (*rcon.Packet, error) { + return m.Result, m.Err +} + +// TestRCONGather test the RCON gather function +func TestRCONGather(t *testing.T) { + mock := &MockRCONClient{ + Result: &rcon.Packet{ + Body: `Showing 1 tracked objective(s) for divislight:- jumps: 178 (jumps)Showing 7 tracked objective(s) for mauxlaim:- total_kills: 39 (total_kills)- "howdy doody": 37 (dalevel)- howdy: 37 (lvl)- jumps: 1290 (jumps)- iron_pickaxe: 284 (iron_pickaxe)- cow_kills: 1 (cow_kills)- "asdf": 37 (😂)Showing 5 tracked objective(s) for torham:- total_kills: 29 (total_kills)- "howdy doody": 33 (dalevel)- howdy: 33 (lvl)- jumps: 263 (jumps)- "asdf": 33 (😂)`, + }, + Err: nil, + } + + want := []string{ + ` 1 tracked objective(s) for divislight:- jumps: 178 (jumps)`, + ` 7 tracked objective(s) for mauxlaim:- total_kills: 39 (total_kills)- "howdy doody": 37 (dalevel)- howdy: 37 (lvl)- jumps: 1290 (jumps)- iron_pickaxe: 284 (iron_pickaxe)- cow_kills: 1 (cow_kills)- "asdf": 37 (😂)`, + ` 5 tracked objective(s) for torham:- total_kills: 29 (total_kills)- "howdy doody": 33 (dalevel)- howdy: 33 (lvl)- jumps: 263 (jumps)- "asdf": 33 (😂)`, + } + + client := &RCON{ + Server: "craftstuff.com", + Port: "2222", + Password: "pass", + client: mock, + } + + got, err := client.Gather() + if err != nil { + t.Fatalf("Gather returned an error. Error %s\n", err) + } + for i, s := range got { + if want[i] != s { + t.Fatalf("Got %s at index %d, want %s at index %d", s, i, want[i], i) + } + } + + client.client = &MockRCONClient{ + Result: &rcon.Packet{ + Body: "", + }, + Err: nil, + } + + got, err = client.Gather() + if err != nil { + t.Fatalf("Gather returned an error. Error %s\n", err) + } + if len(got) != 0 { + t.Fatalf("Expected empty slice of length %d, got slice of length %d", 0, len(got)) + } +}