From 1270425761902f545c44b7057f6a0c8fa51a3d54 Mon Sep 17 00:00:00 2001 From: xcircle Date: Sun, 11 May 2025 00:43:09 +0200 Subject: [PATCH] feat: add go client --- .gitignore | 23 +++- README.md | 40 +++++- goClient/README.md | 95 ++++++++++++++ goClient/cmd/main.go | 51 ++++++++ goClient/go.mod | 5 + goClient/go.sum | 2 + goClient/internal/bot/bot.go | 25 ++++ goClient/internal/bot/factory.go | 24 ++++ goClient/internal/bot/mybot.go | 28 +++++ goClient/internal/bot/randombot.go | 27 ++++ goClient/internal/model/request.go | 20 +++ goClient/internal/model/response.go | 50 ++++++++ goClient/internal/websocket/client.go | 170 ++++++++++++++++++++++++++ 13 files changed, 557 insertions(+), 3 deletions(-) create mode 100644 goClient/README.md create mode 100644 goClient/cmd/main.go create mode 100644 goClient/go.mod create mode 100644 goClient/go.sum create mode 100644 goClient/internal/bot/bot.go create mode 100644 goClient/internal/bot/factory.go create mode 100644 goClient/internal/bot/mybot.go create mode 100644 goClient/internal/bot/randombot.go create mode 100644 goClient/internal/model/request.go create mode 100644 goClient/internal/model/response.go create mode 100644 goClient/internal/websocket/client.go diff --git a/.gitignore b/.gitignore index dff1e85..3bc8459 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,25 @@ build # VS Cache .vs/ .vscode/ -.idea/ \ No newline at end of file +.idea/ + +### Go ### +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Go workspace file +go.work +go.work.sum + +# env file +.env diff --git a/README.md b/README.md index 51d12bf..5038e0a 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,12 @@ auch verschiedene Starthilfen zur Verfügung stellen. ## Aufgabe Es ist ein **Bot** zu entwickeln, der nach den **Standardregeln** das Spiel "4 Gewinnt" spielt. Hierfür haben wir eine -Schnittstelle in den jeweiligen Clients (Java oder Python) bereit gestellt, welche implementiert werden muss. Das Ziel +Schnittstelle in den jeweiligen Clients (Java, Python oder Go) bereit gestellt, welche implementiert werden muss. Das Ziel ist es eine gewisse Anzahl an Spiele gegen eine Gegner KI zu gewinnen und als Turniersieger hervorzugehen! :) ## Teilnahmebedingungen -- Implementierung der vorgegebenen Bot Schnittstelle (Java oder Python) +- Implementierung der vorgegebenen Bot Schnittstelle (Java, Python oder Go) - Der Bot sendet dem Spiel vor dem Timeout (ca 2 Sekunden), was der nächste Zug ist - Die KI ist selbst geschrieben @@ -31,6 +31,7 @@ ist es eine gewisse Anzahl an Spiele gegen eine Gegner KI zu gewinnen und als Tu - Python 3.10.4+ (für den Spieleserver und gegebenfalls für den Bot) - Java SDK 17+ (falls der Bot in Java geschrieben wird) - dotnet 8.0 (falls der Bot in csharp geschrieben wird) +- Go 1.23.5+ (falls der Bot in Go geschrieben wird) ## Den Spieleserver starten @@ -137,6 +138,21 @@ Port | -p or --port | Port des 4 Connect Servers auf dem Zielserver | 87 Wenn ihr also mehrere Bots geschrieben habt, könnt ihr mit dem Namen das ganze umschalten. Es ist aber natürlich auch ausreichend den Default zu belassen und alles im "UserBot" zu machen. +## Einen Client mit dem Server verbinden (Go) + +Den Go Client starten +```bash +cd goClient +go run cmd/main.go +``` +| Parmeter | Switch | Beschreibung | Default | +| -------- | -------| --------------------------------------------- | --------| +|BotName | -bot | Name der KI, die gestartet werden soll | MyBot | +|Port | -port | Port des 4 Connect Servers auf dem Zielserver | 8765 | + +Wenn ihr also mehrere Bots geschrieben habt, könnt ihr mit dem Namen das ganze umschalten. +Es ist aber natürlich auch ausreichend den Default zu belassen und alles im "MyBot" zu machen. + ## Einen Client mit dem Server verbinden (Manueller Client) Um gegen deine eigene KI spielen zu können, kannst du einen manuellen Client starten. @@ -219,6 +235,26 @@ public int Play(int[][] field) Der Returnwert der Play Methode ist ein Integer innerhalb von 0-5 (mögliche Spalten im Spielfeld) +### Go + +Unter dem Pfad `goClient/internal/bot` findest du die Datei `mybot.go`. +Diese Klasse beinhaltet die User-KI und die folgende Funktion `Run()`: + +```go +func (b *MyBot) Run(state *model.StateData) int { + + // + // Implement your logic here so that the bot can play + // + + // Currently, the first column is always selected as the next move + return 0 +} +``` + +Der Returnwert der `Run()`-Funktion ist ein Integer innerhalb von 0-5 (mögliche +Spalten im Spielfeld). + ## Spielfeld Daten diff --git a/goClient/README.md b/goClient/README.md new file mode 100644 index 0000000..2d22f93 --- /dev/null +++ b/goClient/README.md @@ -0,0 +1,95 @@ +# connect4 Tournament - Go Client + +## Overview + +This is a Go client for the connect4 game. + +## Installation + +### Prerequisites + +- Go 1.23.5 or higher +- github.com/gorilla/websocket v1.5.3 or higher + +### Steps to Install + +1. **Install Dependencies** + + Make sure to install the necessary Go dependencies. You can do this by + running: + + ```bash + go mod tidy + ``` + +2. **Build the Bot** + + To compile the bot, simply run: + + ```bash + (on Linux) + go build -o connect4-bot cmd/main.go + + (on Windows) + go build -o connect4-bot.exe cmd/main.go + ``` + +3. **Start the Bot** + + You can now start the bot with the following command: + + ```bash + (on Linux) + ./connect4-bot + + (on Windows) + connect4-bot.exe + ``` + + **Configuration** + + You can configure the bot by passing arguments when starting the bot: + + ```bash + (on Linux) + ./connect4-bot --port 8765 --bot RandomBot + + (on Windows) + connect4-bot.exe --port 8765 --bot RandomBot + ``` + + - **`--port`**: Port where the game server is running (default: `8765`). + - **`--bot`**: Choose the type of bot to use. Possible values: + - `"RandomBot"`: Example bot implementation which plays random + - `"MyBot"`: Template for your custom bot + +## Create Your Own Bot + +1. Create a copy of `mybot.go` in the `internal/bot` directory (e.g. + `internal/bot/mycustombot.go`) +2. Rename all instances of `MyBot` in your new file to your bot's name + (e.g. `MyCustomBot`) +3. Implement your bot logic in the `Run()` method +4. Add your bot in the `internal/bot/factory.go` file in the `NewBot()` method +5. Run the client with your bot's name using the `--bot MyCustomBot` flag + +## Project Structure + +``` +│ go.mod +│ go.sum +│ README.md +├───cmd +│ main.go // Main entry point for the application +└───internal + ├───bot + │ bot.go // Base implementation of the bot + │ factory.go // Bot factory for creating bots + │ mybot.go // Template for creating a custom bot + │ randombot.go // Example bot implementation + ├───model + │ request.go // Defines the requests for the gameserver + │ response.go // Defines the responses from the gameserver + └───websocket + client.go // WebSocket client for communication +``` diff --git a/goClient/cmd/main.go b/goClient/cmd/main.go new file mode 100644 index 0000000..7d1c4d6 --- /dev/null +++ b/goClient/cmd/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "flag" + "fmt" + "log" + + "connect4-bot/internal/bot" + "connect4-bot/internal/websocket" +) + +func main() { + // Define command-line arguments + port := flag.Int("port", 8765, "Port on which the game server listens") + botType := flag.String("bot", "MyBot", "Name of the bot to be used") + + // Parse the command-line arguments + flag.Parse() + + // Print the port and selected bot information + fmt.Printf("Server listens on port: %d\n", *port) + fmt.Printf("Selected bot : %s\n", *botType) + + // Use the BotFactory to create the desired bot + factory := &bot.BotFactory{} + myBot, err := factory.NewBot(*botType) + if err != nil { + log.Fatal("Error creating the bot:", err) + } + + // Create and connect the WebSocket client + client := websocket.NewClient(myBot, *port) + + err = client.Connect() + if err != nil { + log.Fatal("Connection failed:", err) + } else { + log.Println("Client ID :", client.ClientId) + } + + // check if we have a valid connection + if client.ClientId == 0 { + log.Fatal("Connection failed.") + } + + // Start goroutines + go client.Listen() // Listen for WebSocket messages and call the bot + + // Block the main thread to keep the program running + select {} +} diff --git a/goClient/go.mod b/goClient/go.mod new file mode 100644 index 0000000..1ce3770 --- /dev/null +++ b/goClient/go.mod @@ -0,0 +1,5 @@ +module connect4-bot + +go 1.23.5 + +require github.com/gorilla/websocket v1.5.3 diff --git a/goClient/go.sum b/goClient/go.sum new file mode 100644 index 0000000..25a9fc4 --- /dev/null +++ b/goClient/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/goClient/internal/bot/bot.go b/goClient/internal/bot/bot.go new file mode 100644 index 0000000..3294b62 --- /dev/null +++ b/goClient/internal/bot/bot.go @@ -0,0 +1,25 @@ +package bot + +import "connect4-bot/internal/model" + +// Bot is the interface for all bot types. +// Any bot that implements this interface must define two methods: +// 1. Run: Makes the next move +// 2. GetName: Returns the bot's name. +type Bot interface { + // Run is the method that contains the logic for the bot's actions. + // It takes the current game state as input and returns an integer + // indicating the column where the coin has to go. + // The specific behavior of the bot depends on the implementation of this + // method in the concrete bot. + Run(state *model.StateData) int + + // GetName returns the name of the bot. + // This name is used for two purposes: + // 1. To create the websocket URL for communication with the game server. + // 2. To display the bot's name within the game. + // Each bot will have its own unique name (e.g. "RandomBot"). + // This allows the game to know which bot is being used and show the correct + // name. + GetName() string +} diff --git a/goClient/internal/bot/factory.go b/goClient/internal/bot/factory.go new file mode 100644 index 0000000..4dd75f1 --- /dev/null +++ b/goClient/internal/bot/factory.go @@ -0,0 +1,24 @@ +package bot + +import "errors" + +// BotFactory provides a function to create various types of bots +// This factory pattern allows for easy addition of new bot types in the future. +type BotFactory struct{} + +// NewBot creates a new bot based on the given name. +// It returns a specific bot implementation or an error if the bot type is unknown. +func (f *BotFactory) NewBot(name string) (Bot, error) { + // Use a switch statement to decide which type of bot to create based on the provided name + switch name { + case "RandomBot": + // Create and return the bot which fill random columns + return &RandomBot{}, nil + case "MyBot": + // Create and return the bot template bot + return &MyBot{}, nil + default: + // Return an error if the bot name is unknown + return nil, errors.New("unknown bot type: " + name) + } +} diff --git a/goClient/internal/bot/mybot.go b/goClient/internal/bot/mybot.go new file mode 100644 index 0000000..223c555 --- /dev/null +++ b/goClient/internal/bot/mybot.go @@ -0,0 +1,28 @@ +package bot + +import ( + "connect4-bot/internal/model" +) + +// MyBot implements the Bot interface for an template bot. +type MyBot struct { +} + +// Run processes the current game status and determines in which column the coin +// should be inserted. +// In this implementation, the bot simply does nothing and return always a zero. +func (b *MyBot) Run(state *model.StateData) int { + + // + // Implement your logic here so that the bot can play + // + + // Currently, the first column is always selected as the next move + return 0 +} + +// GetName returns the name of the bot. This name is used for creating the +// WebSocket URL and displaying the bot's name within the game. +func (b *MyBot) GetName() string { + return "MyBot" +} diff --git a/goClient/internal/bot/randombot.go b/goClient/internal/bot/randombot.go new file mode 100644 index 0000000..e5da4d0 --- /dev/null +++ b/goClient/internal/bot/randombot.go @@ -0,0 +1,27 @@ +package bot + +import ( + "connect4-bot/internal/model" + "math/rand" + "time" +) + +// RandomBot implements the Bot interface for an random playing bot. +type RandomBot struct { +} + +// Run processes the current game status and determines in which column the coin +// should be inserted. +// In this implementation, the bot simply insert the coin in random columns. +func (b *RandomBot) Run(state *model.StateData) int { + // generate a seed for the random generator + rand.New(rand.NewSource(time.Now().UnixNano())) + // return a random column number + return rand.Intn(len(state.Field[0])) +} + +// GetName returns the name of the bot. This name is used for creating the +// WebSocket URL and displaying the bot's name within the game. +func (b *RandomBot) GetName() string { + return "RandomBot" +} diff --git a/goClient/internal/model/request.go b/goClient/internal/model/request.go new file mode 100644 index 0000000..09f10f6 --- /dev/null +++ b/goClient/internal/model/request.go @@ -0,0 +1,20 @@ +package model + +// ConnectionRequest represents a request to connect with the gameserver +type ConnectionRequest struct { + Type string `json:"type"` + Name string `json:"name"` // Request type: always "connect" +} + +// PlayRequest represents a request to make a move in a specific column. +type PlayRequest struct { + ID int `json:"id"` // Unique identifier of the bot or client + Type string `json:"type"` // Request type: always "play" + Column int `json:"column"` // Column index where the move should be played +} + +// StateRequest represents a request to retrieve the current game state. +type StateRequest struct { + ID int `json:"id"` // Unique identifier of the bot or client + Type string `json:"type"` // Request type: always "getState" +} diff --git a/goClient/internal/model/response.go b/goClient/internal/model/response.go new file mode 100644 index 0000000..904b4ee --- /dev/null +++ b/goClient/internal/model/response.go @@ -0,0 +1,50 @@ +package model + +import "encoding/json" + +// ConnectionData represents the response from the server regarding the connection status. +type ConnectionData struct { + ID int `json:"id"` // Unique identifier for the bot or client. + Connected bool `json:"connected"` // Indicates whether the connection is established. +} + +// StateData represents the game state information returned by the server. +type StateData struct { + ID int `json:"id"` // Unique identifier for the bot or client. + GameState string `json:"gameState,omitempty"` // The current game state (e.g. "playing", "finished"...) + Field [][]int `json:"field,omitempty"` // The current game field as a 2D array. +} + +// own UnmarshalJSON method implementation because the field numbers from the +// gameserver are float, but we want to use integer +func (s *StateData) UnmarshalJSON(data []byte) error { + + var raw struct { + ID int `json:"id"` + GameState string `json:"gameState,omitempty"` + Field [][]interface{} `json:"field,omitempty"` + } + + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + var field [][]int + for _, row := range raw.Field { + intRow := make([]int, len(row)) + for i, val := range row { + if num, ok := val.(float64); ok { + intRow[i] = int(num) + } else { + intRow[i] = 0 + } + } + field = append(field, intRow) + } + + s.ID = raw.ID + s.GameState = raw.GameState + s.Field = field + + return nil +} diff --git a/goClient/internal/websocket/client.go b/goClient/internal/websocket/client.go new file mode 100644 index 0000000..a34ba34 --- /dev/null +++ b/goClient/internal/websocket/client.go @@ -0,0 +1,170 @@ +package websocket + +import ( + "bytes" + "encoding/json" + "log" + "strconv" + "time" + + "connect4-bot/internal/bot" + "connect4-bot/internal/model" + + "github.com/gorilla/websocket" +) + +// Client represents a client that communicates with the game server via WebSocket. +type Client struct { + Conn *websocket.Conn // The WebSocket connection + Bot bot.Bot // The bot that interacts with the game + Port int // The port number to connect to + ClientId int // client ID which is given by the gameserver +} + +// NewClient initializes a new Client with the provided bot and port +func NewClient(bot bot.Bot, port int) *Client { + return &Client{ + Bot: bot, + Port: port, + } +} + +// Connect establishes the WebSocket connection to the server using the bot's name and the specified port. +func (c *Client) Connect() error { + // Construct the URL for the WebSocket connection + url := "ws://localhost:" + strconv.Itoa(c.Port) + + // Establish the WebSocket connection + conn, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + // Return error if connection fails + return err + } + + // Create the ConnectionRequest + req := model.ConnectionRequest{ + Type: "connect", + Name: c.Bot.GetName(), + } + + jsonReq, err := json.Marshal(req) + if err != nil { + log.Fatal("Error while marshalling the ConnectionRequest:", err) + } + + // Send the ConnectionRequest + err = conn.WriteMessage(websocket.TextMessage, jsonReq) + if err != nil { + log.Fatal("Error while sending the ConnectionRequest:", err) + } + + // Read the raw message from the WebSocket connection + _, rawMessage, err := conn.ReadMessage() + if err != nil { + // Log and exit if an error occurs while reading the message + log.Fatal("Error while reading the ConnectionData response:", err) + } + + // Use a JSON decoder to unmarshal the raw message into the PlayState struct + decoder := json.NewDecoder(bytes.NewReader(rawMessage)) + var connData model.ConnectionData + err = decoder.Decode(&connData) + if err != nil { + log.Fatal("Error while decoding the ConnectionData response:", string(rawMessage)) + } + c.ClientId = connData.ID + + // Store the WebSocket connection + c.Conn = conn + return nil +} + +// Listen requests the current game state and send also the next bot move +func (c *Client) Listen() { + for { + req := model.StateRequest{ + Type: "getState", + ID: c.ClientId, + } + + jsonReq, err := json.Marshal(req) + if err != nil { + log.Fatal("Error while marshalling the StateRequest:", err) + } + log.Println("---> SEND GameStateRequest") + err = c.Conn.WriteMessage(websocket.TextMessage, jsonReq) + if err != nil { + log.Fatal("Error while sending the StateRequest:", err) + } + + // Read the raw message from the WebSocket connection + _, rawMessage, err := c.Conn.ReadMessage() + log.Println("<--- RECEIVE GameStateRequest") + if err != nil { + log.Fatal("Error while reading the StateData response:", err) + } + + // unmarshal the raw message into the StateData struct + var state model.StateData + if err := json.Unmarshal(rawMessage, &state); err != nil { + log.Fatal("Error while unmarshalling the StateData response:", err) + } + + switch state.GameState { + case "pending": + log.Println(" GameState: PENDING") + + case "finished": + log.Println(" GameState: FINISHED") + log.Println(" Game finished. Close connection.") + c.Close() + return + + case "playing": + log.Println(" GameState: PLAYING") + + log.Println("---> SEND next move") + nextMove := model.PlayRequest{ + Column: c.Bot.Run(&state), + ID: state.ID, + Type: "play", + } + jsonReq, err := json.Marshal(nextMove) + if err != nil { + log.Fatal("Error while marshalling the PlayRequest:", err) + } + + err = c.Conn.WriteMessage(websocket.TextMessage, jsonReq) + if err != nil { + log.Fatal("Error while sending the PlayRequest:", err) + } + + _, rawMessage, err := c.Conn.ReadMessage() + if err != nil { + log.Fatal("Error while reading the StateData response:", err) + } + if err := json.Unmarshal(rawMessage, &state); err != nil { + log.Fatal("Error while unmarshalling the StateRequest:", err) + } + + if state.GameState == "finished" { + log.Println(" GameState: FINISHED") + log.Println(" Game finished. Close connection.") + c.Close() + return + } + + default: + log.Println(" GameState: not my turn") + } + + // Relieve server and client load + time.Sleep(250 * time.Millisecond) + } +} + +// Close closes the WebSocket connection when done. +func (c *Client) Close() { + // Close the WebSocket connection + c.Conn.Close() +}