feat: add initial code

This commit is contained in:
2025-07-27 23:35:04 +02:00
commit be73abca63
9 changed files with 871 additions and 0 deletions

View File

@ -0,0 +1,186 @@
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
}