From e7a4362a66bfd1778cf7a929a3066abe1ea94abd Mon Sep 17 00:00:00 2001 From: Bhavnoor Singh Saroya Date: Mon, 18 Aug 2025 21:31:31 -0700 Subject: [PATCH] add http handler into play.go this allows for http game creation --- cli/commands/play.go | 296 ++++++++++++++++++++++++++++++++++++------- cli/commands/root.go | 2 + 2 files changed, 250 insertions(+), 48 deletions(-) diff --git a/cli/commands/play.go b/cli/commands/play.go index 3224bd5..f43007f 100644 --- a/cli/commands/play.go +++ b/cli/commands/play.go @@ -17,27 +17,45 @@ import ( "time" // adding custom stuff here + "context" "github.com/BattlesnakeOfficial/rules" "github.com/BattlesnakeOfficial/rules/board" "github.com/BattlesnakeOfficial/rules/client" "github.com/BattlesnakeOfficial/rules/maps" "github.com/google/uuid" - _ "github.com/mattn/go-sqlite3" // the SQLite driver (underscore means we only init it) - "github.com/pkg/browser" + + // _ "github.com/mattn/go-sqlite3" // the SQLite driver for local development + "github.com/spf13/cobra" log "github.com/spf13/jwalterweatherman" - // adding custom stuff below here - "github.com/jackc/pgx/v5/pgxpool" + "github.com/BattlesnakeOfficial/rules/db" ) -var db *pgxpool.Pool // global database connection pool - -// store frames in memory for now // will write to db at end of game var frames []board.GameEvent +func setupDb(dsn string) *db.Database { + // Replace with your Postgres DSN or set DATABASE_URL + + if dsn == "" { + print("Using default DSN for local development. Please set DATABASE_URL in production.\n") + dsn = "removeditdidnotpushtoproductionhaha" // DO NOT PUSH TO PRODUCTION + + } + + // Connect to DB + database, err := db.Connect(dsn) + if err != nil { + log.ERROR.Fatalf("Failed to connect: %v", err) + } + + print("SUCCESS: Connected to database\n") + // os.Exit(0) // Exit after successful connection for testing purposes + return database +} + // Used to store state for each SnakeState while running a local game type SnakeState struct { URL string @@ -89,6 +107,172 @@ type GameState struct { idGenerator func(int) string } +var ( + activeGame *GameState + // Mutex to protect activeGame from concurrent access + activeGameMutex sync.Mutex +) + +func NewHostCommand() *cobra.Command { + + var hostCmd = &cobra.Command{ + Use: "host", + Short: "Host an HTTP server to start Battlesnake games via /play endpoint.", + Long: "Runs an HTTP server that accepts /play requests with query parameters instead of CLI flags. Each request will initialize and run a new game in the background.", + Run: func(cmd *cobra.Command, args []string) { + http.HandleFunc("/play", func(w http.ResponseWriter, r *http.Request) { + activeGameMutex.Lock() + + defer activeGameMutex.Unlock() + + if activeGame != nil { + log.WARN.Print("A game is already running. Please wait for it to finish before starting a new one.") + w.WriteHeader(http.StatusConflict) // 409 Conflict + json.NewEncoder(w).Encode(map[string]string{ + "status": "busy", + "message": "A game is already running. Please wait for it to finish before starting a new one.", + }) + return + } + + // Create a new game state with defaults + gameState := &GameState{ + Width: 11, + Height: 11, + Timeout: 500, + GameType: "standard", + MapName: "standard", + Seed: time.Now().UTC().UnixNano(), + TurnDelay: 0, + TurnDuration: 0, + OutputPath: "", + ViewInBrowser: true, + BoardURL: "https://board.battlesnake.com", + FoodSpawnChance: 15, + MinimumFood: 1, + HazardDamagePerTurn: 14, + ShrinkEveryNTurns: 25, + } + + q := r.URL.Query() + + // Map query params to fields + if v := q.Get("width"); v != "" { + if i, err := strconv.Atoi(v); err == nil { + gameState.Width = i + } + } + if v := q.Get("height"); v != "" { + if i, err := strconv.Atoi(v); err == nil { + gameState.Height = i + } + } + if v := q["name"]; len(v) > 0 { + gameState.Names = v + } + if v := q["url"]; len(v) > 0 { + gameState.URLs = v + } + if v := q.Get("timeout"); v != "" { + if i, err := strconv.Atoi(v); err == nil { + gameState.Timeout = i + } + } + if v := q.Get("sequential"); v == "true" { + gameState.Sequential = true + } + if v := q.Get("gametype"); v != "" { + gameState.GameType = v + } + if v := q.Get("map"); v != "" { + gameState.MapName = v + } + if v := q.Get("viewmap"); v == "true" { + gameState.ViewMap = true + } + if v := q.Get("color"); v == "true" { + gameState.UseColor = true + } + if v := q.Get("seed"); v != "" { + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + gameState.Seed = i + } + } + if v := q.Get("delay"); v != "" { + if i, err := strconv.Atoi(v); err == nil { + gameState.TurnDelay = i + } + } + if v := q.Get("duration"); v != "" { + if i, err := strconv.Atoi(v); err == nil { + gameState.TurnDuration = i + } + } + if v := q.Get("output"); v != "" { + gameState.OutputPath = v + } + if v := q.Get("browser"); v == "true" { + gameState.ViewInBrowser = true + } + if v := q.Get("board-url"); v != "" { + gameState.BoardURL = v + } + if v := q.Get("foodSpawnChance"); v != "" { + if i, err := strconv.Atoi(v); err == nil { + gameState.FoodSpawnChance = i + } + } + if v := q.Get("minimumFood"); v != "" { + if i, err := strconv.Atoi(v); err == nil { + gameState.MinimumFood = i + } + } + if v := q.Get("hazardDamagePerTurn"); v != "" { + if i, err := strconv.Atoi(v); err == nil { + gameState.HazardDamagePerTurn = i + } + } + if v := q.Get("shrinkEveryNTurns"); v != "" { + if i, err := strconv.Atoi(v); err == nil { + gameState.ShrinkEveryNTurns = i + } + } + + if err := gameState.Initialize(); err != nil { + log.FATAL.Printf("Error initializing game: %v", err) + http.Error(w, fmt.Sprintf("failed to initialize: %v", err), http.StatusBadRequest) + return + } + + // Set active game + activeGame = gameState + + // Run game asynchronously + go func(gs *GameState) { + + if err := gs.Run(); err != nil { + log.FATAL.Printf("Error running game: %v", err) + } + }(gameState) + + w.Header().Set("Content-Type", "application/json") + + w.WriteHeader(http.StatusOK) + resp := map[string]string{ + "id": gameState.gameID, + } + print("gameState.gameID", gameState.gameID) + json.NewEncoder(w).Encode(resp) + }) + + log.INFO.Print("Hosting Battlesnake HTTP server on :6969") + log.INFO.Print(http.ListenAndServe(":6969", nil)) + }, + } + + return hostCmd +} + func NewPlayCommand() *cobra.Command { gameState := &GameState{} @@ -187,6 +371,11 @@ func (gameState *GameState) Initialize() error { // Setup and run a full game. func (gameState *GameState) Run() error { + + var dbconnection string = os.Getenv("DATABASE_URL") + var ctx context.Context = context.Background() + var database *db.Database = setupDb(dbconnection) + defer database.Pool.Close() var gameOver bool var err error @@ -216,7 +405,7 @@ func (gameState *GameState) Run() error { boardGame := board.Game{ ID: gameState.gameID, - Status: "running", + Status: "running", // switch to comlpeted before pushing to prod Width: gameState.Width, Height: gameState.Height, Ruleset: map[string]string{ @@ -228,23 +417,24 @@ func (gameState *GameState) Run() error { } boardServer := board.NewBoardServer(boardGame) - if gameState.outputFile != nil { - // insert initial game start into database - // direct db call since this is the first event + // commented out for now, we will write to db at end of game, and we modified the functionality + // if gameState.outputFile != nil { + // // insert initial game start into database + // // direct db call since this is the first event - bytes, err := json.Marshal(boardGame) + // bytes, err := json.Marshal(boardGame) // remove this later - if err != nil { - log.WARN.Printf("Failed to serialize frame event for turn%v", err) - } else { - bytes = append(bytes, '\n') // write each event on its own line - if _, err := gameState.outputFile.Write(bytes); err != nil { - log.WARN.Printf("Failed to write frame event to file: %v", err) - } else { - log.INFO.Printf("Wrote initial get state to output file") - } - } - } + // if err != nil { + // log.WARN.Printf("Failed to serialize frame event for turn%v", err) + // } else { + // bytes = append(bytes, '\n') // write each event on its own line + // if _, err := gameState.outputFile.Write(bytes); err != nil { + // log.WARN.Printf("Failed to write frame event to file: %v", err) + // } else { + // log.INFO.Printf("Wrote initial get state to output file") + // } + // } + // } if gameState.ViewInBrowser { serverURL, err := boardServer.Listen() @@ -253,13 +443,13 @@ func (gameState *GameState) Run() error { } defer boardServer.Shutdown() log.INFO.Printf("Board server listening on %s", serverURL) + // no need to open the browser automatically since our primary use case is to run this headlessly + // boardURL := fmt.Sprintf(gameState.BoardURL+"?engine=%s&game=%s&autoplay=true", serverURL, gameState.gameID) - boardURL := fmt.Sprintf(gameState.BoardURL+"?engine=%s&game=%s&autoplay=true", serverURL, gameState.gameID) - - log.INFO.Printf("Opening board URL: %s", boardURL) - if err := browser.OpenURL(boardURL); err != nil { - log.ERROR.Printf("Failed to open browser: %v", err) - } + // log.INFO.Printf("Opening board URL: %s", boardURL) + // if err := browser.OpenURL(boardURL); err != nil { + // log.ERROR.Printf("Failed to open browser: %v", err) + // } // send turn zero to websocket server boardServer.SendEvent(gameState.buildFrameEvent(boardState)) @@ -273,6 +463,7 @@ func (gameState *GameState) Run() error { gameState.printState(boardState) } + // Bhavnoor's notes: This is straight up a lie, the default output is nothing like a valid API request. // Export game first, if enabled, so that we capture the request for turn zero. if exportGame { // The output file was designed in a way so that (nearly) every entry is equivalent to a valid API request. @@ -364,17 +555,11 @@ func (gameState *GameState) Run() error { Data: boardGame, } - // boardServer.SendEvent(board.GameEvent{ - // EventType: board.EVENT_TYPE_GAME_END, - // Data: boardGame, - // }) - boardServer.SendEvent(endEvent) + frames = append(frames, endEvent) // add game end to frames array if gameState.outputFile != nil { - // write frames array to db - // insert game end into database bytes, err := json.Marshal(endEvent) if err != nil { // log.WARN.Printf("Unable to serialize game end event: %v", err) @@ -386,23 +571,36 @@ func (gameState *GameState) Run() error { } } - // // log.INFO.Printf("wrote frame event for game end") } } - - if exportGame { - // lines, err := gameExporter.FlushToFile(gameState.outputFile) - - lines, err := gameExporter.FlushToFile(io.Discard) - - if err != nil { - return fmt.Errorf("Unable to export game: %w", err) - } - log.INFO.Printf("Fake Wrote %d lines to output file: %s", lines, gameState.OutputPath) + // the magic happens here: + err = database.WriteInfo(ctx, boardGame, frames) + if err != nil { + log.WARN.Printf("Failed to write game end event to database: %v", err) + } else { + log.INFO.Printf("Wrote game end event to database") } + activeGameMutex.Lock() + activeGame = nil + activeGameMutex.Unlock() + + // if exportGame { + // // lines, err := gameExporter.FlushToFile(gameState.outputFile) + + // lines, err := gameExporter.FlushToFile(io.Discard) + + // if err != nil { + // return fmt.Errorf("Unable to export game: %w", err) + // } + // log.INFO.Printf("Fake Wrote %d lines to output file: %s", lines, gameState.OutputPath) + // } + + // return nil + // os.Exit(0) // Exit after successful run for testing purposes return nil + } func (gameState *GameState) initializeBoardFromArgs() (bool, *rules.BoardState, error) { @@ -844,10 +1042,12 @@ func (gameState *GameState) buildFrameEvent(boardState *rules.BoardState) board. Data: gameFrame, } + frames = append(frames, gameEvent) + // === New: Write frame event to output file if set === if gameState.outputFile != nil { // add frame event to array for now, will write to db at end of game - frames = append(frames, gameEvent) + // frames = append(frames, gameEvent) bytes, err := json.Marshal(gameEvent) if err != nil { diff --git a/cli/commands/root.go b/cli/commands/root.go index 72bab2b..c44b61c 100644 --- a/cli/commands/root.go +++ b/cli/commands/root.go @@ -22,7 +22,9 @@ var rootCmd = &cobra.Command{ } func Execute() { + rootCmd.AddCommand(NewPlayCommand()) + rootCmd.AddCommand(NewHostCommand()) mapCommand := NewMapCommand() mapCommand.AddCommand(NewMapListCommand())