diff --git a/.github/workflows/simple-game-server-go.yml b/.github/workflows/simple-game-server-go.yml index ea62087..596b536 100644 --- a/.github/workflows/simple-game-server-go.yml +++ b/.github/workflows/simple-game-server-go.yml @@ -20,11 +20,11 @@ jobs: with: go-version: '1.20' - name: Lint - uses: golangci/golangci-lint-action@v2 + uses: golangci/golangci-lint-action@v8 with: - version: v1.52.2 working-directory: './simple-game-server-go' skip-go-installation: true + only-new-issues: true - name: Build uses: goreleaser/goreleaser-action@v2 if: startsWith(github.ref, 'refs/tags/') == false diff --git a/go.work.sum b/go.work.sum index 468b667..e4a2a25 100644 --- a/go.work.sum +++ b/go.work.sum @@ -4,6 +4,7 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r github.com/centrifugal/centrifuge v0.21.1/go.mod h1:uAFqaz85mlIw995eZblWzfuMj1Ok4jWNGZUMYbpQfbE= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/gomodule/redigo v1.8.5/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/igm/sockjs-go/v3 v3.0.2/go.mod h1:UqchsOjeagIBFHvd+RZpLaVRbCwGilEC08EDHsD1jYE= github.com/klauspost/cpuid/v2 v2.0.6 h1:dQ5ueTiftKxp0gyjKSx5+8BtPWkyQbd95m8Gys/RarI= diff --git a/simple-game-server-go/.golangci.yml b/simple-game-server-go/.golangci.yml index 43994d4..b348ad5 100644 --- a/simple-game-server-go/.golangci.yml +++ b/simple-game-server-go/.golangci.yml @@ -1,42 +1,54 @@ +version: "2" run: concurrency: 4 - deadline: 5m - -linters-settings: - cyclop: - max-complexity: 20 - gocyclo: - min-complexity: 20 - linters: - enable-all: true + default: all disable: + - depguard - exhaustive - exhaustruct - - exhaustivestruct + - forcetypeassert - funlen - gochecknoglobals + - godox - gomoddirectives - - interfacer - - maligned - - gomnd + - ireturn + - lll + - mnd - nestif + - nlreturn - paralleltest - - scopelint + - tagliatelle - testpackage - tparallel + - varnamelen - wrapcheck - wsl - - godox - - forcetypeassert - - nlreturn - - tagliatelle - - varnamelen - - ireturn - - varcheck - - deadcode - - golint - - ifshort - - structcheck - - nosnakecase - - lll + settings: + cyclop: + max-complexity: 20 + gocyclo: + min-complexity: 20 + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - builtin$ + - examples$ + - third_party$ +formatters: + enable: + - gci + - gofmt + - gofumpt + - goimports + exclusions: + generated: lax + paths: + - builtin$ + - examples$ + - third_party$ diff --git a/simple-game-server-go/go.sum b/simple-game-server-go/go.sum index 0fc32eb..5c389d4 100644 --- a/simple-game-server-go/go.sum +++ b/simple-game-server-go/go.sum @@ -1,9 +1,5 @@ github.com/FZambia/eagle v0.0.2 h1:35qHDuXSQevZ4w9A51k4wU7OE/tPHTEWXoywA93hvkY= github.com/FZambia/sentinel v1.1.0 h1:qrCBfxc8SvJihYNjBWgwUI93ZCvFe/PJIPTHKmlp8a8= -github.com/Unity-Technologies/unity-gaming-services-go-sdk v0.3.1 h1:piV2hAtoc5ke3ywkIvUs82J+CphjWXoN3219isDmY00= -github.com/Unity-Technologies/unity-gaming-services-go-sdk v0.3.1/go.mod h1:WgDwSafd4alCs+HdK0z+7htBVZIe+LUrLQgM738WDd0= -github.com/Unity-Technologies/unity-gaming-services-go-sdk v0.5.0 h1:nH2XUCCx1BAV6hXhjSaIA+rOrzX2CRPz4/Yx/sZ26Do= -github.com/Unity-Technologies/unity-gaming-services-go-sdk v0.5.0/go.mod h1:WgDwSafd4alCs+HdK0z+7htBVZIe+LUrLQgM738WDd0= github.com/Unity-Technologies/unity-gaming-services-go-sdk v0.5.4 h1:2KQYKCx44tEurLU5TlhmPhvKyvXo5QyBiTEfFqiC25g= github.com/Unity-Technologies/unity-gaming-services-go-sdk v0.5.4/go.mod h1:WgDwSafd4alCs+HdK0z+7htBVZIe+LUrLQgM738WDd0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= diff --git a/simple-game-server-go/internal/game/allocated.go b/simple-game-server-go/internal/game/allocated.go index 055ce29..3256c57 100644 --- a/simple-game-server-go/internal/game/allocated.go +++ b/simple-game-server-go/internal/game/allocated.go @@ -32,10 +32,10 @@ func (g *Game) allocated(allocationID string) { maxPlayers = defaultMaxPlayers } - g.Server.SetMaxPlayers(int32(maxPlayers)) - g.Server.SetServerName(fmt.Sprintf("simple-game-server-go - %s", c.AllocatedUUID)) - g.Server.SetGameType(c.Extra["gameType"]) - g.Server.SetGameMap(c.Extra["map"]) + g.SetMaxPlayers(int32(maxPlayers)) + g.SetServerName("simple-game-server-go - " + c.AllocatedUUID) + g.SetGameType(c.Extra["gameType"]) + g.SetGameMap(c.Extra["map"]) // Set a random metric, if using SQP. if c.QueryType == server.QueryProtocolSQP { @@ -95,7 +95,7 @@ func (g *Game) acceptClient(server *net.TCPListener) (*net.TCPConn, error) { } g.clients.Store(client.RemoteAddr(), client) - currentPlayers := g.Server.PlayerJoined() + currentPlayers := g.PlayerJoined() g.logger.WithFields(logrus.Fields{ "client_ip": client.RemoteAddr().String(), "current_players": currentPlayers, @@ -108,7 +108,7 @@ func (g *Game) acceptClient(server *net.TCPListener) (*net.TCPConn, error) { func (g *Game) handleClient(client *net.TCPConn) { defer func() { g.clients.Delete(client.RemoteAddr()) - currentPlayers := g.Server.PlayerLeft() + currentPlayers := g.PlayerLeft() g.logger.WithFields(logrus.Fields{ "client_ip": client.RemoteAddr().String(), "current_players": currentPlayers, diff --git a/simple-game-server-go/internal/game/deallocated.go b/simple-game-server-go/internal/game/deallocated.go index 0611797..9bae5c5 100644 --- a/simple-game-server-go/internal/game/deallocated.go +++ b/simple-game-server-go/internal/game/deallocated.go @@ -19,7 +19,7 @@ func (g *Game) deallocated() { // disconnectAllClients disconnects all remaining clients connected to the game server. func (g *Game) disconnectAllClients() { - g.clients.Range(func(key interface{}, value interface{}) bool { + g.clients.Range(func(_, value any) bool { client, ok := value.(*net.TCPConn) if !ok { return true diff --git a/simple-game-server-go/internal/game/game.go b/simple-game-server-go/internal/game/game.go index b9321b1..69be5df 100644 --- a/simple-game-server-go/internal/game/game.go +++ b/simple-game-server-go/internal/game/game.go @@ -77,7 +77,7 @@ func (g *Game) Start() error { g.logger.Info("stopped") }() - return g.Server.WaitUntilTerminated() + return g.WaitUntilTerminated() } // processEvents handles processing events for the operation of the diff --git a/simple-game-server-go/main.go b/simple-game-server-go/main.go index 10981b2..a9ab26c 100644 --- a/simple-game-server-go/main.go +++ b/simple-game-server-go/main.go @@ -2,9 +2,11 @@ package main import ( "flag" + "io" "os" "path/filepath" "runtime/debug" + "strings" "github.com/Unity-Technologies/multiplay-examples/simple-game-server-go/internal/game" "github.com/sirupsen/logrus" @@ -15,29 +17,89 @@ func parseFlags(args []string) (string, string, string, error) { dir, _ := os.UserHomeDir() f := flag.NewFlagSet("simple-game-server-go", flag.ContinueOnError) - var log, logFile, tracebackLevel string - f.StringVar(&log, "log", filepath.Join(dir, "logs"), "path to the log directory to write to") + var logTargets, logFile, tracebackLevel string + f.StringVar(&logTargets, "log", "stdout,"+filepath.Join(dir, "logs"), "comma-separated log targets: 'stdout', file path, or directory") f.StringVar(&logFile, "logFile", "", "path to the log file to write to") - f.StringVar( - &tracebackLevel, - "tracebackLevel", - "", - "the amount of detail printed by the runtime prints before exiting due to an unrecovered panic", - ) + f.StringVar(&tracebackLevel, "tracebackLevel", "none", "the amount of detail printed by the runtime prints before exiting due to an unrecovered panic") // Flags which are not used, but must be present to satisfy the default parameters in the Unity Dashboard. var port, queryPort uint f.UintVar(&port, "port", 8000, "port for the game server to bind to") f.UintVar(&queryPort, "queryport", 8001, "port for the query endpoint to bind to") - return log, logFile, tracebackLevel, f.Parse(args) + return logTargets, logFile, tracebackLevel, f.Parse(args) +} + +// logWritersFromTargets creates a multi-writer from the specified log targets and log file. +// If no valid targets are provided, it defaults to writing to stdout. +func logWritersFromTargets(logTargets string, logFile string, logger *logrus.Logger) io.Writer { + targets := make([]io.Writer, 0) + for _, t := range splitAndTrim(logTargets) { + switch t { + case "stdout": + targets = append(targets, os.Stdout) + case "stderr": + targets = append(targets, os.Stderr) + default: + // If it's a directory, use server.log inside it + info, err := os.Stat(t) + if err == nil && info.IsDir() { + t = filepath.Join(t, "server.log") + } + f, err := os.OpenFile(t, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666) + if err != nil { + logger.WithError(err).Warningf("could not open log target %s for writing", t) + continue + } + targets = append(targets, f) + } + } + // logFile takes precedence + if logFile != "" { + f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666) + if err == nil { + targets = append(targets, f) + } else { + logger.WithError(err).Warning("could not open log file for writing") + } + } + if len(targets) == 0 { + return os.Stdout + } + return io.MultiWriter(targets...) +} + +// splitAndTrim splits a string by the OS-specific path list separator and trims each part. +func splitAndTrim(s string) []string { + parts := make([]string, 0) + for _, p := range filepath.SplitList(s) { + for _, t := range splitComma(p) { + trimmed := filepath.Clean(t) + if trimmed != "" && trimmed != "." { + parts = append(parts, trimmed) + } + } + } + return parts +} + +// splitComma splits a string by commas and trims each part, returning a slice of non-empty strings. +func splitComma(s string) []string { + res := make([]string, 0) + for _, t := range strings.Split(s, ",") { + trimmed := strings.TrimSpace(t) + if trimmed != "" && trimmed != "." { + res = append(res, trimmed) + } + } + return res } func main() { logger := logrus.New() logger.SetFormatter(&logrus.JSONFormatter{}) - log, logFile, tracebackLevel, err := parseFlags(os.Args[1:]) + logTargets, logFile, tracebackLevel, err := parseFlags(os.Args[1:]) if err != nil { logger.WithError(err).Fatal("error parsing flags") } @@ -47,20 +109,7 @@ func main() { debug.SetTraceback(tracebackLevel) } - // Let -logFile take precedence over -log - if logFile == "" && log != "" { - logFile = filepath.Join(log, "server.log") - } - - if logFile != "" { - f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666) - if err == nil { - defer f.Close() - logger.Out = f - } else { - logger.WithError(err).Warning("could not open log file for writing") - } - } + logger.Out = logWritersFromTargets(logTargets, logFile, logger) g, err := game.New(logger) if err != nil { diff --git a/simple-game-server-go/main_test.go b/simple-game-server-go/main_test.go index 5ba62f5..bf5f420 100644 --- a/simple-game-server-go/main_test.go +++ b/simple-game-server-go/main_test.go @@ -1,23 +1,272 @@ package main import ( + "os" + "path/filepath" + "strings" "testing" + "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" ) func Test_parseFlags(t *testing.T) { t.Parallel() - log, logFile, tracebackLevel, err := parseFlags([]string{ - "-port", "9000", - "-queryport", "9010", - "-log", "/tmp/", - "-logFile", "/tmp/Engine.log", - "-tracebackLevel", "all", - }) - - require.NoError(t, err) - require.Equal(t, "/tmp/", log) - require.Equal(t, "/tmp/Engine.log", logFile) - require.Equal(t, "all", tracebackLevel) + + t.Run("single log target", func(t *testing.T) { + t.Parallel() + log, logFile, tracebackLevel, err := parseFlags([]string{ + "-port", "9000", + "-queryport", "9010", + "-log", "/tmp/", + "-logFile", "/tmp/Engine.log", + "-tracebackLevel", "all", + }) + + require.NoError(t, err) + require.Equal(t, "/tmp/", log) + require.Equal(t, "/tmp/Engine.log", logFile) + require.Equal(t, "all", tracebackLevel) + }) + + t.Run("multiple log targets", func(t *testing.T) { + t.Parallel() + log, logFile, tracebackLevel, err := parseFlags([]string{ + "-port", "9000", + "-queryport", "9010", + "-log", "stdout,/tmp/", + "-logFile", "/tmp/Engine.log", + "-tracebackLevel", "all", + }) + + require.NoError(t, err) + require.Equal(t, "stdout,/tmp/", log) + require.Equal(t, "/tmp/Engine.log", logFile) + require.Equal(t, "all", tracebackLevel) + }) +} + +func Test_logWritersFromTargets(t *testing.T) { + t.Parallel() + + t.Run("stdout target", func(t *testing.T) { + t.Parallel() + logger := logrus.New() + + writer := logWritersFromTargets("stdout", "", logger) + + // Test that we can write to it (should not panic) + n, err := writer.Write([]byte("test")) + require.NoError(t, err) + require.Positive(t, n) + }) + + t.Run("stderr target", func(t *testing.T) { + t.Parallel() + logger := logrus.New() + + writer := logWritersFromTargets("stderr", "", logger) + + // Test that we can write to it (should not panic) + n, err := writer.Write([]byte("test")) + require.NoError(t, err) + require.Positive(t, n) + }) + + t.Run("file target", func(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + testFile := filepath.Join(tempDir, "test.log") + logger := logrus.New() + + writer := logWritersFromTargets(testFile, "", logger) + + // Write some data + testData := "test log message\n" + n, err := writer.Write([]byte(testData)) + require.NoError(t, err) + require.Equal(t, len(testData), n) + + // Verify file was created and contains data + content, err := os.ReadFile(testFile) + require.NoError(t, err) + require.Equal(t, testData, string(content)) + }) + + t.Run("directory target", func(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + testDir := filepath.Join(tempDir, "logs") + require.NoError(t, os.MkdirAll(testDir, 0o755)) + logger := logrus.New() + + writer := logWritersFromTargets(testDir, "", logger) + + // Write some data + testData := "test log message in directory\n" + n, err := writer.Write([]byte(testData)) + require.NoError(t, err) + require.Equal(t, len(testData), n) + + // Verify server.log was created in the directory + serverLogPath := filepath.Join(testDir, "server.log") + content, err := os.ReadFile(serverLogPath) + require.NoError(t, err) + require.Equal(t, testData, string(content)) + }) + + t.Run("multiple targets", func(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + multiFile := filepath.Join(tempDir, "multi.log") + targets := strings.Join([]string{"stdout", multiFile}, ",") + logger := logrus.New() + + writer := logWritersFromTargets(targets, "", logger) + + // Write some data + testData := "multi-target test\n" + n, err := writer.Write([]byte(testData)) + require.NoError(t, err) + require.Equal(t, len(testData), n) + + // Verify file was created + content, err := os.ReadFile(multiFile) + require.NoError(t, err) + require.Equal(t, testData, string(content)) + }) + + t.Run("logFile takes precedence", func(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + priorityFile := filepath.Join(tempDir, "priority.log") + logger := logrus.New() + + writer := logWritersFromTargets("stdout", priorityFile, logger) + + // Write some data + testData := "priority file test\n" + n, err := writer.Write([]byte(testData)) + require.NoError(t, err) + require.Equal(t, len(testData), n) + + // Verify priority file was created + content, err := os.ReadFile(priorityFile) + require.NoError(t, err) + require.Equal(t, testData, string(content)) + }) + + t.Run("invalid file target falls back to other targets", func(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + invalidPath := "/invalid/path/that/does/not/exist/file.log" + validFile := filepath.Join(tempDir, "valid.log") + targets := strings.Join([]string{invalidPath, validFile}, ",") + logger := logrus.New() + + writer := logWritersFromTargets(targets, "", logger) + + // Write some data + testData := "fallback test\n" + n, err := writer.Write([]byte(testData)) + require.NoError(t, err) + require.Equal(t, len(testData), n) + + // Verify only valid file was created + _, err = os.Stat(invalidPath) + require.True(t, os.IsNotExist(err)) + + content, err := os.ReadFile(validFile) + require.NoError(t, err) + require.Equal(t, testData, string(content)) + }) + + t.Run("no valid targets defaults to stdout", func(t *testing.T) { + t.Parallel() + invalidPath := "/invalid/path/that/does/not/exist" + logger := logrus.New() + + writer := logWritersFromTargets(invalidPath, "", logger) + + // Should not be nil and should be writable + require.NotNil(t, writer) + n, err := writer.Write([]byte("test")) + require.NoError(t, err) + require.Positive(t, n) + }) + + t.Run("empty targets defaults to stdout", func(t *testing.T) { + t.Parallel() + logger := logrus.New() + + writer := logWritersFromTargets("", "", logger) + + // Should not be nil and should be writable + require.NotNil(t, writer) + n, err := writer.Write([]byte("test")) + require.NoError(t, err) + require.Positive(t, n) + }) +} + +func Test_splitAndTrim(t *testing.T) { + t.Parallel() + + t.Run("single target", func(t *testing.T) { + t.Parallel() + result := splitAndTrim("stdout") + require.Equal(t, []string{"stdout"}, result) + }) + + t.Run("multiple comma-separated targets", func(t *testing.T) { + t.Parallel() + result := splitAndTrim("stdout,stderr,/tmp/test.log") + require.Equal(t, []string{"stdout", "stderr", "/tmp/test.log"}, result) + }) + + t.Run("targets with whitespace", func(t *testing.T) { + t.Parallel() + result := splitAndTrim("stdout, stderr , /tmp/test.log ") + require.Equal(t, []string{"stdout", "stderr", "/tmp/test.log"}, result) + }) + + t.Run("empty string", func(t *testing.T) { + t.Parallel() + result := splitAndTrim("") + require.Empty(t, result) + }) + + t.Run("empty targets filtered out", func(t *testing.T) { + t.Parallel() + result := splitAndTrim("stdout,,stderr,") + require.Equal(t, []string{"stdout", "stderr"}, result) + }) +} + +func Test_splitComma(t *testing.T) { + t.Parallel() + + t.Run("single value", func(t *testing.T) { + t.Parallel() + result := splitComma("stdout") + require.Equal(t, []string{"stdout"}, result) + }) + + t.Run("multiple values", func(t *testing.T) { + t.Parallel() + result := splitComma("stdout,stderr,/tmp/log") + require.Equal(t, []string{"stdout", "stderr", "/tmp/log"}, result) + }) + + t.Run("values with whitespace", func(t *testing.T) { + t.Parallel() + result := splitComma("stdout, stderr , /tmp/log ") + require.Equal(t, []string{"stdout", "stderr", "/tmp/log"}, result) + }) + + t.Run("empty values filtered out", func(t *testing.T) { + t.Parallel() + result := splitComma("stdout,,stderr") + require.Equal(t, []string{"stdout", "stderr"}, result) + }) }