Skip to content

Commit

Permalink
Support Minecraft server 1.13 and newer (influxdata#5733)
Browse files Browse the repository at this point in the history
  • Loading branch information
danielnelson authored and bitcharmer committed Oct 18, 2019
1 parent deeb213 commit a94fbc0
Show file tree
Hide file tree
Showing 9 changed files with 611 additions and 554 deletions.
92 changes: 55 additions & 37 deletions plugins/inputs/minecraft/README.md
Original file line number Diff line number Diff line change
@@ -1,66 +1,84 @@
# Minecraft Plugin
# Minecraft Input 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.
The `minecraft` plugin connects to a Minecraft server using the RCON protocol
to collects scores from the server [scoreboard][].

To enable [RCON](http://wiki.vg/RCON) on the minecraft server, add this to your server configuration in the `server.properties` file:
This plugin is known to support Minecraft Java Edition versions 1.11 - 1.14.
When using an version of Minecraft earlier than 1.13, be aware that the values
for some criterion has changed and may need to be modified.

```
#### Server Setup

Enable [RCON][] on the Minecraft server, add this to your server configuration
in the [server.properties][] file:

```conf
enable-rcon=true
rcon.password=<your 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`
Scoreboard [Objectives][] must be added using the server console for the
plugin to collect. These can be added in game by players with op status,
from the server console, or over an RCON connection.

Stats are collected with the following RCON command, issued by the plugin:

`/scoreboard players list *`
When getting started pick an easy to test objective. This command will add an
objective that counts the number of times a player has jumped:
```
/scoreboard objectives add jumps minecraft.custom:minecraft.jump
```

### Configuration:
Once a player has triggered the event they will be added to the scoreboard,
you can then list all players with recorded scores:
```
[[inputs.minecraft]]
# server address for minecraft
server = "localhost"
# port for RCON
port = "25575"
# password RCON for mincraft server
password = "replace_me"
/scoreboard players list
```

### Measurements & Fields:
View the current scores with a command, substituting your player name:
```
/scoreboard players list Etho
```

*This plugin uses only one measurement, titled* `minecraft`
### Configuration

- The field name is the scoreboard objective name.
- The field value is the count of the scoreboard objective
```toml
[[inputs.minecraft]]
## Address of the Minecraft server.
# server = "localhost"

- `minecraft`
- `<objective_name>` (integer, count)
## Server RCON Port.
# port = "25575"

### Tags:
## Server RCON Password.
password = ""
```

- The `minecraft` measurement:
- `server`: the Minecraft RCON server
- `player`: the Minecraft player
### Metrics

- minecraft
- tags:
- player
- port (port of the server)
- server (hostname:port, deprecated in 1.11; use `source` and `port` tags)
- source (hostname of the server)
- fields:
- `<objective_name>` (integer, count)

### 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"
SELECT SPREAD("jumps") 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
minecraft,player=notch,source=127.0.0.1,port=25575 jumps=178i 1498261397000000000
minecraft,player=dinnerbone,source=127.0.0.1,port=25575 deaths=1i,jumps=1999i,cow_kills=1i 1498261397000000000
minecraft,player=jeb,source=127.0.0.1,port=25575 d_pickaxe=1i,damage_dealt=80i,d_sword=2i,hunger=20i,health=20i,kills=1i,level=33i,jumps=264i,armor=15i 1498261397000000000
```

[server.properies]: https://minecraft.gamepedia.com/Server.properties
[scoreboard]: http://minecraft.gamepedia.com/Scoreboard
[objectives]: https://minecraft.gamepedia.com/Scoreboard#Objectives
[rcon]: http://wiki.vg/RCON
205 changes: 205 additions & 0 deletions plugins/inputs/minecraft/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package minecraft

import (
"regexp"
"strconv"
"strings"

"github.com/influxdata/telegraf/plugins/inputs/minecraft/internal/rcon"
)

var (
scoreboardRegexLegacy = regexp.MustCompile(`(?U):\s(?P<value>\d+)\s\((?P<name>.*)\)`)
scoreboardRegex = regexp.MustCompile(`\[(?P<name>[^\]]+)\]: (?P<value>\d+)`)
)

// Connection is an established connection to the Minecraft server.
type Connection interface {
// Execute runs a command.
Execute(command string) (string, error)
}

// Connector is used to create connections to the Minecraft server.
type Connector interface {
// Connect establishes a connection to the server.
Connect() (Connection, error)
}

func NewConnector(hostname, port, password string) (*connector, error) {
return &connector{
hostname: hostname,
port: port,
password: password,
}, nil
}

type connector struct {
hostname string
port string
password string
}

func (c *connector) Connect() (Connection, error) {
p, err := strconv.Atoi(c.port)
if err != nil {
return nil, err
}

rcon, err := rcon.NewClient(c.hostname, p)
if err != nil {
return nil, err
}

_, err = rcon.Authorize(c.password)
if err != nil {
return nil, err
}

return &connection{rcon: rcon}, nil
}

func NewClient(connector Connector) (*client, error) {
return &client{connector: connector}, nil
}

type client struct {
connector Connector
conn Connection
}

func (c *client) Connect() error {
conn, err := c.connector.Connect()
if err != nil {
return err
}
c.conn = conn
return nil
}

func (c *client) Players() ([]string, error) {
if c.conn == nil {
err := c.Connect()
if err != nil {
return nil, err
}
}

resp, err := c.conn.Execute("/scoreboard players list")
if err != nil {
c.conn = nil
return nil, err
}

players, err := parsePlayers(resp)
if err != nil {
c.conn = nil
return nil, err
}

return players, nil
}

func (c *client) Scores(player string) ([]Score, error) {
if c.conn == nil {
err := c.Connect()
if err != nil {
return nil, err
}
}

resp, err := c.conn.Execute("/scoreboard players list " + player)
if err != nil {
c.conn = nil
return nil, err
}

scores, err := parseScores(resp)
if err != nil {
c.conn = nil
return nil, err
}

return scores, nil
}

type connection struct {
rcon *rcon.Client
}

func (c *connection) Execute(command string) (string, error) {
packet, err := c.rcon.Execute(command)
if err != nil {
return "", err
}
return packet.Body, nil
}

func parsePlayers(input string) ([]string, error) {
parts := strings.SplitAfterN(input, ":", 2)
if len(parts) != 2 {
return []string{}, nil
}

names := strings.Split(parts[1], ",")

// Detect Minecraft <= 1.12
if strings.Contains(parts[0], "players on the scoreboard") && len(names) > 0 {
// Split the last two player names: ex: "notch and dinnerbone"
head := names[:len(names)-1]
tail := names[len(names)-1]
names = append(head, strings.SplitN(tail, " and ", 2)...)
}

var players []string
for _, name := range names {
name := strings.TrimSpace(name)
if name == "" {
continue
}
players = append(players, name)

}
return players, nil
}

// Score is an individual tracked scoreboard stat.
type Score struct {
Name string
Value int64
}

func parseScores(input string) ([]Score, error) {
if strings.Contains(input, "has no scores") {
return []Score{}, nil
}

// Detect Minecraft <= 1.12
var re *regexp.Regexp
if strings.Contains(input, "tracked objective") {
re = scoreboardRegexLegacy
} else {
re = scoreboardRegex
}

var scores []Score
matches := re.FindAllStringSubmatch(input, -1)
for _, match := range matches {
score := Score{}
for i, subexp := range re.SubexpNames() {
switch subexp {
case "name":
score.Name = match[i]
case "value":
value, err := strconv.ParseInt(match[i], 10, 64)
if err != nil {
continue
}
score.Value = value
default:
continue
}
}
scores = append(scores, score)
}
return scores, nil
}
Loading

0 comments on commit a94fbc0

Please sign in to comment.