feat: add initial code
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
bin/
|
||||||
16
Makefile
Normal file
16
Makefile
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
SRCS != find ./ -type f -name '*.go'
|
||||||
|
|
||||||
|
GO ?= go
|
||||||
|
BIN_DIR ?= bin
|
||||||
|
BIN_NAME ?= urbanterror-agones
|
||||||
|
|
||||||
|
bin: ${SRCS}
|
||||||
|
${GO} build -o ${BIN_DIR}/${BIN_NAME} ./cmd/urbanterror-agones/
|
||||||
|
|
||||||
|
test: bin
|
||||||
|
${GO} test ./...
|
||||||
|
|
||||||
|
run: bin
|
||||||
|
${BIN_DIR}/${BIN_NAME}
|
||||||
|
|
||||||
|
.PHONY: test run
|
||||||
217
cmd/urbanterror-agones/agones.go
Normal file
217
cmd/urbanterror-agones/agones.go
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdk "agones.dev/agones/sdks/go"
|
||||||
|
q3c "bp99.eu/ut-agones/internal/quake3/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
checkInterval = 5 * time.Second
|
||||||
|
playersListName = "players"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sidecarState struct {
|
||||||
|
q3 *q3c.RobustQ3Client
|
||||||
|
sdk *sdk.SDK
|
||||||
|
players map[string]q3c.Player
|
||||||
|
}
|
||||||
|
|
||||||
|
type agonesObserver struct {
|
||||||
|
id string
|
||||||
|
state *sidecarState
|
||||||
|
ctx context.Context
|
||||||
|
firstPing bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *agonesObserver) Update(event string) {
|
||||||
|
slog.Debug("Got notification", "event", event)
|
||||||
|
switch event {
|
||||||
|
case q3c.PongEvent:
|
||||||
|
if o.firstPing {
|
||||||
|
slog.Debug("This is the first time gameserver was reached; reporting ready to Agones")
|
||||||
|
if err := o.state.sdk.Ready(); err != nil {
|
||||||
|
slog.Error("Failed to send ready signal to Agones", "error", err)
|
||||||
|
}
|
||||||
|
o.firstPing = false
|
||||||
|
} else {
|
||||||
|
slog.Debug("Sending health ping to Agones")
|
||||||
|
if err := o.state.sdk.Health(); err != nil {
|
||||||
|
slog.Error("Failed to send health ping to Agones", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *agonesObserver) GetID() string {
|
||||||
|
return o.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartAgonesSidecar() error {
|
||||||
|
slog.Info("Starting Urban Terror Agones sidecar")
|
||||||
|
|
||||||
|
// Create context
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Create simple game client
|
||||||
|
client, err := q3c.New("localhost", 27960)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to create new Quake3 client", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create robust game client
|
||||||
|
robustClient := q3c.NewRobust(ctx, client)
|
||||||
|
|
||||||
|
// Initialize the Agones SDK
|
||||||
|
sdk, err := sdk.NewSDK()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to initialize Agones SDK", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up SIGINT/SIGTERM handling
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
<-sigChan
|
||||||
|
slog.Info("Received shutdown signal")
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Keep state
|
||||||
|
state := &sidecarState{
|
||||||
|
q3: robustClient,
|
||||||
|
sdk: sdk,
|
||||||
|
players: make(map[string]q3c.Player, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and subscribe game client event observer
|
||||||
|
o := &agonesObserver{
|
||||||
|
id: "agones",
|
||||||
|
state: state,
|
||||||
|
ctx: ctx,
|
||||||
|
firstPing: true,
|
||||||
|
}
|
||||||
|
robustClient.Subscribe(o)
|
||||||
|
|
||||||
|
// Start status check loop
|
||||||
|
go statusLoop(ctx, state)
|
||||||
|
|
||||||
|
// Keep alive
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
slog.Info("Shutting down Urban Terror Agones sidecar")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
if err := state.q3.Stop(); err != nil {
|
||||||
|
slog.Error("Failed to gracefully Quake3 client", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusLoop(ctx context.Context, state *sidecarState) {
|
||||||
|
slog.Info("Starting status check loop", "interval", checkInterval)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(checkInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
slog.Info("Exiting status check loop", "reason", "context cancelled")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
slog.Debug("Tick; Retrieving gameserver status")
|
||||||
|
reportStatus(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportStatus(state *sidecarState) {
|
||||||
|
status, err := state.q3.GetStatus()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to get gameserer status from Quake3 client", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := updatePlayersList(state, status.Players); err != nil {
|
||||||
|
slog.Error("Error while updating player list", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePlayersList(state *sidecarState, players []q3c.Player) error {
|
||||||
|
curPlayerMap := make(map[string]q3c.Player)
|
||||||
|
for _, p := range players {
|
||||||
|
curPlayerMap[p.Name] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
// New and still connected players
|
||||||
|
for _, p := range players {
|
||||||
|
if _, exists := state.players[p.Name]; exists {
|
||||||
|
slog.Debug("Player already known to be online", "player", p.Name)
|
||||||
|
} else {
|
||||||
|
slog.Debug("Player joined", "player", p.Name)
|
||||||
|
if err := tryAppendToPlayerList(state.sdk, p.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state.players[p.Name] = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for disconnected players
|
||||||
|
for name := range state.players {
|
||||||
|
if _, exists := curPlayerMap[name]; !exists {
|
||||||
|
slog.Debug("Player disconnected", "player", name)
|
||||||
|
if err := tryDeleteFromPlayerList(state.sdk, name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
delete(state.players, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("Updated players")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryAppendToPlayerList(sdk *sdk.SDK, player string) error {
|
||||||
|
exists, err := sdk.Beta().ListContains(playersListName, player)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sdk.Beta().AppendListValue(playersListName, player); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryDeleteFromPlayerList(sdk *sdk.SDK, player string) error {
|
||||||
|
exists, err := sdk.Beta().ListContains(playersListName, player)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sdk.Beta().DeleteListValue(playersListName, player); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
89
cmd/urbanterror-agones/main.go
Normal file
89
cmd/urbanterror-agones/main.go
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
q3c "bp99.eu/ut-agones/internal/quake3/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
verbose := flag.Bool("v", false, "Enable debug verbosity")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
level := slog.LevelInfo
|
||||||
|
if *verbose {
|
||||||
|
level = slog.LevelDebug
|
||||||
|
}
|
||||||
|
slog.SetDefault(
|
||||||
|
slog.New(
|
||||||
|
slog.NewJSONHandler(
|
||||||
|
os.Stdout,
|
||||||
|
&slog.HandlerOptions{Level: level},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := StartAgonesSidecar(); err != nil {
|
||||||
|
slog.Error("Agones sidecar error", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func smain() {
|
||||||
|
slog.SetDefault(
|
||||||
|
slog.New(
|
||||||
|
slog.NewJSONHandler(
|
||||||
|
os.Stdout,
|
||||||
|
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
c, err := q3c.New("localhost", 27960)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to create client", "error", err)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
slog.Info("Client created")
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := c.Ping(); err != nil {
|
||||||
|
slog.Error("Failed to ping with client", "error", err)
|
||||||
|
} else {
|
||||||
|
slog.Info("Pinged client")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func xmain() {
|
||||||
|
slog.SetDefault(
|
||||||
|
slog.New(
|
||||||
|
slog.NewJSONHandler(
|
||||||
|
os.Stdout,
|
||||||
|
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
c, err := q3c.New("localhost", 27960)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to create client", "error", err)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
slog.Info("Client created", "client", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
q3c.NewRobust(ctx, c)
|
||||||
|
|
||||||
|
select {}
|
||||||
|
}
|
||||||
16
go.mod
Normal file
16
go.mod
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
module bp99.eu/ut-agones
|
||||||
|
|
||||||
|
go 1.24.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
agones.dev/agones v1.50.0 // indirect
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
golang.org/x/net v0.41.0 // indirect
|
||||||
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
|
golang.org/x/text v0.26.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
|
||||||
|
google.golang.org/grpc v1.73.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
|
)
|
||||||
21
go.sum
Normal file
21
go.sum
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
agones.dev/agones v1.50.0 h1:5EAMeLqgkAWpLafn/Njjj7c8fKyDB6e0Gi9UkWQSyiU=
|
||||||
|
agones.dev/agones v1.50.0/go.mod h1:8U85AVWwPf6VCZYHjkkmnRfHqfWaNh/JTW78hiEkiHo=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||||
|
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||||
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||||
|
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||||
|
google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||||
|
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
|
||||||
|
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
|
||||||
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
|
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
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