DEV 559: Refactor CLI and add customizations (#57)

* move snake API structs into a new client package

* add customizations to snake objects

* refactor and add support for passing snake customizations in games
This commit is contained in:
Rob O'Dwyer 2021-11-25 14:07:56 -08:00 committed by GitHub
parent 6140f232c2
commit 4a9dbbcaef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 665 additions and 197 deletions

View file

@ -15,100 +15,26 @@ import (
"time" "time"
"github.com/BattlesnakeOfficial/rules" "github.com/BattlesnakeOfficial/rules"
"github.com/BattlesnakeOfficial/rules/client"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
type Battlesnake struct { // Used to store state for each SnakeState while running a local game
type SnakeState struct {
URL string URL string
Name string Name string
ID string ID string
API string
LastMove string LastMove string
Squad string Squad string
Character rune Character rune
} Color string
Head string
type Coord struct { Tail string
X int32 `json:"x"`
Y int32 `json:"y"`
}
type SnakeResponse struct {
Id string `json:"id"`
Name string `json:"name"`
Health int32 `json:"health"`
Body []Coord `json:"body"`
Latency string `json:"latency"`
Head Coord `json:"head"`
Length int32 `json:"length"`
Shout string `json:"shout"`
Squad string `json:"squad"`
}
type BoardResponse struct {
Height int32 `json:"height"`
Width int32 `json:"width"`
Food []Coord `json:"food"`
Hazards []Coord `json:"hazards"`
Snakes []SnakeResponse `json:"snakes"`
}
type GameResponseRulesetSettings struct {
HazardDamagePerTurn int32 `json:"hazardDamagePerTurn"`
FoodSpawnChance int32 `json:"foodSpawnChance"`
MinimumFood int32 `json:"minimumFood"`
RoyaleSettings RoyaleSettings `json:"royale"`
SquadSettings SquadSettings `json:"squad"`
}
type RoyaleSettings struct {
ShrinkEveryNTurns int32 `json:"shrinkEveryNTurns"`
}
type SquadSettings struct {
AllowBodyCollisions bool `json:"allowBodyCollisions"`
SharedElimination bool `json:"sharedElimination"`
SharedHealth bool `json:"sharedHealth"`
SharedLength bool `json:"sharedLength"`
}
type GameResponseRuleset struct {
Name string `json:"name"`
Version string `json:"version"`
Settings GameResponseRulesetSettings `json:"settings"`
}
type GameResponse struct {
Id string `json:"id"`
Timeout int32 `json:"timeout"`
Ruleset GameResponseRuleset `json:"ruleset"`
}
type ResponsePayload struct {
Game GameResponse `json:"game"`
Turn int32 `json:"turn"`
Board BoardResponse `json:"board"`
You SnakeResponse `json:"you"`
}
type PlayerResponse struct {
Move string `json:"move"`
Shout string `json:"shout"`
}
type PingResponse struct {
APIVersion string `json:"apiversion"`
Author string `json:"author"`
Color string `json:"color"`
Head string `json:"head"`
Tail string `json:"tail"`
Version string `json:"version"`
} }
var GameId string var GameId string
var Turn int32 var Turn int32
var Battlesnakes map[string]Battlesnake
var HttpClient http.Client var HttpClient http.Client
var Width int32 var Width int32
var Height int32 var Height int32
@ -162,24 +88,20 @@ func init() {
var run = func(cmd *cobra.Command, args []string) { var run = func(cmd *cobra.Command, args []string) {
rand.Seed(Seed) rand.Seed(Seed)
Battlesnakes = make(map[string]Battlesnake)
GameId = uuid.New().String() GameId = uuid.New().String()
Turn = 0 Turn = 0
snakes := buildSnakesFromOptions() snakeStates := buildSnakesFromOptions()
ruleset := getRuleset(Seed, snakes) ruleset := getRuleset(Seed, snakeStates)
state := initializeBoardFromArgs(ruleset, snakes) state := initializeBoardFromArgs(ruleset, snakeStates)
for _, snake := range snakes {
Battlesnakes[snake.ID] = snake
}
for v := false; !v; v, _ = ruleset.IsGameOver(state) { for v := false; !v; v, _ = ruleset.IsGameOver(state) {
Turn++ Turn++
state = createNextBoardState(ruleset, state, snakes, Turn) state = createNextBoardState(ruleset, state, snakeStates, Turn)
if ViewMap { if ViewMap {
printMap(state, Turn) printMap(state, snakeStates, Turn)
} else { } else {
log.Printf("[%v]: State: %v\n", Turn, state) log.Printf("[%v]: State: %v\n", Turn, state)
} }
@ -197,9 +119,9 @@ var run = func(cmd *cobra.Command, args []string) {
for _, snake := range state.Snakes { for _, snake := range state.Snakes {
if snake.EliminatedCause == rules.NotEliminated { if snake.EliminatedCause == rules.NotEliminated {
isDraw = false isDraw = false
winner = Battlesnakes[snake.ID].Name winner = snakeStates[snake.ID].Name
} }
sendEndRequest(ruleset, state, Battlesnakes[snake.ID]) sendEndRequest(ruleset, state, snakeStates[snake.ID], snakeStates)
} }
if isDraw { if isDraw {
@ -210,7 +132,7 @@ var run = func(cmd *cobra.Command, args []string) {
} }
} }
func getRuleset(seed int64, snakes []Battlesnake) rules.Ruleset { func getRuleset(seed int64, snakeStates map[string]SnakeState) rules.Ruleset {
var ruleset rules.Ruleset var ruleset rules.Ruleset
var royale rules.RoyaleRuleset var royale rules.RoyaleRuleset
@ -231,8 +153,8 @@ func getRuleset(seed int64, snakes []Battlesnake) rules.Ruleset {
ruleset = &royale ruleset = &royale
case "squad": case "squad":
squadMap := map[string]string{} squadMap := map[string]string{}
for _, snake := range snakes { for _, snakeState := range snakeStates {
squadMap[snake.ID] = snake.Squad squadMap[snakeState.ID] = snakeState.Squad
} }
ruleset = &rules.SquadRuleset{ ruleset = &rules.SquadRuleset{
StandardRuleset: standard, StandardRuleset: standard,
@ -260,7 +182,7 @@ func getRuleset(seed int64, snakes []Battlesnake) rules.Ruleset {
return ruleset return ruleset
} }
func initializeBoardFromArgs(ruleset rules.Ruleset, snakes []Battlesnake) *rules.BoardState { func initializeBoardFromArgs(ruleset rules.Ruleset, snakeStates map[string]SnakeState) *rules.BoardState {
if Timeout == 0 { if Timeout == 0 {
Timeout = 500 Timeout = 500
} }
@ -269,8 +191,8 @@ func initializeBoardFromArgs(ruleset rules.Ruleset, snakes []Battlesnake) *rules
} }
snakeIds := []string{} snakeIds := []string{}
for _, snake := range snakes { for _, snakeState := range snakeStates {
snakeIds = append(snakeIds, snake.ID) snakeIds = append(snakeIds, snakeState.ID)
} }
state, err := rules.CreateDefaultBoardState(Width, Height, snakeIds) state, err := rules.CreateDefaultBoardState(Width, Height, snakeIds)
if err != nil { if err != nil {
@ -281,9 +203,9 @@ func initializeBoardFromArgs(ruleset rules.Ruleset, snakes []Battlesnake) *rules
log.Panic("[PANIC]: Error Initializing Board State") log.Panic("[PANIC]: Error Initializing Board State")
} }
for _, snake := range snakes { for _, snakeState := range snakeStates {
requestBody := getIndividualBoardStateForSnake(state, snake, ruleset) requestBody := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
u, _ := url.ParseRequestURI(snake.URL) u, _ := url.ParseRequestURI(snakeState.URL)
u.Path = path.Join(u.Path, "start") u.Path = path.Join(u.Path, "start")
if DebugRequests { if DebugRequests {
log.Printf("POST %s: %v", u, string(requestBody)) log.Printf("POST %s: %v", u, string(requestBody))
@ -296,25 +218,28 @@ func initializeBoardFromArgs(ruleset rules.Ruleset, snakes []Battlesnake) *rules
return state return state
} }
func createNextBoardState(ruleset rules.Ruleset, state *rules.BoardState, snakes []Battlesnake, turn int32) *rules.BoardState { func createNextBoardState(ruleset rules.Ruleset, state *rules.BoardState, snakeStates map[string]SnakeState, turn int32) *rules.BoardState {
var moves []rules.SnakeMove var moves []rules.SnakeMove
if Sequential { if Sequential {
for _, snake := range snakes { for _, snakeState := range snakeStates {
for _, stateSnake := range state.Snakes { for _, snake := range state.Snakes {
if snake.ID == stateSnake.ID && stateSnake.EliminatedCause == rules.NotEliminated { if snakeState.ID == snake.ID && snake.EliminatedCause == rules.NotEliminated {
moves = append(moves, getMoveForSnake(ruleset, state, snake)) moves = append(moves, getMoveForSnake(ruleset, state, snakeState, snakeStates))
} }
} }
} }
} else { } else {
var wg sync.WaitGroup var wg sync.WaitGroup
c := make(chan rules.SnakeMove, len(snakes)) c := make(chan rules.SnakeMove, len(snakeStates))
for _, snake := range snakes { for _, snakeState := range snakeStates {
for _, stateSnake := range state.Snakes { for _, snake := range state.Snakes {
if snake.ID == stateSnake.ID && stateSnake.EliminatedCause == rules.NotEliminated { if snakeState.ID == snake.ID && snake.EliminatedCause == rules.NotEliminated {
wg.Add(1) wg.Add(1)
go getConcurrentMoveForSnake(&wg, ruleset, state, snake, c) go func(snakeState SnakeState) {
defer wg.Done()
c <- getMoveForSnake(ruleset, state, snakeState, snakeStates)
}(snakeState)
} }
} }
} }
@ -327,14 +252,13 @@ func createNextBoardState(ruleset rules.Ruleset, state *rules.BoardState, snakes
} }
} }
for _, move := range moves { for _, move := range moves {
snake := Battlesnakes[move.ID] snakeState := snakeStates[move.ID]
snake.LastMove = move.Move snakeState.LastMove = move.Move
Battlesnakes[move.ID] = snake snakeStates[move.ID] = snakeState
} }
state, err := ruleset.CreateNextBoardState(state, moves) state, err := ruleset.CreateNextBoardState(state, moves)
if err != nil { if err != nil {
log.Panic("[PANIC]: Error Producing Next Board State") log.Panicf("[PANIC]: Error Producing Next Board State: %v", err)
panic(err)
} }
state.Turn = turn state.Turn = turn
@ -342,20 +266,15 @@ func createNextBoardState(ruleset rules.Ruleset, state *rules.BoardState, snakes
return state return state
} }
func getConcurrentMoveForSnake(wg *sync.WaitGroup, ruleset rules.Ruleset, state *rules.BoardState, snake Battlesnake, c chan rules.SnakeMove) { func getMoveForSnake(ruleset rules.Ruleset, state *rules.BoardState, snakeState SnakeState, snakeStates map[string]SnakeState) rules.SnakeMove {
defer wg.Done() requestBody := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
c <- getMoveForSnake(ruleset, state, snake) u, _ := url.ParseRequestURI(snakeState.URL)
}
func getMoveForSnake(ruleset rules.Ruleset, state *rules.BoardState, snake Battlesnake) rules.SnakeMove {
requestBody := getIndividualBoardStateForSnake(state, snake, ruleset)
u, _ := url.ParseRequestURI(snake.URL)
u.Path = path.Join(u.Path, "move") u.Path = path.Join(u.Path, "move")
if DebugRequests { if DebugRequests {
log.Printf("POST %s: %v", u, string(requestBody)) log.Printf("POST %s: %v", u, string(requestBody))
} }
res, err := HttpClient.Post(u.String(), "application/json", bytes.NewBuffer(requestBody)) res, err := HttpClient.Post(u.String(), "application/json", bytes.NewBuffer(requestBody))
move := snake.LastMove move := snakeState.LastMove
if err != nil { if err != nil {
log.Printf("[WARN]: Request to %v failed\n", u.String()) log.Printf("[WARN]: Request to %v failed\n", u.String())
log.Printf("Body --> %v\n", string(requestBody)) log.Printf("Body --> %v\n", string(requestBody))
@ -365,7 +284,7 @@ func getMoveForSnake(ruleset rules.Ruleset, state *rules.BoardState, snake Battl
if readErr != nil { if readErr != nil {
log.Fatal(readErr) log.Fatal(readErr)
} else { } else {
playerResponse := PlayerResponse{} playerResponse := client.MoveResponse{}
jsonErr := json.Unmarshal(body, &playerResponse) jsonErr := json.Unmarshal(body, &playerResponse)
if jsonErr != nil { if jsonErr != nil {
log.Fatal(jsonErr) log.Fatal(jsonErr)
@ -374,12 +293,12 @@ func getMoveForSnake(ruleset rules.Ruleset, state *rules.BoardState, snake Battl
} }
} }
} }
return rules.SnakeMove{ID: snake.ID, Move: move} return rules.SnakeMove{ID: snakeState.ID, Move: move}
} }
func sendEndRequest(ruleset rules.Ruleset, state *rules.BoardState, snake Battlesnake) { func sendEndRequest(ruleset rules.Ruleset, state *rules.BoardState, snakeState SnakeState, snakeStates map[string]SnakeState) {
requestBody := getIndividualBoardStateForSnake(state, snake, ruleset) requestBody := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
u, _ := url.ParseRequestURI(snake.URL) u, _ := url.ParseRequestURI(snakeState.URL)
u.Path = path.Join(u.Path, "end") u.Path = path.Join(u.Path, "end")
if DebugRequests { if DebugRequests {
log.Printf("POST %s: %v", u, string(requestBody)) log.Printf("POST %s: %v", u, string(requestBody))
@ -390,26 +309,26 @@ func sendEndRequest(ruleset rules.Ruleset, state *rules.BoardState, snake Battle
} }
} }
func getIndividualBoardStateForSnake(state *rules.BoardState, snake Battlesnake, ruleset rules.Ruleset) []byte { func getIndividualBoardStateForSnake(state *rules.BoardState, snakeState SnakeState, snakeStates map[string]SnakeState, ruleset rules.Ruleset) []byte {
var youSnake rules.Snake var youSnake rules.Snake
for _, snk := range state.Snakes { for _, snk := range state.Snakes {
if snake.ID == snk.ID { if snakeState.ID == snk.ID {
youSnake = snk youSnake = snk
break break
} }
} }
response := ResponsePayload{ request := client.SnakeRequest{
Game: GameResponse{Id: GameId, Timeout: Timeout, Ruleset: GameResponseRuleset{ Game: client.Game{ID: GameId, Timeout: Timeout, Ruleset: client.Ruleset{
Name: ruleset.Name(), Name: ruleset.Name(),
Version: "cli", // TODO: Use GitHub Release Version Version: "cli", // TODO: Use GitHub Release Version
Settings: GameResponseRulesetSettings{ Settings: client.RulesetSettings{
HazardDamagePerTurn: HazardDamagePerTurn, HazardDamagePerTurn: HazardDamagePerTurn,
FoodSpawnChance: FoodSpawnChance, FoodSpawnChance: FoodSpawnChance,
MinimumFood: MinimumFood, MinimumFood: MinimumFood,
RoyaleSettings: RoyaleSettings{ RoyaleSettings: client.RoyaleSettings{
ShrinkEveryNTurns: ShrinkEveryNTurns, ShrinkEveryNTurns: ShrinkEveryNTurns,
}, },
SquadSettings: SquadSettings{ SquadSettings: client.SquadSettings{
AllowBodyCollisions: true, AllowBodyCollisions: true,
SharedElimination: true, SharedElimination: true,
SharedHealth: true, SharedHealth: true,
@ -418,63 +337,56 @@ func getIndividualBoardStateForSnake(state *rules.BoardState, snake Battlesnake,
}, },
}}, }},
Turn: Turn, Turn: Turn,
Board: BoardResponse{ Board: client.Board{
Height: state.Height, Height: state.Height,
Width: state.Width, Width: state.Width,
Food: coordFromPointArray(state.Food), Food: client.CoordFromPointArray(state.Food),
Hazards: coordFromPointArray(state.Hazards), Hazards: client.CoordFromPointArray(state.Hazards),
Snakes: buildSnakesResponse(state.Snakes), Snakes: convertRulesSnakes(state.Snakes, snakeStates),
}, },
You: snakeResponseFromSnake(youSnake), You: convertRulesSnake(youSnake, snakeStates[youSnake.ID]),
} }
responseJson, err := json.Marshal(response) requestJSON, err := json.Marshal(request)
if err != nil { if err != nil {
log.Panic("[PANIC]: Error Marshalling JSON from State") log.Panic("[PANIC]: Error Marshalling JSON from State")
panic(err) panic(err)
} }
return responseJson return requestJSON
} }
func snakeResponseFromSnake(snake rules.Snake) SnakeResponse { func convertRulesSnake(snake rules.Snake, snakeState SnakeState) client.Snake {
return SnakeResponse{ return client.Snake{
Id: snake.ID, ID: snake.ID,
Name: Battlesnakes[snake.ID].Name, Name: snakeState.Name,
Health: snake.Health, Health: snake.Health,
Body: coordFromPointArray(snake.Body), Body: client.CoordFromPointArray(snake.Body),
Latency: "0", Latency: "0",
Head: coordFromPoint(snake.Body[0]), Head: client.CoordFromPoint(snake.Body[0]),
Length: int32(len(snake.Body)), Length: int32(len(snake.Body)),
Shout: "", Shout: "",
Squad: Battlesnakes[snake.ID].Squad, Squad: snakeState.Squad,
Customizations: client.Customizations{
Head: snakeState.Head,
Tail: snakeState.Tail,
Color: snakeState.Color,
},
} }
} }
func buildSnakesResponse(snakes []rules.Snake) []SnakeResponse { func convertRulesSnakes(snakes []rules.Snake, snakeStates map[string]SnakeState) []client.Snake {
var a []SnakeResponse var a []client.Snake
for _, snake := range snakes { for _, snake := range snakes {
if snake.EliminatedCause == rules.NotEliminated { if snake.EliminatedCause == rules.NotEliminated {
a = append(a, snakeResponseFromSnake(snake)) a = append(a, convertRulesSnake(snake, snakeStates[snake.ID]))
} }
} }
return a return a
} }
func coordFromPoint(pt rules.Point) Coord { func buildSnakesFromOptions() map[string]SnakeState {
return Coord{X: pt.X, Y: pt.Y}
}
func coordFromPointArray(ptArray []rules.Point) []Coord {
a := make([]Coord, 0)
for _, pt := range ptArray {
a = append(a, coordFromPoint(pt))
}
return a
}
func buildSnakesFromOptions() []Battlesnake {
bodyChars := []rune{'■', '⌀', '●', '⍟', '◘', '☺', '□', '☻'} bodyChars := []rune{'■', '⌀', '●', '⍟', '◘', '☺', '□', '☻'}
var numSnakes int var numSnakes int
var snakes []Battlesnake snakes := map[string]SnakeState{}
numNames := len(Names) numNames := len(Names)
numURLs := len(URLs) numURLs := len(URLs)
numSquads := len(Squads) numSquads := len(Squads)
@ -521,10 +433,12 @@ func buildSnakesFromOptions() []Battlesnake {
snakeSquad = strconv.Itoa(i / 2) snakeSquad = strconv.Itoa(i / 2)
} }
} }
snakeState := SnakeState{
Name: snakeName, URL: snakeURL, ID: id, LastMove: "up", Character: bodyChars[i%8],
}
res, err := HttpClient.Get(snakeURL) res, err := HttpClient.Get(snakeURL)
api := "0"
if err != nil { if err != nil {
log.Printf("[WARN]: Request to %v failed", snakeURL) log.Printf("[WARN]: Request to %v failed: %v", snakeURL, err)
} else if res.Body != nil { } else if res.Body != nil {
defer res.Body.Close() defer res.Body.Close()
body, readErr := ioutil.ReadAll(res.Body) body, readErr := ioutil.ReadAll(res.Body)
@ -532,24 +446,25 @@ func buildSnakesFromOptions() []Battlesnake {
log.Fatal(readErr) log.Fatal(readErr)
} }
pingResponse := PingResponse{} pingResponse := client.SnakeMetadataResponse{}
jsonErr := json.Unmarshal(body, &pingResponse) jsonErr := json.Unmarshal(body, &pingResponse)
if jsonErr != nil { if jsonErr != nil {
log.Fatal(jsonErr) log.Printf("Error reading response from %v: %v", snakeURL, jsonErr)
} else { } else {
api = pingResponse.APIVersion snakeState.Head = pingResponse.Head
snakeState.Tail = pingResponse.Tail
snakeState.Color = pingResponse.Color
} }
} }
snake := Battlesnake{Name: snakeName, URL: snakeURL, ID: id, API: api, LastMove: "up", Character: bodyChars[i%8]}
if GameType == "squad" { if GameType == "squad" {
snake.Squad = snakeSquad snakeState.Squad = snakeSquad
} }
snakes = append(snakes, snake) snakes[snakeState.ID] = snakeState
} }
return snakes return snakes
} }
func printMap(state *rules.BoardState, gameTurn int32) { func printMap(state *rules.BoardState, snakeStates map[string]SnakeState, gameTurn int32) {
var o bytes.Buffer var o bytes.Buffer
o.WriteString(fmt.Sprintf("Ruleset: %s, Seed: %d, Turn: %v\n", GameType, Seed, gameTurn)) o.WriteString(fmt.Sprintf("Ruleset: %s, Seed: %d, Turn: %v\n", GameType, Seed, gameTurn))
board := make([][]rune, state.Width) board := make([][]rune, state.Width)
@ -572,10 +487,10 @@ func printMap(state *rules.BoardState, gameTurn int32) {
for _, s := range state.Snakes { for _, s := range state.Snakes {
for _, b := range s.Body { for _, b := range s.Body {
if b.X >= 0 && b.X < state.Width && b.Y >= 0 && b.Y < state.Height { if b.X >= 0 && b.X < state.Width && b.Y >= 0 && b.Y < state.Height {
board[b.X][b.Y] = Battlesnakes[s.ID].Character board[b.X][b.Y] = snakeStates[s.ID].Character
} }
} }
o.WriteString(fmt.Sprintf("%v %c: %v\n", Battlesnakes[s.ID].Name, Battlesnakes[s.ID].Character, s)) o.WriteString(fmt.Sprintf("%v %c: %v\n", snakeStates[s.ID].Name, snakeStates[s.ID].Character, s))
} }
for y := state.Height - 1; y >= 0; y-- { for y := state.Height - 1; y >= 0; y-- {
for x := int32(0); x < state.Width; x++ { for x := int32(0); x < state.Width; x++ {

View file

@ -4,6 +4,7 @@ import (
"testing" "testing"
"github.com/BattlesnakeOfficial/rules" "github.com/BattlesnakeOfficial/rules"
"github.com/BattlesnakeOfficial/rules/test"
) )
func TestGetIndividualBoardStateForSnake(t *testing.T) { func TestGetIndividualBoardStateForSnake(t *testing.T) {
@ -14,8 +15,27 @@ func TestGetIndividualBoardStateForSnake(t *testing.T) {
Width: 11, Width: 11,
Snakes: []rules.Snake{s1, s2}, Snakes: []rules.Snake{s1, s2},
} }
snake := Battlesnake{Name: "one", URL: "", ID: "one"} s1State := SnakeState{
requestBody := getIndividualBoardStateForSnake(state, snake, &rules.StandardRuleset{}) ID: "one",
Name: "ONE",
rules.RequireJSONMatchesFixture(t, "testdata/snake_request_body.json", string(requestBody)) URL: "http://example1.com",
Head: "safe",
Tail: "curled",
Color: "#123456",
}
s2State := SnakeState{
ID: "two",
Name: "TWO",
URL: "http://example2.com",
Head: "silly",
Tail: "bolt",
Color: "#654321",
}
snakeStates := map[string]SnakeState{
s1State.ID: s1State,
s2State.ID: s2State,
}
requestBody := getIndividualBoardStateForSnake(state, s1State, snakeStates, &rules.StandardRuleset{})
test.RequireJSONMatchesFixture(t, "testdata/snake_request_body.json", string(requestBody))
} }

View file

@ -1,14 +1,13 @@
{ {
"game": { "game": {
"id": "", "id": "",
"timeout": 500,
"ruleset": { "ruleset": {
"name": "standard", "name": "standard",
"version": "cli", "version": "cli",
"settings": { "settings": {
"hazardDamagePerTurn": 14,
"foodSpawnChance": 15, "foodSpawnChance": 15,
"minimumFood": 1, "minimumFood": 1,
"hazardDamagePerTurn": 14,
"royale": { "royale": {
"shrinkEveryNTurns": 25 "shrinkEveryNTurns": 25
}, },
@ -19,18 +18,19 @@
"sharedLength": true "sharedLength": true
} }
} }
} },
"timeout": 500,
"source": ""
}, },
"turn": 0, "turn": 0,
"board": { "board": {
"height": 11, "height": 11,
"width": 11, "width": 11,
"food": [],
"hazards": [],
"snakes": [ "snakes": [
{ {
"id": "one", "id": "one",
"name": "", "name": "ONE",
"latency": "0",
"health": 0, "health": 0,
"body": [ "body": [
{ {
@ -38,18 +38,23 @@
"y": 3 "y": 3
} }
], ],
"latency": "0",
"head": { "head": {
"x": 3, "x": 3,
"y": 3 "y": 3
}, },
"length": 1, "length": 1,
"shout": "", "shout": "",
"squad": "" "squad": "",
"customizations": {
"color": "#123456",
"head": "safe",
"tail": "curled"
}
}, },
{ {
"id": "two", "id": "two",
"name": "", "name": "TWO",
"latency": "0",
"health": 0, "health": 0,
"body": [ "body": [
{ {
@ -57,20 +62,27 @@
"y": 3 "y": 3
} }
], ],
"latency": "0",
"head": { "head": {
"x": 4, "x": 4,
"y": 3 "y": 3
}, },
"length": 1, "length": 1,
"shout": "", "shout": "",
"squad": "" "squad": "",
"customizations": {
"color": "#654321",
"head": "silly",
"tail": "bolt"
} }
] }
],
"food": [],
"hazards": []
}, },
"you": { "you": {
"id": "one", "id": "one",
"name": "", "name": "ONE",
"latency": "0",
"health": 0, "health": 0,
"body": [ "body": [
{ {
@ -78,13 +90,17 @@
"y": 3 "y": 3
} }
], ],
"latency": "0",
"head": { "head": {
"x": 3, "x": 3,
"y": 3 "y": 3
}, },
"length": 1, "length": 1,
"shout": "", "shout": "",
"squad": "" "squad": "",
"customizations": {
"color": "#123456",
"head": "safe",
"tail": "curled"
}
} }
} }

90
client/fixtures_test.go Normal file
View file

@ -0,0 +1,90 @@
package client
func exampleSnakeRequest() SnakeRequest {
return SnakeRequest{
Game: Game{
ID: "game-id",
Ruleset: Ruleset{
Name: "test-ruleset-name",
Version: "cli",
Settings: exampleRulesetSettings,
},
Timeout: 33,
Source: "league",
},
Turn: 11,
Board: Board{
Height: 22,
Width: 11,
Snakes: []Snake{
{
ID: "snake-0",
Name: "snake-0-name",
Latency: "snake-0-latency",
Health: 100,
Body: []Coord{{X: 1, Y: 2}, {X: 1, Y: 3}, {X: 1, Y: 4}},
Head: Coord{X: 1, Y: 2},
Length: 3,
Shout: "snake-0-shout",
Squad: "",
Customizations: Customizations{
Head: "safe",
Tail: "curled",
Color: "#123456",
},
},
{
ID: "snake-1",
Name: "snake-1-name",
Latency: "snake-1-latency",
Health: 200,
Body: []Coord{{X: 2, Y: 2}, {X: 2, Y: 3}, {X: 2, Y: 4}},
Head: Coord{X: 2, Y: 2},
Length: 3,
Shout: "snake-1-shout",
Squad: "snake-1-squad",
Customizations: Customizations{
Head: "silly",
Tail: "bolt",
Color: "#654321",
},
},
},
Food: []Coord{{X: 2, Y: 2}},
Hazards: []Coord{{X: 8, Y: 8}, {X: 9, Y: 9}},
},
You: Snake{
ID: "snake-1",
Name: "snake-1-name",
Latency: "snake-1-latency",
Health: 200,
Body: []Coord{{X: 2, Y: 2}, {X: 2, Y: 3}, {X: 2, Y: 4}},
Head: Coord{X: 2, Y: 2},
Length: 3,
Shout: "snake-1-shout",
Squad: "snake-1-squad",
Customizations: Customizations{
Head: "silly",
Tail: "bolt",
Color: "#654321",
},
},
}
}
var exampleRulesetSettings = RulesetSettings{
FoodSpawnChance: 10,
MinimumFood: 20,
HazardDamagePerTurn: 30,
RoyaleSettings: RoyaleSettings{
ShrinkEveryNTurns: 40,
},
SquadSettings: SquadSettings{
AllowBodyCollisions: true,
SharedElimination: true,
SharedHealth: true,
SharedLength: true,
},
}

107
client/models.go Normal file
View file

@ -0,0 +1,107 @@
package client
import "github.com/BattlesnakeOfficial/rules"
// The top-level message sent in /start, /move, and /end requests
type SnakeRequest struct {
Game Game `json:"game"`
Turn int32 `json:"turn"`
Board Board `json:"board"`
You Snake `json:"you"`
}
// Game represents the current game state
type Game struct {
ID string `json:"id"`
Ruleset Ruleset `json:"ruleset"`
Timeout int32 `json:"timeout"`
Source string `json:"source"`
}
// Board provides information about the game board
type Board struct {
Height int32 `json:"height"`
Width int32 `json:"width"`
Snakes []Snake `json:"snakes"`
Food []Coord `json:"food"`
Hazards []Coord `json:"hazards"`
}
// Snake represents information about a snake in the game
type Snake struct {
ID string `json:"id"`
Name string `json:"name"`
Latency string `json:"latency"`
Health int32 `json:"health"`
Body []Coord `json:"body"`
Head Coord `json:"head"`
Length int32 `json:"length"`
Shout string `json:"shout"`
Squad string `json:"squad"`
Customizations Customizations `json:"customizations"`
}
type Customizations struct {
Color string `json:"color"`
Head string `json:"head"`
Tail string `json:"tail"`
}
type Ruleset struct {
Name string `json:"name"`
Version string `json:"version"`
Settings RulesetSettings `json:"settings"`
}
type RulesetSettings struct {
FoodSpawnChance int32 `json:"foodSpawnChance"`
MinimumFood int32 `json:"minimumFood"`
HazardDamagePerTurn int32 `json:"hazardDamagePerTurn"`
RoyaleSettings RoyaleSettings `json:"royale"`
SquadSettings SquadSettings `json:"squad"`
}
type RoyaleSettings struct {
ShrinkEveryNTurns int32 `json:"shrinkEveryNTurns"`
}
type SquadSettings struct {
AllowBodyCollisions bool `json:"allowBodyCollisions"`
SharedElimination bool `json:"sharedElimination"`
SharedHealth bool `json:"sharedHealth"`
SharedLength bool `json:"sharedLength"`
}
// Coord represents a point on the board
type Coord struct {
X int32 `json:"x"`
Y int32 `json:"y"`
}
// The expected format of the response body from a /move request
type MoveResponse struct {
Move string `json:"move"`
Shout string `json:"shout"`
}
// The expected format of the response body from a GET request to a Battlesnake's index URL
type SnakeMetadataResponse struct {
APIVersion string `json:"apiversion,omitempty"`
Author string `json:"author,omitempty"`
Color string `json:"color,omitempty"`
Head string `json:"head,omitempty"`
Tail string `json:"tail,omitempty"`
Version string `json:"version,omitempty"`
}
func CoordFromPoint(pt rules.Point) Coord {
return Coord{X: pt.X, Y: pt.Y}
}
func CoordFromPointArray(ptArray []rules.Point) []Coord {
a := make([]Coord, 0)
for _, pt := range ptArray {
a = append(a, CoordFromPoint(pt))
}
return a
}

26
client/models_test.go Normal file
View file

@ -0,0 +1,26 @@
package client
import (
"encoding/json"
"testing"
"github.com/BattlesnakeOfficial/rules/test"
"github.com/stretchr/testify/require"
)
func TestBuildSnakeRequestJSON(t *testing.T) {
snakeRequest := exampleSnakeRequest()
data, err := json.MarshalIndent(snakeRequest, "", " ")
require.NoError(t, err)
test.RequireJSONMatchesFixture(t, "testdata/snake_request.json", string(data))
}
func TestBuildSnakeRequestJSONEmptyRulesetSettings(t *testing.T) {
snakeRequest := exampleSnakeRequest()
snakeRequest.Game.Ruleset.Settings = RulesetSettings{}
data, err := json.MarshalIndent(snakeRequest, "", " ")
require.NoError(t, err)
test.RequireJSONMatchesFixture(t, "testdata/snake_request_empty_ruleset_settings.json", string(data))
}

144
client/testdata/snake_request.json vendored Normal file
View file

@ -0,0 +1,144 @@
{
"game": {
"id": "game-id",
"ruleset": {
"name": "test-ruleset-name",
"version": "cli",
"settings": {
"foodSpawnChance": 10,
"minimumFood": 20,
"hazardDamagePerTurn": 30,
"royale": {
"shrinkEveryNTurns": 40
},
"squad": {
"allowBodyCollisions": true,
"sharedElimination": true,
"sharedHealth": true,
"sharedLength": true
}
}
},
"timeout": 33,
"source": "league"
},
"turn": 11,
"board": {
"height": 22,
"width": 11,
"snakes": [
{
"id": "snake-0",
"name": "snake-0-name",
"latency": "snake-0-latency",
"health": 100,
"body": [
{
"x": 1,
"y": 2
},
{
"x": 1,
"y": 3
},
{
"x": 1,
"y": 4
}
],
"head": {
"x": 1,
"y": 2
},
"length": 3,
"shout": "snake-0-shout",
"squad": "",
"customizations": {
"color": "#123456",
"head": "safe",
"tail": "curled"
}
},
{
"id": "snake-1",
"name": "snake-1-name",
"latency": "snake-1-latency",
"health": 200,
"body": [
{
"x": 2,
"y": 2
},
{
"x": 2,
"y": 3
},
{
"x": 2,
"y": 4
}
],
"head": {
"x": 2,
"y": 2
},
"length": 3,
"shout": "snake-1-shout",
"squad": "snake-1-squad",
"customizations": {
"color": "#654321",
"head": "silly",
"tail": "bolt"
}
}
],
"food": [
{
"x": 2,
"y": 2
}
],
"hazards": [
{
"x": 8,
"y": 8
},
{
"x": 9,
"y": 9
}
]
},
"you": {
"id": "snake-1",
"name": "snake-1-name",
"latency": "snake-1-latency",
"health": 200,
"body": [
{
"x": 2,
"y": 2
},
{
"x": 2,
"y": 3
},
{
"x": 2,
"y": 4
}
],
"head": {
"x": 2,
"y": 2
},
"length": 3,
"shout": "snake-1-shout",
"squad": "snake-1-squad",
"customizations": {
"color": "#654321",
"head": "silly",
"tail": "bolt"
}
}
}

View file

@ -0,0 +1,144 @@
{
"game": {
"id": "game-id",
"ruleset": {
"name": "test-ruleset-name",
"version": "cli",
"settings": {
"foodSpawnChance": 0,
"minimumFood": 0,
"hazardDamagePerTurn": 0,
"royale": {
"shrinkEveryNTurns": 0
},
"squad": {
"allowBodyCollisions": false,
"sharedElimination": false,
"sharedHealth": false,
"sharedLength": false
}
}
},
"timeout": 33,
"source": "league"
},
"turn": 11,
"board": {
"height": 22,
"width": 11,
"snakes": [
{
"id": "snake-0",
"name": "snake-0-name",
"latency": "snake-0-latency",
"health": 100,
"body": [
{
"x": 1,
"y": 2
},
{
"x": 1,
"y": 3
},
{
"x": 1,
"y": 4
}
],
"head": {
"x": 1,
"y": 2
},
"length": 3,
"shout": "snake-0-shout",
"squad": "",
"customizations": {
"color": "#123456",
"head": "safe",
"tail": "curled"
}
},
{
"id": "snake-1",
"name": "snake-1-name",
"latency": "snake-1-latency",
"health": 200,
"body": [
{
"x": 2,
"y": 2
},
{
"x": 2,
"y": 3
},
{
"x": 2,
"y": 4
}
],
"head": {
"x": 2,
"y": 2
},
"length": 3,
"shout": "snake-1-shout",
"squad": "snake-1-squad",
"customizations": {
"color": "#654321",
"head": "silly",
"tail": "bolt"
}
}
],
"food": [
{
"x": 2,
"y": 2
}
],
"hazards": [
{
"x": 8,
"y": 8
},
{
"x": 9,
"y": 9
}
]
},
"you": {
"id": "snake-1",
"name": "snake-1-name",
"latency": "snake-1-latency",
"health": 200,
"body": [
{
"x": 2,
"y": 2
},
{
"x": 2,
"y": 3
},
{
"x": 2,
"y": 4
}
],
"head": {
"x": 2,
"y": 2
},
"length": 3,
"shout": "snake-1-shout",
"squad": "snake-1-squad",
"customizations": {
"color": "#654321",
"head": "silly",
"tail": "bolt"
}
}
}

View file

@ -4,6 +4,9 @@ import (
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
// included to allow using -update-fixtures for every package without errors
_ "github.com/BattlesnakeOfficial/rules/test"
) )
func TestRulesetError(t *testing.T) { func TestRulesetError(t *testing.T) {

View file

@ -1,10 +1,11 @@
package rules package test
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"flag" "flag"
"io/ioutil" "io/ioutil"
"log"
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -26,6 +27,8 @@ func RequireJSONMatchesFixture(t *testing.T, filename string, actual string) {
require.NoError(t, err, "Failed to indent JSON") require.NoError(t, err, "Failed to indent JSON")
err = ioutil.WriteFile(filename, indented.Bytes(), 0644) err = ioutil.WriteFile(filename, indented.Bytes(), 0644)
require.NoError(t, err, "Failed to update fixture", filename) require.NoError(t, err, "Failed to update fixture", filename)
log.Printf("Updating fixture file %#v", filename)
} }
expectedData, err := ioutil.ReadFile(filename) expectedData, err := ioutil.ReadFile(filename)