feat: add initial code
This commit is contained in:
186
internal/quake3/client/client.go
Normal file
186
internal/quake3/client/client.go
Normal 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
|
||||
}
|
||||
159
internal/quake3/client/client_test.go
Normal file
159
internal/quake3/client/client_test.go
Normal file
@ -0,0 +1,159 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
tests := []struct {
|
||||
host string
|
||||
port int
|
||||
shouldWork bool
|
||||
}{
|
||||
{"localhost", 27960, true},
|
||||
{"something.invalid", 27960, false},
|
||||
{"locahost", 0, false},
|
||||
{"locahost", 99999, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
testname := fmt.Sprintf("%s:%d->%t", tt.host, tt.port, tt.shouldWork)
|
||||
t.Run(testname, func(t *testing.T) {
|
||||
_, err := New(tt.host, tt.port)
|
||||
success := err == nil
|
||||
if tt.shouldWork && !success || !tt.shouldWork && success {
|
||||
t.Errorf(
|
||||
"Expected success=%t / was %t (err=%v)",
|
||||
tt.shouldWork, success,
|
||||
err,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClose(t *testing.T) {
|
||||
c := mustNew(t)
|
||||
if err := c.Close(); err != nil {
|
||||
t.Errorf("Error while closing client: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAsMap(t *testing.T) {
|
||||
res := parseAsMap(
|
||||
[]byte(
|
||||
"\xff\xff\xff\xff" + `infoResponse
|
||||
\modversion\4.3.4\game\q3ut4\auth\1\pure\1\gametype\0\sv_maxclients\10\bots\0\clients\0\mapname\ut4_casa\hostname\test\protocol\68`,
|
||||
),
|
||||
)
|
||||
exp := map[string]string{
|
||||
"modversion": "4.3.4",
|
||||
"game": "q3ut4",
|
||||
"auth": "1",
|
||||
"pure": "1",
|
||||
"gametype": "0",
|
||||
"sv_maxclients": "10",
|
||||
"bots": "0",
|
||||
"clients": "0",
|
||||
"mapname": "ut4_casa",
|
||||
"hostname": "test",
|
||||
"protocol": "68",
|
||||
}
|
||||
if !reflect.DeepEqual(exp, res) {
|
||||
t.Errorf(
|
||||
"Result mismatch: expected %v, was %v",
|
||||
exp, res,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePlayers(t *testing.T) {
|
||||
res, err := parsePlayers(
|
||||
[]byte(
|
||||
"\xff\xff\xff\xff" + `statusResponse
|
||||
\g_gametype\0\timelimit\20`,
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to parse players: %v", err)
|
||||
}
|
||||
|
||||
exp := []Player{}
|
||||
|
||||
if !reflect.DeepEqual(exp, res) {
|
||||
t.Errorf(
|
||||
"Result mismatch: expected %v, was %v",
|
||||
exp, res,
|
||||
)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dataStr string
|
||||
expected []Player
|
||||
}{
|
||||
{
|
||||
name: "nobody",
|
||||
dataStr: "\xff\xff\xff\xff" + `statusResponse
|
||||
\g_gametype\0\timelimit\20`,
|
||||
expected: []Player{},
|
||||
},
|
||||
{
|
||||
name: "oneplayer",
|
||||
dataStr: "\xff\xff\xff\xff" + `statusResponse
|
||||
\g_gametype\0\timelimit\20
|
||||
-1 5 "The!_oneandonly"`,
|
||||
expected: []Player{
|
||||
{
|
||||
Name: "The!_oneandonly",
|
||||
Score: -1,
|
||||
Ping: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "twoplayers",
|
||||
dataStr: "\xff\xff\xff\xff" + `statusResponse
|
||||
\g_gametype\0\timelimit\20
|
||||
0 0 "fooplayer"
|
||||
3 0 "barplayer"`,
|
||||
expected: []Player{
|
||||
{
|
||||
Name: "fooplayer",
|
||||
Score: 0,
|
||||
Ping: 0,
|
||||
},
|
||||
{
|
||||
Name: "barplayer",
|
||||
Score: 3,
|
||||
Ping: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res, err := parsePlayers([]byte(tt.dataStr))
|
||||
if err != nil {
|
||||
t.Errorf("Failed to parse players: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(tt.expected, res) {
|
||||
t.Errorf(
|
||||
"Result mismatch: expected %v, was %v",
|
||||
tt.expected, res,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustNew(t *testing.T) *Q3Client {
|
||||
c, err := New("localhost", 27960)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to create client: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
166
internal/quake3/client/robust.go
Normal file
166
internal/quake3/client/robust.go
Normal file
@ -0,0 +1,166 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
checkInterval = 2 * time.Second
|
||||
minBackoff = 1 * time.Second
|
||||
maxBackoff = 60 * time.Second
|
||||
backoffFactor = 2.0
|
||||
|
||||
PongEvent = "PONG"
|
||||
FailedPingEvent = "UNREACHABLE"
|
||||
)
|
||||
|
||||
type Observer interface {
|
||||
Update(event string)
|
||||
GetID() string
|
||||
}
|
||||
|
||||
type RobustQ3Client struct {
|
||||
Client *Q3Client
|
||||
backoff time.Duration
|
||||
attempt uint
|
||||
stop chan bool
|
||||
observers map[string]Observer
|
||||
}
|
||||
|
||||
func NewRobust(ctx context.Context, client *Q3Client) *RobustQ3Client {
|
||||
c := &RobustQ3Client{
|
||||
Client: client,
|
||||
backoff: minBackoff,
|
||||
attempt: 0,
|
||||
stop: make(chan bool),
|
||||
observers: make(map[string]Observer),
|
||||
}
|
||||
go c.keepAliveLoop(ctx)
|
||||
return c
|
||||
}
|
||||
|
||||
func (rc *RobustQ3Client) Stop() error {
|
||||
close(rc.stop)
|
||||
return rc.Client.Close()
|
||||
}
|
||||
|
||||
func (rc *RobustQ3Client) Ping() error {
|
||||
return rc.Client.Ping()
|
||||
}
|
||||
|
||||
func (rc *RobustQ3Client) GetInfo() (map[string]string, error) {
|
||||
return rc.Client.GetInfo()
|
||||
}
|
||||
|
||||
func (rc *RobustQ3Client) GetStatus() (*Status, error) {
|
||||
return rc.Client.GetStatus()
|
||||
}
|
||||
|
||||
func (rc *RobustQ3Client) Subscribe(o Observer) {
|
||||
rc.observers[o.GetID()] = o
|
||||
}
|
||||
|
||||
func (rc *RobustQ3Client) Unsubscribe(id string) {
|
||||
delete(rc.observers, id)
|
||||
}
|
||||
|
||||
func (rc *RobustQ3Client) keepAliveLoop(ctx context.Context) {
|
||||
slog.Info("Starting robust client reconnect loop", "interval", checkInterval)
|
||||
|
||||
ticker := time.NewTicker(checkInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.Info("Exiting robust client reconnect loop", "reason", "context cancelled")
|
||||
return
|
||||
case <-rc.stop:
|
||||
slog.Info("Exiting robust client reconnect loop", "reason", "stop signal")
|
||||
return
|
||||
case <-ticker.C:
|
||||
slog.Debug("Tick; Trying to ping gameserver")
|
||||
rc.tryPing(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RobustQ3Client) tryPing(ctx context.Context) {
|
||||
if err := rc.Client.Ping(); err != nil {
|
||||
slog.Warn("No response to gameserver ping", "error", err)
|
||||
rc.notifyObservers(FailedPingEvent)
|
||||
rc.reconnectLoop(ctx)
|
||||
} else {
|
||||
slog.Debug("Pong!")
|
||||
rc.notifyObservers(PongEvent)
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RobustQ3Client) reconnectLoop(ctx context.Context) {
|
||||
slog.Debug("Waiting until gameserver is reachable")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.Info("Exiting reconnect attempt loop", "reason", "context cancelled")
|
||||
return
|
||||
case <-rc.stop:
|
||||
slog.Info("Exiting reconnect attempt loop", "reason", "stop signal")
|
||||
return
|
||||
default:
|
||||
if err := rc.attemptReconnect(ctx); err != nil {
|
||||
rc.notifyObservers(FailedPingEvent)
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RobustQ3Client) attemptReconnect(ctx context.Context) error {
|
||||
if err := rc.Client.Ping(); err != nil {
|
||||
slog.Warn(
|
||||
"Failed to reach gameserver",
|
||||
"error", err,
|
||||
"backoff", rc.backoff,
|
||||
"attempt", rc.attempt,
|
||||
)
|
||||
|
||||
timer := time.NewTimer(rc.backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
case <-rc.stop:
|
||||
timer.Stop()
|
||||
case <-timer.C:
|
||||
rc.incRetryVars()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Info("Successfully reached gameserver", "attempts", rc.attempt)
|
||||
rc.resetRetryVars()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rc *RobustQ3Client) incRetryVars() {
|
||||
rc.backoff = min(
|
||||
time.Duration(float64(rc.backoff)*backoffFactor),
|
||||
maxBackoff,
|
||||
)
|
||||
rc.attempt++
|
||||
}
|
||||
|
||||
func (rc *RobustQ3Client) resetRetryVars() {
|
||||
rc.backoff = minBackoff
|
||||
rc.attempt = 0
|
||||
}
|
||||
|
||||
func (rc *RobustQ3Client) notifyObservers(event string) {
|
||||
slog.Debug("Notifying all observers", "event", event)
|
||||
for _, o := range rc.observers {
|
||||
o.Update(event)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user