187 lines
3.4 KiB
Go
187 lines
3.4 KiB
Go
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"net"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
const (
|
|
outOfBandPrefix = "\xff\xff\xff\xff"
|
|
bufSize = 1024 * 1024
|
|
timeout = 5 * time.Second
|
|
)
|
|
|
|
type Q3Client struct {
|
|
address *net.UDPAddr
|
|
conn *net.UDPConn
|
|
}
|
|
|
|
type Status struct {
|
|
Cfg map[string]string
|
|
Players []Player
|
|
}
|
|
|
|
type Player struct {
|
|
Name string
|
|
Ping int
|
|
Score int
|
|
}
|
|
|
|
func New(host string, port int) (*Q3Client, error) {
|
|
// Resolve host in case it's a hostname not an IP
|
|
addr, err := net.ResolveUDPAddr(
|
|
"udp4",
|
|
fmt.Sprintf("%s:%d", host, port),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Open UDP socket to server
|
|
conn, err := net.DialUDP("udp4", nil, addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Build client object
|
|
return &Q3Client{
|
|
address: addr,
|
|
conn: conn,
|
|
}, nil
|
|
}
|
|
|
|
func (c *Q3Client) Close() error {
|
|
if err := c.conn.Close(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Q3Client) Ping() error {
|
|
_, err := c.sendCommand("getinfo")
|
|
return err
|
|
}
|
|
|
|
func (c *Q3Client) GetInfo() (map[string]string, error) {
|
|
resp, err := c.sendCommand("getinfo")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return parseAsMap(resp), nil
|
|
}
|
|
|
|
func (c *Q3Client) GetStatus() (*Status, error) {
|
|
data, err := c.sendCommand("getstatus")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Try to split into three parts by newlines:
|
|
// header \n status-string \n players
|
|
data = bytes.TrimSuffix(data, []byte("\n"))
|
|
parts := bytes.SplitN(data, []byte("\n"), 3)
|
|
|
|
switch len(parts) {
|
|
case 2: // only header and status-string -> no players
|
|
return &Status{
|
|
Cfg: parseAsMap(parts[1]),
|
|
Players: make([]Player, 0),
|
|
}, nil
|
|
case 3: // header + status-string + players
|
|
players, err := parsePlayers(parts[2])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Status{
|
|
Cfg: parseAsMap(parts[1]),
|
|
Players: players,
|
|
}, nil
|
|
default:
|
|
return nil, errors.Errorf(
|
|
"cannot parse response: %q", data,
|
|
)
|
|
}
|
|
}
|
|
|
|
func (c *Q3Client) sendCommand(command string) ([]byte, error) {
|
|
// Reset socket timeout
|
|
if err := c.conn.SetDeadline(time.Now().Add(timeout)); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Send our message
|
|
if _, err := c.conn.Write(
|
|
fmt.Appendf(nil, "%s%s", outOfBandPrefix, command),
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Read the reply
|
|
buf := make([]byte, bufSize)
|
|
n, _, err := c.conn.ReadFrom(buf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buf[:n], nil
|
|
}
|
|
|
|
func parseAsMap(data []byte) map[string]string {
|
|
// Strip header if any
|
|
if i := bytes.Index(data, []byte("\n")); i >= 0 {
|
|
data = data[i+1:]
|
|
}
|
|
|
|
// Trim ends
|
|
data = bytes.TrimPrefix(data, []byte("\\"))
|
|
data = bytes.TrimSuffix(data, []byte("\n"))
|
|
|
|
// Split up by backslashes and mapify
|
|
parts := bytes.Split(data, []byte("\\"))
|
|
m := make(map[string]string)
|
|
for i := 0; i < len(parts)-1; i += 2 {
|
|
m[string(parts[i])] = string(parts[i+1])
|
|
}
|
|
|
|
return m
|
|
}
|
|
|
|
func parsePlayers(data []byte) ([]Player, error) {
|
|
players := make([]Player, 0)
|
|
for p := range bytes.SplitSeq(data, []byte("\n")) {
|
|
parts := bytes.SplitN(p, []byte(" "), 3)
|
|
if len(parts) != 3 {
|
|
continue
|
|
}
|
|
|
|
score, err := strconv.Atoi(string(parts[0]))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ping, err := strconv.Atoi(string(parts[1]))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
name, err := strconv.Unquote(string(parts[2]))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
players = append(
|
|
players,
|
|
Player{Name: name, Score: score, Ping: ping},
|
|
)
|
|
}
|
|
|
|
return players, nil
|
|
}
|