From be73abca6319320cbdd8420c9b68a7cfb902a3bd Mon Sep 17 00:00:00 2001 From: bp99 Date: Sun, 27 Jul 2025 23:35:04 +0200 Subject: [PATCH] feat: add initial code --- .gitignore | 1 + Makefile | 16 ++ cmd/urbanterror-agones/agones.go | 217 ++++++++++++++++++++++++++ cmd/urbanterror-agones/main.go | 89 +++++++++++ go.mod | 16 ++ go.sum | 21 +++ internal/quake3/client/client.go | 186 ++++++++++++++++++++++ internal/quake3/client/client_test.go | 159 +++++++++++++++++++ internal/quake3/client/robust.go | 166 ++++++++++++++++++++ 9 files changed, 871 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 cmd/urbanterror-agones/agones.go create mode 100644 cmd/urbanterror-agones/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/quake3/client/client.go create mode 100644 internal/quake3/client/client_test.go create mode 100644 internal/quake3/client/robust.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e660fd9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..afef5eb --- /dev/null +++ b/Makefile @@ -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 diff --git a/cmd/urbanterror-agones/agones.go b/cmd/urbanterror-agones/agones.go new file mode 100644 index 0000000..a0de02c --- /dev/null +++ b/cmd/urbanterror-agones/agones.go @@ -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 +} diff --git a/cmd/urbanterror-agones/main.go b/cmd/urbanterror-agones/main.go new file mode 100644 index 0000000..661f29b --- /dev/null +++ b/cmd/urbanterror-agones/main.go @@ -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 {} +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9b04bb2 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5866ccd --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/quake3/client/client.go b/internal/quake3/client/client.go new file mode 100644 index 0000000..b34e9ed --- /dev/null +++ b/internal/quake3/client/client.go @@ -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 +} diff --git a/internal/quake3/client/client_test.go b/internal/quake3/client/client_test.go new file mode 100644 index 0000000..71831cd --- /dev/null +++ b/internal/quake3/client/client_test.go @@ -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 +} diff --git a/internal/quake3/client/robust.go b/internal/quake3/client/robust.go new file mode 100644 index 0000000..7ce2ce6 --- /dev/null +++ b/internal/quake3/client/robust.go @@ -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) + } +}