add http handler into play.go

this allows for http game creation
This commit is contained in:
Bhavnoor Singh Saroya 2025-08-18 21:31:31 -07:00
parent 442970f17c
commit e7a4362a66
2 changed files with 250 additions and 48 deletions

View file

@ -17,27 +17,45 @@ import (
"time" "time"
// adding custom stuff here // adding custom stuff here
"context"
"github.com/BattlesnakeOfficial/rules" "github.com/BattlesnakeOfficial/rules"
"github.com/BattlesnakeOfficial/rules/board" "github.com/BattlesnakeOfficial/rules/board"
"github.com/BattlesnakeOfficial/rules/client" "github.com/BattlesnakeOfficial/rules/client"
"github.com/BattlesnakeOfficial/rules/maps" "github.com/BattlesnakeOfficial/rules/maps"
"github.com/google/uuid" "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" "github.com/spf13/cobra"
log "github.com/spf13/jwalterweatherman" log "github.com/spf13/jwalterweatherman"
// adding custom stuff below here "github.com/BattlesnakeOfficial/rules/db"
"github.com/jackc/pgx/v5/pgxpool"
) )
var db *pgxpool.Pool // global database connection pool
// store frames in memory for now
// will write to db at end of game // will write to db at end of game
var frames []board.GameEvent 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 // Used to store state for each SnakeState while running a local game
type SnakeState struct { type SnakeState struct {
URL string URL string
@ -89,6 +107,172 @@ type GameState struct {
idGenerator func(int) string 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 { func NewPlayCommand() *cobra.Command {
gameState := &GameState{} gameState := &GameState{}
@ -187,6 +371,11 @@ func (gameState *GameState) Initialize() error {
// Setup and run a full game. // Setup and run a full game.
func (gameState *GameState) Run() error { 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 gameOver bool
var err error var err error
@ -216,7 +405,7 @@ func (gameState *GameState) Run() error {
boardGame := board.Game{ boardGame := board.Game{
ID: gameState.gameID, ID: gameState.gameID,
Status: "running", Status: "running", // switch to comlpeted before pushing to prod
Width: gameState.Width, Width: gameState.Width,
Height: gameState.Height, Height: gameState.Height,
Ruleset: map[string]string{ Ruleset: map[string]string{
@ -228,23 +417,24 @@ func (gameState *GameState) Run() error {
} }
boardServer := board.NewBoardServer(boardGame) boardServer := board.NewBoardServer(boardGame)
if gameState.outputFile != nil { // commented out for now, we will write to db at end of game, and we modified the functionality
// insert initial game start into database // if gameState.outputFile != nil {
// direct db call since this is the first event // // 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 { // if err != nil {
log.WARN.Printf("Failed to serialize frame event for turn%v", err) // log.WARN.Printf("Failed to serialize frame event for turn%v", err)
} else { // } else {
bytes = append(bytes, '\n') // write each event on its own line // bytes = append(bytes, '\n') // write each event on its own line
if _, err := gameState.outputFile.Write(bytes); err != nil { // if _, err := gameState.outputFile.Write(bytes); err != nil {
log.WARN.Printf("Failed to write frame event to file: %v", err) // log.WARN.Printf("Failed to write frame event to file: %v", err)
} else { // } else {
log.INFO.Printf("Wrote initial get state to output file") // log.INFO.Printf("Wrote initial get state to output file")
} // }
} // }
} // }
if gameState.ViewInBrowser { if gameState.ViewInBrowser {
serverURL, err := boardServer.Listen() serverURL, err := boardServer.Listen()
@ -253,13 +443,13 @@ func (gameState *GameState) Run() error {
} }
defer boardServer.Shutdown() defer boardServer.Shutdown()
log.INFO.Printf("Board server listening on %s", serverURL) 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.INFO.Printf("Opening board URL: %s", boardURL) // log.ERROR.Printf("Failed to open browser: %v", err)
if err := browser.OpenURL(boardURL); err != nil { // }
log.ERROR.Printf("Failed to open browser: %v", err)
}
// send turn zero to websocket server // send turn zero to websocket server
boardServer.SendEvent(gameState.buildFrameEvent(boardState)) boardServer.SendEvent(gameState.buildFrameEvent(boardState))
@ -273,6 +463,7 @@ func (gameState *GameState) Run() error {
gameState.printState(boardState) 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. // Export game first, if enabled, so that we capture the request for turn zero.
if exportGame { if exportGame {
// The output file was designed in a way so that (nearly) every entry is equivalent to a valid API request. // 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, Data: boardGame,
} }
// boardServer.SendEvent(board.GameEvent{
// EventType: board.EVENT_TYPE_GAME_END,
// Data: boardGame,
// })
boardServer.SendEvent(endEvent) boardServer.SendEvent(endEvent)
frames = append(frames, endEvent) // add game end to frames array
if gameState.outputFile != nil { if gameState.outputFile != nil {
// write frames array to db
// insert game end into database
bytes, err := json.Marshal(endEvent) bytes, err := json.Marshal(endEvent)
if err != nil { if err != nil {
// log.WARN.Printf("Unable to serialize game end event: %v", err) // 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")
} }
} }
// the magic happens here:
if exportGame { err = database.WriteInfo(ctx, boardGame, frames)
// lines, err := gameExporter.FlushToFile(gameState.outputFile) if err != nil {
log.WARN.Printf("Failed to write game end event to database: %v", err)
lines, err := gameExporter.FlushToFile(io.Discard) } else {
log.INFO.Printf("Wrote game end event to database")
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)
} }
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 return nil
} }
func (gameState *GameState) initializeBoardFromArgs() (bool, *rules.BoardState, error) { func (gameState *GameState) initializeBoardFromArgs() (bool, *rules.BoardState, error) {
@ -844,10 +1042,12 @@ func (gameState *GameState) buildFrameEvent(boardState *rules.BoardState) board.
Data: gameFrame, Data: gameFrame,
} }
frames = append(frames, gameEvent)
// === New: Write frame event to output file if set === // === New: Write frame event to output file if set ===
if gameState.outputFile != nil { if gameState.outputFile != nil {
// add frame event to array for now, will write to db at end of game // 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) bytes, err := json.Marshal(gameEvent)
if err != nil { if err != nil {

View file

@ -22,7 +22,9 @@ var rootCmd = &cobra.Command{
} }
func Execute() { func Execute() {
rootCmd.AddCommand(NewPlayCommand()) rootCmd.AddCommand(NewPlayCommand())
rootCmd.AddCommand(NewHostCommand())
mapCommand := NewMapCommand() mapCommand := NewMapCommand()
mapCommand.AddCommand(NewMapListCommand()) mapCommand.AddCommand(NewMapListCommand())