DEV-765 pipeline refactor (#64)

Refactor rulesets into smaller composable operations

In order to mix up the functionality from different rulesets like Solo, Royale, etc. the code in these classes needs to be broken up into small functions that can be composed in a pipeline to make a custom game mode.
This commit is contained in:
Torben 2022-03-16 16:58:05 -07:00 committed by GitHub
parent 5e629e9e93
commit 397d925110
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1475 additions and 222 deletions

View file

@ -57,11 +57,22 @@ var MinimumFood int32
var HazardDamagePerTurn int32
var ShrinkEveryNTurns int32
var defaultConfig = map[string]string{
// default to standard ruleset
rules.ParamGameType: "standard",
// squad settings default to true (not zero value)
rules.ParamSharedElimination: "true",
rules.ParamSharedHealth: "true",
rules.ParamSharedLength: "true",
rules.ParamAllowBodyCollisions: "true",
}
var playCmd = &cobra.Command{
Use: "play",
Short: "Play a game of Battlesnake locally.",
Long: "Play a game of Battlesnake locally.",
Run: run,
Use: "play",
Short: "Play a game of Battlesnake locally.",
Long: "Play a game of Battlesnake locally.",
Run: run,
PreRun: playPreRun,
}
func init() {
@ -90,6 +101,10 @@ func init() {
playCmd.Flags().SortFlags = false
}
func playPreRun(cmd *cobra.Command, args []string) {
initialiseGameConfig()
}
var run = func(cmd *cobra.Command, args []string) {
rand.Seed(Seed)
@ -174,54 +189,23 @@ var run = func(cmd *cobra.Command, args []string) {
}
}
func initialiseGameConfig() {
defaultConfig[rules.ParamGameType] = GameType
defaultConfig[rules.ParamFoodSpawnChance] = fmt.Sprint(FoodSpawnChance)
defaultConfig[rules.ParamMinimumFood] = fmt.Sprint(MinimumFood)
defaultConfig[rules.ParamHazardDamagePerTurn] = fmt.Sprint(HazardDamagePerTurn)
defaultConfig[rules.ParamShrinkEveryNTurns] = fmt.Sprint(ShrinkEveryNTurns)
}
func getRuleset(seed int64, snakeStates map[string]SnakeState) rules.Ruleset {
var ruleset rules.Ruleset
var royale rules.RoyaleRuleset
rb := rules.NewRulesetBuilder().WithSeed(seed).WithParams(defaultConfig)
standard := rules.StandardRuleset{
FoodSpawnChance: FoodSpawnChance,
MinimumFood: MinimumFood,
HazardDamagePerTurn: 0,
for _, s := range snakeStates {
rb.AddSnakeToSquad(s.ID, s.Squad)
}
switch GameType {
case "royale":
standard.HazardDamagePerTurn = HazardDamagePerTurn
royale = rules.RoyaleRuleset{
StandardRuleset: standard,
Seed: seed,
ShrinkEveryNTurns: ShrinkEveryNTurns,
}
ruleset = &royale
case "squad":
squadMap := map[string]string{}
for _, snakeState := range snakeStates {
squadMap[snakeState.ID] = snakeState.Squad
}
ruleset = &rules.SquadRuleset{
StandardRuleset: standard,
SquadMap: squadMap,
AllowBodyCollisions: true,
SharedElimination: true,
SharedHealth: true,
SharedLength: true,
}
case "solo":
ruleset = &rules.SoloRuleset{
StandardRuleset: standard,
}
case "wrapped":
ruleset = &rules.WrappedRuleset{
StandardRuleset: standard,
}
case "constrictor":
ruleset = &rules.ConstrictorRuleset{
StandardRuleset: standard,
}
default:
ruleset = &standard
}
return ruleset
return rb.Ruleset()
}
func initializeBoardFromArgs(ruleset rules.Ruleset, snakeStates map[string]SnakeState) *rules.BoardState {
@ -382,22 +366,9 @@ func serialiseSnakeRequest(snakeRequest client.SnakeRequest) []byte {
func createClientGame(ruleset rules.Ruleset) client.Game {
return client.Game{ID: GameId, Timeout: Timeout, Ruleset: client.Ruleset{
Name: ruleset.Name(),
Version: "cli", // TODO: Use GitHub Release Version
Settings: client.RulesetSettings{
HazardDamagePerTurn: HazardDamagePerTurn,
FoodSpawnChance: FoodSpawnChance,
MinimumFood: MinimumFood,
RoyaleSettings: client.RoyaleSettings{
ShrinkEveryNTurns: ShrinkEveryNTurns,
},
SquadSettings: client.SquadSettings{
AllowBodyCollisions: true,
SharedElimination: true,
SharedHealth: true,
SharedLength: true,
},
},
Name: ruleset.Name(),
Version: "cli", // TODO: Use GitHub Release Version
Settings: ruleset.Settings(),
}}
}

View file

@ -1,6 +1,7 @@
package commands
import (
"fmt"
"testing"
"github.com/BattlesnakeOfficial/rules"
@ -35,8 +36,69 @@ func TestGetIndividualBoardStateForSnake(t *testing.T) {
s1State.ID: s1State,
s2State.ID: s2State,
}
snakeRequest := getIndividualBoardStateForSnake(state, s1State, snakeStates, &rules.StandardRuleset{})
initialiseGameConfig() // initialise default config
snakeRequest := getIndividualBoardStateForSnake(state, s1State, snakeStates, getRuleset(0, snakeStates))
requestBody := serialiseSnakeRequest(snakeRequest)
test.RequireJSONMatchesFixture(t, "testdata/snake_request_body.json", string(requestBody))
}
func TestSettingsRequestSerialization(t *testing.T) {
s1 := rules.Snake{ID: "one", Body: []rules.Point{{X: 3, Y: 3}}}
s2 := rules.Snake{ID: "two", Body: []rules.Point{{X: 4, Y: 3}}}
state := &rules.BoardState{
Height: 11,
Width: 11,
Snakes: []rules.Snake{s1, s2},
}
s1State := SnakeState{
ID: "one",
Name: "ONE",
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}
rsb := rules.NewRulesetBuilder().
WithParams(map[string]string{
// standard
rules.ParamFoodSpawnChance: "11",
rules.ParamMinimumFood: "7",
rules.ParamHazardDamagePerTurn: "19",
rules.ParamHazardMap: "hz_spiral",
rules.ParamHazardMapAuthor: "altersaddle",
// squad
rules.ParamAllowBodyCollisions: "true",
rules.ParamSharedElimination: "false",
rules.ParamSharedHealth: "true",
rules.ParamSharedLength: "false",
// royale
rules.ParamShrinkEveryNTurns: "17",
})
for _, gt := range []string{
rules.GameTypeStandard, rules.GameTypeRoyale, rules.GameTypeSolo,
rules.GameTypeWrapped, rules.GameTypeSquad, rules.GameTypeConstrictor,
} {
t.Run(gt, func(t *testing.T) {
// apply game type
ruleset := rsb.WithParams(map[string]string{rules.ParamGameType: gt}).Ruleset()
snakeRequest := getIndividualBoardStateForSnake(state, s1State, snakeStates, ruleset)
requestBody := serialiseSnakeRequest(snakeRequest)
t.Log(string(requestBody))
test.RequireJSONMatchesFixture(t, fmt.Sprintf("testdata/snake_request_body_%s.json", gt), string(requestBody))
})
}
}

View file

@ -11,13 +11,13 @@
"hazardMap": "",
"hazardMapAuthor": "",
"royale": {
"shrinkEveryNTurns": 25
"shrinkEveryNTurns": 0
},
"squad": {
"allowBodyCollisions": true,
"sharedElimination": true,
"sharedHealth": true,
"sharedLength": true
"allowBodyCollisions": false,
"sharedElimination": false,
"sharedHealth": false,
"sharedLength": false
}
}
},

View file

@ -0,0 +1,108 @@
{
"game": {
"id": "",
"ruleset": {
"name": "constrictor",
"version": "cli",
"settings": {
"foodSpawnChance": 11,
"minimumFood": 7,
"hazardDamagePerTurn": 19,
"hazardMap": "hz_spiral",
"hazardMapAuthor": "altersaddle",
"royale": {
"shrinkEveryNTurns": 0
},
"squad": {
"allowBodyCollisions": false,
"sharedElimination": false,
"sharedHealth": false,
"sharedLength": false
}
}
},
"timeout": 500,
"source": ""
},
"turn": 0,
"board": {
"height": 11,
"width": 11,
"snakes": [
{
"id": "one",
"name": "ONE",
"latency": "0",
"health": 0,
"body": [
{
"x": 3,
"y": 3
}
],
"head": {
"x": 3,
"y": 3
},
"length": 1,
"shout": "",
"squad": "",
"customizations": {
"color": "#123456",
"head": "safe",
"tail": "curled"
}
},
{
"id": "two",
"name": "TWO",
"latency": "0",
"health": 0,
"body": [
{
"x": 4,
"y": 3
}
],
"head": {
"x": 4,
"y": 3
},
"length": 1,
"shout": "",
"squad": "",
"customizations": {
"color": "#654321",
"head": "silly",
"tail": "bolt"
}
}
],
"food": [],
"hazards": []
},
"you": {
"id": "one",
"name": "ONE",
"latency": "0",
"health": 0,
"body": [
{
"x": 3,
"y": 3
}
],
"head": {
"x": 3,
"y": 3
},
"length": 1,
"shout": "",
"squad": "",
"customizations": {
"color": "#123456",
"head": "safe",
"tail": "curled"
}
}
}

View file

@ -0,0 +1,108 @@
{
"game": {
"id": "",
"ruleset": {
"name": "royale",
"version": "cli",
"settings": {
"foodSpawnChance": 11,
"minimumFood": 7,
"hazardDamagePerTurn": 19,
"hazardMap": "hz_spiral",
"hazardMapAuthor": "altersaddle",
"royale": {
"shrinkEveryNTurns": 17
},
"squad": {
"allowBodyCollisions": false,
"sharedElimination": false,
"sharedHealth": false,
"sharedLength": false
}
}
},
"timeout": 500,
"source": ""
},
"turn": 0,
"board": {
"height": 11,
"width": 11,
"snakes": [
{
"id": "one",
"name": "ONE",
"latency": "0",
"health": 0,
"body": [
{
"x": 3,
"y": 3
}
],
"head": {
"x": 3,
"y": 3
},
"length": 1,
"shout": "",
"squad": "",
"customizations": {
"color": "#123456",
"head": "safe",
"tail": "curled"
}
},
{
"id": "two",
"name": "TWO",
"latency": "0",
"health": 0,
"body": [
{
"x": 4,
"y": 3
}
],
"head": {
"x": 4,
"y": 3
},
"length": 1,
"shout": "",
"squad": "",
"customizations": {
"color": "#654321",
"head": "silly",
"tail": "bolt"
}
}
],
"food": [],
"hazards": []
},
"you": {
"id": "one",
"name": "ONE",
"latency": "0",
"health": 0,
"body": [
{
"x": 3,
"y": 3
}
],
"head": {
"x": 3,
"y": 3
},
"length": 1,
"shout": "",
"squad": "",
"customizations": {
"color": "#123456",
"head": "safe",
"tail": "curled"
}
}
}

View file

@ -0,0 +1,108 @@
{
"game": {
"id": "",
"ruleset": {
"name": "solo",
"version": "cli",
"settings": {
"foodSpawnChance": 11,
"minimumFood": 7,
"hazardDamagePerTurn": 19,
"hazardMap": "hz_spiral",
"hazardMapAuthor": "altersaddle",
"royale": {
"shrinkEveryNTurns": 0
},
"squad": {
"allowBodyCollisions": false,
"sharedElimination": false,
"sharedHealth": false,
"sharedLength": false
}
}
},
"timeout": 500,
"source": ""
},
"turn": 0,
"board": {
"height": 11,
"width": 11,
"snakes": [
{
"id": "one",
"name": "ONE",
"latency": "0",
"health": 0,
"body": [
{
"x": 3,
"y": 3
}
],
"head": {
"x": 3,
"y": 3
},
"length": 1,
"shout": "",
"squad": "",
"customizations": {
"color": "#123456",
"head": "safe",
"tail": "curled"
}
},
{
"id": "two",
"name": "TWO",
"latency": "0",
"health": 0,
"body": [
{
"x": 4,
"y": 3
}
],
"head": {
"x": 4,
"y": 3
},
"length": 1,
"shout": "",
"squad": "",
"customizations": {
"color": "#654321",
"head": "silly",
"tail": "bolt"
}
}
],
"food": [],
"hazards": []
},
"you": {
"id": "one",
"name": "ONE",
"latency": "0",
"health": 0,
"body": [
{
"x": 3,
"y": 3
}
],
"head": {
"x": 3,
"y": 3
},
"length": 1,
"shout": "",
"squad": "",
"customizations": {
"color": "#123456",
"head": "safe",
"tail": "curled"
}
}
}

View file

@ -0,0 +1,108 @@
{
"game": {
"id": "",
"ruleset": {
"name": "squad",
"version": "cli",
"settings": {
"foodSpawnChance": 11,
"minimumFood": 7,
"hazardDamagePerTurn": 19,
"hazardMap": "hz_spiral",
"hazardMapAuthor": "altersaddle",
"royale": {
"shrinkEveryNTurns": 0
},
"squad": {
"allowBodyCollisions": true,
"sharedElimination": false,
"sharedHealth": true,
"sharedLength": false
}
}
},
"timeout": 500,
"source": ""
},
"turn": 0,
"board": {
"height": 11,
"width": 11,
"snakes": [
{
"id": "one",
"name": "ONE",
"latency": "0",
"health": 0,
"body": [
{
"x": 3,
"y": 3
}
],
"head": {
"x": 3,
"y": 3
},
"length": 1,
"shout": "",
"squad": "",
"customizations": {
"color": "#123456",
"head": "safe",
"tail": "curled"
}
},
{
"id": "two",
"name": "TWO",
"latency": "0",
"health": 0,
"body": [
{
"x": 4,
"y": 3
}
],
"head": {
"x": 4,
"y": 3
},
"length": 1,
"shout": "",
"squad": "",
"customizations": {
"color": "#654321",
"head": "silly",
"tail": "bolt"
}
}
],
"food": [],
"hazards": []
},
"you": {
"id": "one",
"name": "ONE",
"latency": "0",
"health": 0,
"body": [
{
"x": 3,
"y": 3
}
],
"head": {
"x": 3,
"y": 3
},
"length": 1,
"shout": "",
"squad": "",
"customizations": {
"color": "#123456",
"head": "safe",
"tail": "curled"
}
}
}

View file

@ -0,0 +1,108 @@
{
"game": {
"id": "",
"ruleset": {
"name": "standard",
"version": "cli",
"settings": {
"foodSpawnChance": 11,
"minimumFood": 7,
"hazardDamagePerTurn": 19,
"hazardMap": "hz_spiral",
"hazardMapAuthor": "altersaddle",
"royale": {
"shrinkEveryNTurns": 0
},
"squad": {
"allowBodyCollisions": false,
"sharedElimination": false,
"sharedHealth": false,
"sharedLength": false
}
}
},
"timeout": 500,
"source": ""
},
"turn": 0,
"board": {
"height": 11,
"width": 11,
"snakes": [
{
"id": "one",
"name": "ONE",
"latency": "0",
"health": 0,
"body": [
{
"x": 3,
"y": 3
}
],
"head": {
"x": 3,
"y": 3
},
"length": 1,
"shout": "",
"squad": "",
"customizations": {
"color": "#123456",
"head": "safe",
"tail": "curled"
}
},
{
"id": "two",
"name": "TWO",
"latency": "0",
"health": 0,
"body": [
{
"x": 4,
"y": 3
}
],
"head": {
"x": 4,
"y": 3
},
"length": 1,
"shout": "",
"squad": "",
"customizations": {
"color": "#654321",
"head": "silly",
"tail": "bolt"
}
}
],
"food": [],
"hazards": []
},
"you": {
"id": "one",
"name": "ONE",
"latency": "0",
"health": 0,
"body": [
{
"x": 3,
"y": 3
}
],
"head": {
"x": 3,
"y": 3
},
"length": 1,
"shout": "",
"squad": "",
"customizations": {
"color": "#123456",
"head": "safe",
"tail": "curled"
}
}
}

View file

@ -0,0 +1,108 @@
{
"game": {
"id": "",
"ruleset": {
"name": "wrapped",
"version": "cli",
"settings": {
"foodSpawnChance": 11,
"minimumFood": 7,
"hazardDamagePerTurn": 19,
"hazardMap": "hz_spiral",
"hazardMapAuthor": "altersaddle",
"royale": {
"shrinkEveryNTurns": 0
},
"squad": {
"allowBodyCollisions": false,
"sharedElimination": false,
"sharedHealth": false,
"sharedLength": false
}
}
},
"timeout": 500,
"source": ""
},
"turn": 0,
"board": {
"height": 11,
"width": 11,
"snakes": [
{
"id": "one",
"name": "ONE",
"latency": "0",
"health": 0,
"body": [
{
"x": 3,
"y": 3
}
],
"head": {
"x": 3,
"y": 3
},
"length": 1,
"shout": "",
"squad": "",
"customizations": {
"color": "#123456",
"head": "safe",
"tail": "curled"
}
},
{
"id": "two",
"name": "TWO",
"latency": "0",
"health": 0,
"body": [
{
"x": 4,
"y": 3
}
],
"head": {
"x": 4,
"y": 3
},
"length": 1,
"shout": "",
"squad": "",
"customizations": {
"color": "#654321",
"head": "silly",
"tail": "bolt"
}
}
],
"food": [],
"hazards": []
},
"you": {
"id": "one",
"name": "ONE",
"latency": "0",
"health": 0,
"body": [
{
"x": 3,
"y": 3
}
],
"head": {
"x": 3,
"y": 3
},
"length": 1,
"shout": "",
"squad": "",
"customizations": {
"color": "#123456",
"head": "safe",
"tail": "curled"
}
}
}

View file

@ -1,5 +1,7 @@
package client
import "github.com/BattlesnakeOfficial/rules"
func exampleSnakeRequest() SnakeRequest {
return SnakeRequest{
Game: Game{
@ -72,18 +74,18 @@ func exampleSnakeRequest() SnakeRequest {
}
}
var exampleRulesetSettings = RulesetSettings{
var exampleRulesetSettings = rules.Settings{
FoodSpawnChance: 10,
MinimumFood: 20,
HazardDamagePerTurn: 30,
HazardMap: "hz_spiral",
HazardMapAuthor: "altersaddle",
RoyaleSettings: RoyaleSettings{
RoyaleSettings: rules.RoyaleSettings{
ShrinkEveryNTurns: 40,
},
SquadSettings: SquadSettings{
SquadSettings: rules.SquadSettings{
AllowBodyCollisions: true,
SharedElimination: true,
SharedHealth: true,

View file

@ -48,31 +48,19 @@ type Customizations struct {
}
type Ruleset struct {
Name string `json:"name"`
Version string `json:"version"`
Settings RulesetSettings `json:"settings"`
Name string `json:"name"`
Version string `json:"version"`
Settings rules.Settings `json:"settings"`
}
type RulesetSettings struct {
FoodSpawnChance int32 `json:"foodSpawnChance"`
MinimumFood int32 `json:"minimumFood"`
HazardDamagePerTurn int32 `json:"hazardDamagePerTurn"`
HazardMap string `json:"hazardMap"`
HazardMapAuthor string `json:"hazardMapAuthor"`
RoyaleSettings RoyaleSettings `json:"royale"`
SquadSettings SquadSettings `json:"squad"`
}
// RulesetSettings is deprecated: use rules.Settings instead
type RulesetSettings rules.Settings
type RoyaleSettings struct {
ShrinkEveryNTurns int32 `json:"shrinkEveryNTurns"`
}
// RoyaleSettings is deprecated: use rules.RoyaleSettings instead
type RoyaleSettings rules.RoyaleSettings
type SquadSettings struct {
AllowBodyCollisions bool `json:"allowBodyCollisions"`
SharedElimination bool `json:"sharedElimination"`
SharedHealth bool `json:"sharedHealth"`
SharedLength bool `json:"sharedLength"`
}
// SquadSettings is deprecated: use rules.SquadSettings instead
type SquadSettings rules.SquadSettings
// Coord represents a point on the board
type Coord struct {

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"testing"
"github.com/BattlesnakeOfficial/rules"
"github.com/BattlesnakeOfficial/rules/test"
"github.com/stretchr/testify/require"
)
@ -18,7 +19,7 @@ func TestBuildSnakeRequestJSON(t *testing.T) {
func TestBuildSnakeRequestJSONEmptyRulesetSettings(t *testing.T) {
snakeRequest := exampleSnakeRequest()
snakeRequest.Game.Ruleset.Settings = RulesetSettings{}
snakeRequest.Game.Ruleset.Settings = rules.Settings{}
data, err := json.MarshalIndent(snakeRequest, "", " ")
require.NoError(t, err)

View file

@ -4,29 +4,32 @@ type ConstrictorRuleset struct {
StandardRuleset
}
func (r *ConstrictorRuleset) Name() string { return "constrictor" }
func (r *ConstrictorRuleset) Name() string { return GameTypeConstrictor }
func (r *ConstrictorRuleset) ModifyInitialBoardState(initialBoardState *BoardState) (*BoardState, error) {
initialBoardState, err := r.StandardRuleset.ModifyInitialBoardState(initialBoardState)
if err != nil {
return nil, err
}
newBoardState := initialBoardState.Clone()
err = r.applyConstrictorRules(newBoardState)
r.removeFood(initialBoardState)
err = r.applyConstrictorRules(initialBoardState)
if err != nil {
return nil, err
}
return newBoardState, nil
return initialBoardState, nil
}
func (r *ConstrictorRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) {
nextState, err := r.StandardRuleset.CreateNextBoardState(prevState, moves)
if err != nil {
return nil, err
}
r.removeFood(nextState)
err = r.applyConstrictorRules(nextState)
if err != nil {
return nil, err
@ -35,10 +38,23 @@ func (r *ConstrictorRuleset) CreateNextBoardState(prevState *BoardState, moves [
return nextState, nil
}
func (r *ConstrictorRuleset) applyConstrictorRules(b *BoardState) error {
func (r *ConstrictorRuleset) removeFood(b *BoardState) {
_, _ = r.callStageFunc(RemoveFoodConstrictor, b, []SnakeMove{})
}
func RemoveFoodConstrictor(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
// Remove all food from the board
b.Food = []Point{}
return false, nil
}
func (r *ConstrictorRuleset) applyConstrictorRules(b *BoardState) error {
_, err := r.callStageFunc(GrowSnakesConstrictor, b, []SnakeMove{})
return err
}
func GrowSnakesConstrictor(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
// Set all snakes to max health and ensure they grow next turn
for i := 0; i < len(b.Snakes); i++ {
b.Snakes[i].Health = SnakeMaxHealth
@ -46,9 +62,9 @@ func (r *ConstrictorRuleset) applyConstrictorRules(b *BoardState) error {
tail := b.Snakes[i].Body[len(b.Snakes[i].Body)-1]
subTail := b.Snakes[i].Body[len(b.Snakes[i].Body)-2]
if tail != subTail {
r.growSnake(&b.Snakes[i])
growSnake(&b.Snakes[i])
}
}
return nil
return false, nil
}

View file

@ -13,7 +13,7 @@ type RoyaleRuleset struct {
ShrinkEveryNTurns int32
}
func (r *RoyaleRuleset) Name() string { return "royale" }
func (r *RoyaleRuleset) Name() string { return GameTypeRoyale }
func (r *RoyaleRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) {
if r.StandardRuleset.HazardDamagePerTurn < 1 {
@ -26,7 +26,7 @@ func (r *RoyaleRuleset) CreateNextBoardState(prevState *BoardState, moves []Snak
}
// Royale's only job is now to populate the hazards for next turn - StandardRuleset takes care of applying hazard damage.
err = r.populateHazards(nextBoardState, prevState.Turn+1)
err = r.populateHazards(nextBoardState)
if err != nil {
return nil, err
}
@ -34,20 +34,28 @@ func (r *RoyaleRuleset) CreateNextBoardState(prevState *BoardState, moves []Snak
return nextBoardState, nil
}
func (r *RoyaleRuleset) populateHazards(b *BoardState, turn int32) error {
func (r *RoyaleRuleset) populateHazards(b *BoardState) error {
_, err := r.callStageFunc(PopulateHazardsRoyale, b, []SnakeMove{})
return err
}
func PopulateHazardsRoyale(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
b.Hazards = []Point{}
if r.ShrinkEveryNTurns < 1 {
return errors.New("royale game can't shrink more frequently than every turn")
// Royale uses the current turn to generate hazards, not the previous turn that's in the board state
turn := b.Turn + 1
if settings.RoyaleSettings.ShrinkEveryNTurns < 1 {
return false, errors.New("royale game can't shrink more frequently than every turn")
}
if turn < r.ShrinkEveryNTurns {
return nil
if turn < settings.RoyaleSettings.ShrinkEveryNTurns {
return false, nil
}
randGenerator := rand.New(rand.NewSource(r.Seed))
randGenerator := rand.New(rand.NewSource(settings.RoyaleSettings.seed))
numShrinks := turn / r.ShrinkEveryNTurns
numShrinks := turn / settings.RoyaleSettings.ShrinkEveryNTurns
minX, maxX := int32(0), b.Width-1
minY, maxY := int32(0), b.Height-1
for i := int32(0); i < numShrinks; i++ {
@ -71,5 +79,19 @@ func (r *RoyaleRuleset) populateHazards(b *BoardState, turn int32) error {
}
}
return nil
return false, nil
}
func (r RoyaleRuleset) Settings() Settings {
s := r.StandardRuleset.Settings()
s.RoyaleSettings = RoyaleSettings{
seed: r.Seed,
ShrinkEveryNTurns: r.ShrinkEveryNTurns,
}
return s
}
// Adaptor for integrating stages into RoyaleRuleset
func (r *RoyaleRuleset) callStageFunc(stage StageFunc, boardState *BoardState, moves []SnakeMove) (bool, error) {
return stage(boardState, r.Settings(), moves)
}

View file

@ -90,7 +90,7 @@ func TestRoyaleHazards(t *testing.T) {
for _, test := range tests {
b := &BoardState{
Turn: test.Turn,
Turn: test.Turn - 1,
Width: test.Width,
Height: test.Height,
}
@ -102,7 +102,7 @@ func TestRoyaleHazards(t *testing.T) {
ShrinkEveryNTurns: test.ShrinkEveryNTurns,
}
err := r.populateHazards(b, test.Turn)
err := r.populateHazards(b)
require.Equal(t, test.Error, err)
if err == nil {
// Obstacles should match
@ -131,7 +131,7 @@ func TestRoyalDamageNextTurn(t *testing.T) {
stateAfterTurn := func(prevState *BoardState, turn int32) *BoardState {
nextState := prevState.Clone()
nextState.Turn = turn - 1
err := r.populateHazards(nextState, turn)
err := r.populateHazards(nextState)
require.NoError(t, err)
nextState.Turn = turn
return nextState

View file

@ -1,5 +1,9 @@
package rules
import (
"strconv"
)
type RulesetError string
func (err RulesetError) Error() string { return string(err) }
@ -24,6 +28,7 @@ const (
EliminatedByOutOfHealth = "out-of-health"
EliminatedByHeadToHeadCollision = "head-collision"
EliminatedByOutOfBounds = "wall-collision"
EliminatedBySquad = "squad-eliminated"
// TODO - Error consts
ErrorTooManySnakes = RulesetError("too many snakes for fixed start positions")
@ -31,8 +36,147 @@ const (
ErrorNoRoomForFood = RulesetError("not enough space to place food")
ErrorNoMoveFound = RulesetError("move not provided for snake")
ErrorZeroLengthSnake = RulesetError("snake is length zero")
// Ruleset / game type names
GameTypeConstrictor = "constrictor"
GameTypeRoyale = "royale"
GameTypeSolo = "solo"
GameTypeSquad = "squad"
GameTypeStandard = "standard"
GameTypeWrapped = "wrapped"
// Game creation parameter names
ParamGameType = "name"
ParamFoodSpawnChance = "foodSpawnChance"
ParamMinimumFood = "minimumFood"
ParamHazardDamagePerTurn = "damagePerTurn"
ParamHazardMap = "hazardMap"
ParamHazardMapAuthor = "hazardMapAuthor"
ParamShrinkEveryNTurns = "shrinkEveryNTurns"
ParamAllowBodyCollisions = "allowBodyCollisions"
ParamSharedElimination = "sharedElimination"
ParamSharedHealth = "sharedHealth"
ParamSharedLength = "sharedLength"
)
type rulesetBuilder struct {
params map[string]string // game customisation parameters
seed int64 // used for random events in games
squads map[string]string // Snake ID -> Squad Name
}
// NewRulesetBuilder returns an instance of a builder for the Ruleset types.
func NewRulesetBuilder() *rulesetBuilder {
return &rulesetBuilder{
params: map[string]string{},
squads: map[string]string{},
}
}
// WithParams accepts a map of game parameters for customizing games.
//
// Parameters are copied. If called multiple times, parameters are merged such that:
// - existing keys in both maps get overwritten by the new ones
// - existing keys not present in the new map will be retained
// - non-existing keys only in the new map will be added
//
// Unrecognised parameters will be ignored and default values will be used.
// Invalid parameters (i.e. a non-numerical value where one is expected), will be ignored
// and default values will be used.
func (rb *rulesetBuilder) WithParams(params map[string]string) *rulesetBuilder {
for k, v := range params {
rb.params[k] = v
}
return rb
}
// WithSeed sets the seed used for randomisation by certain game modes.
func (rb *rulesetBuilder) WithSeed(seed int64) *rulesetBuilder {
rb.seed = seed
return rb
}
// AddSnakeToSquad adds the specified snake (by ID) to a squad with the given name.
// This configuration may be ignored by game modes if they do not support squads.
func (rb *rulesetBuilder) AddSnakeToSquad(snakeID, squadName string) *rulesetBuilder {
rb.squads[snakeID] = squadName
return rb
}
// Ruleset constructs a customised ruleset using the parameters passed to the builder.
func (rb rulesetBuilder) Ruleset() Ruleset {
standardRuleset := &StandardRuleset{
FoodSpawnChance: paramsInt32(rb.params, ParamFoodSpawnChance, 0),
MinimumFood: paramsInt32(rb.params, ParamMinimumFood, 0),
HazardDamagePerTurn: paramsInt32(rb.params, ParamHazardDamagePerTurn, 0),
HazardMap: rb.params[ParamHazardMap],
HazardMapAuthor: rb.params[ParamHazardMapAuthor],
}
name, ok := rb.params[ParamGameType]
if !ok {
return standardRuleset
}
switch name {
case GameTypeConstrictor:
return &ConstrictorRuleset{
StandardRuleset: *standardRuleset,
}
case GameTypeRoyale:
return &RoyaleRuleset{
StandardRuleset: *standardRuleset,
Seed: rb.seed,
ShrinkEveryNTurns: paramsInt32(rb.params, ParamShrinkEveryNTurns, 0),
}
case GameTypeSolo:
return &SoloRuleset{
StandardRuleset: *standardRuleset,
}
case GameTypeWrapped:
return &WrappedRuleset{
StandardRuleset: *standardRuleset,
}
case GameTypeSquad:
squadMap := map[string]string{}
for id, squad := range rb.squads {
squadMap[id] = squad
}
return &SquadRuleset{
StandardRuleset: *standardRuleset,
SquadMap: squadMap,
AllowBodyCollisions: paramsBool(rb.params, ParamAllowBodyCollisions, false),
SharedElimination: paramsBool(rb.params, ParamSharedElimination, false),
SharedHealth: paramsBool(rb.params, ParamSharedHealth, false),
SharedLength: paramsBool(rb.params, ParamSharedLength, false),
}
}
return standardRuleset
}
// paramsBool returns the boolean value for the specified parameter.
// If the parameter doesn't exist, the default value will be returned.
// If the parameter does exist, but is not "true", false will be returned.
func paramsBool(params map[string]string, paramName string, defaultValue bool) bool {
if val, ok := params[paramName]; ok {
return val == "true"
}
return defaultValue
}
// paramsInt32 returns the int32 value for the specified parameter.
// If the parameter doesn't exist, the default value will be returned.
// If the parameter does exist, but is not a valid int, the default value will be returned.
func paramsInt32(params map[string]string, paramName string, defaultValue int32) int32 {
if val, ok := params[paramName]; ok {
i, err := strconv.Atoi(val)
if err == nil {
return int32(i)
}
}
return defaultValue
}
type Point struct {
X int32
Y int32
@ -57,4 +201,41 @@ type Ruleset interface {
ModifyInitialBoardState(initialState *BoardState) (*BoardState, error)
CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error)
IsGameOver(state *BoardState) (bool, error)
// Settings provides the game settings that are relevant to the ruleset.
Settings() Settings
}
// Settings contains all settings relevant to a game.
// It is used by game logic to take a previous game state and produce a next game state.
type Settings struct {
FoodSpawnChance int32 `json:"foodSpawnChance"`
MinimumFood int32 `json:"minimumFood"`
HazardDamagePerTurn int32 `json:"hazardDamagePerTurn"`
HazardMap string `json:"hazardMap"`
HazardMapAuthor string `json:"hazardMapAuthor"`
RoyaleSettings RoyaleSettings `json:"royale"`
SquadSettings SquadSettings `json:"squad"`
}
// RoyaleSettings contains settings that are specific to the "royale" game mode
type RoyaleSettings struct {
seed int64
ShrinkEveryNTurns int32 `json:"shrinkEveryNTurns"`
}
// SquadSettings contains settings that are specific to the "squad" game mode
type SquadSettings struct {
squadMap map[string]string
AllowBodyCollisions bool `json:"allowBodyCollisions"`
SharedElimination bool `json:"sharedElimination"`
SharedHealth bool `json:"sharedHealth"`
SharedLength bool `json:"sharedLength"`
}
// StageFunc represents a single stage of an ordered pipeline and applies custom logic to the board state each turn.
// It is expected to modify the boardState directly.
// The return values are a boolean (to indicate whether the game has ended as a result of the stage)
// and an error if any errors occurred during the stage.
//
// Errors should be treated as meaning the stage failed and the board state is now invalid.
type StageFunc func(*BoardState, Settings, []SnakeMove) (bool, error)

85
ruleset_internal_test.go Normal file
View file

@ -0,0 +1,85 @@
package rules
import (
"testing"
"github.com/stretchr/testify/require"
// included to allow using -update-fixtures for every package without errors
_ "github.com/BattlesnakeOfficial/rules/test"
)
func TestParamInt32(t *testing.T) {
require.Equal(t, int32(5), paramsInt32(nil, "test", 5), "nil map")
require.Equal(t, int32(10), paramsInt32(map[string]string{}, "foo", 10), "empty map")
require.Equal(t, int32(10), paramsInt32(map[string]string{"hullo": "there"}, "hullo", 10), "invalid value")
require.Equal(t, int32(20), paramsInt32(map[string]string{"bonjour": "20"}, "bonjour", 20), "valid value")
}
func TestParamBool(t *testing.T) {
// missing values default to specified value
require.Equal(t, true, paramsBool(nil, "test", true), "nil map true")
require.Equal(t, false, paramsBool(nil, "test", false), "nil map false")
// missing values default to specified value
require.Equal(t, true, paramsBool(map[string]string{}, "foo", true), "empty map true")
require.Equal(t, false, paramsBool(map[string]string{}, "foo", false), "empty map false")
// invalid values (exist but not booL) default to false
require.Equal(t, false, paramsBool(map[string]string{"hullo": "there"}, "hullo", true), "invalid value default true")
require.Equal(t, false, paramsBool(map[string]string{"hullo": "there"}, "hullo", false), "invalid value default false")
// valid values ignore defaults
require.Equal(t, false, paramsBool(map[string]string{"bonjour": "false"}, "bonjour", false), "valid value false")
require.Equal(t, true, paramsBool(map[string]string{"bonjour": "true"}, "bonjour", false), "valid value true")
}
func TestRulesetError(t *testing.T) {
err := (error)(RulesetError("test error string"))
require.Equal(t, "test error string", err.Error())
}
func TestRulesetBuilderInternals(t *testing.T) {
// test Royale with seed
rsb := NewRulesetBuilder().WithSeed(3).WithParams(map[string]string{ParamGameType: GameTypeRoyale})
require.Equal(t, int64(3), rsb.seed)
require.Equal(t, GameTypeRoyale, rsb.Ruleset().Name())
require.Equal(t, int64(3), rsb.Ruleset().(*RoyaleRuleset).Seed)
// test squad configuration
rsb = NewRulesetBuilder().
WithParams(map[string]string{
ParamGameType: GameTypeSquad,
}).
AddSnakeToSquad("snek1", "squad1").
AddSnakeToSquad("snek2", "squad1").
AddSnakeToSquad("snek3", "squad2").
AddSnakeToSquad("snek4", "squad2")
require.NotNil(t, rsb.Ruleset())
require.Equal(t, GameTypeSquad, rsb.Ruleset().Name())
require.Equal(t, 4, len(rsb.squads))
require.Equal(t, "squad1", rsb.Ruleset().(*SquadRuleset).SquadMap["snek1"])
require.Equal(t, "squad1", rsb.Ruleset().(*SquadRuleset).SquadMap["snek2"])
require.Equal(t, "squad2", rsb.Ruleset().(*SquadRuleset).SquadMap["snek3"])
require.Equal(t, "squad2", rsb.Ruleset().(*SquadRuleset).SquadMap["snek4"])
// test parameter merging
rsb = NewRulesetBuilder().
WithParams(map[string]string{
"someSetting": "some value",
"anotherSetting": "another value",
}).
WithParams(map[string]string{
"anotherSetting": "overridden value",
"aNewSetting": "a new value",
})
require.Equal(t, map[string]string{
"someSetting": "some value",
"anotherSetting": "overridden value",
"aNewSetting": "a new value",
}, rsb.params, "multiple calls to WithParams should merge parameters")
}

View file

@ -1,15 +1,193 @@
package rules
package rules_test
import (
"testing"
"github.com/BattlesnakeOfficial/rules"
"github.com/stretchr/testify/assert"
"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) {
err := (error)(RulesetError("test error string"))
require.Equal(t, "test error string", err.Error())
func TestStandardRulesetSettings(t *testing.T) {
ruleset := rules.StandardRuleset{
MinimumFood: 5,
FoodSpawnChance: 10,
HazardDamagePerTurn: 10,
HazardMap: "hz_spiral",
HazardMapAuthor: "altersaddle",
}
assert.Equal(t, ruleset.MinimumFood, ruleset.Settings().MinimumFood)
assert.Equal(t, ruleset.FoodSpawnChance, ruleset.Settings().FoodSpawnChance)
assert.Equal(t, ruleset.HazardDamagePerTurn, ruleset.Settings().HazardDamagePerTurn)
assert.Equal(t, ruleset.HazardMap, ruleset.Settings().HazardMap)
assert.Equal(t, ruleset.HazardMapAuthor, ruleset.Settings().HazardMapAuthor)
}
func TestWrappedRulesetSettings(t *testing.T) {
ruleset := rules.WrappedRuleset{
StandardRuleset: rules.StandardRuleset{
MinimumFood: 5,
FoodSpawnChance: 10,
HazardDamagePerTurn: 10,
HazardMap: "hz_spiral",
HazardMapAuthor: "altersaddle",
},
}
assert.Equal(t, ruleset.MinimumFood, ruleset.Settings().MinimumFood)
assert.Equal(t, ruleset.FoodSpawnChance, ruleset.Settings().FoodSpawnChance)
assert.Equal(t, ruleset.HazardDamagePerTurn, ruleset.Settings().HazardDamagePerTurn)
assert.Equal(t, ruleset.HazardMap, ruleset.Settings().HazardMap)
assert.Equal(t, ruleset.HazardMapAuthor, ruleset.Settings().HazardMapAuthor)
}
func TestSoloRulesetSettings(t *testing.T) {
ruleset := rules.SoloRuleset{
StandardRuleset: rules.StandardRuleset{
MinimumFood: 5,
FoodSpawnChance: 10,
HazardDamagePerTurn: 10,
HazardMap: "hz_spiral",
HazardMapAuthor: "altersaddle",
},
}
assert.Equal(t, ruleset.MinimumFood, ruleset.Settings().MinimumFood)
assert.Equal(t, ruleset.FoodSpawnChance, ruleset.Settings().FoodSpawnChance)
assert.Equal(t, ruleset.HazardDamagePerTurn, ruleset.Settings().HazardDamagePerTurn)
assert.Equal(t, ruleset.HazardMap, ruleset.Settings().HazardMap)
assert.Equal(t, ruleset.HazardMapAuthor, ruleset.Settings().HazardMapAuthor)
}
func TestRoyaleRulesetSettings(t *testing.T) {
ruleset := rules.RoyaleRuleset{
Seed: 30,
ShrinkEveryNTurns: 12,
StandardRuleset: rules.StandardRuleset{
MinimumFood: 5,
FoodSpawnChance: 10,
HazardDamagePerTurn: 10,
HazardMap: "hz_spiral",
HazardMapAuthor: "altersaddle",
},
}
assert.Equal(t, ruleset.ShrinkEveryNTurns, ruleset.Settings().RoyaleSettings.ShrinkEveryNTurns)
assert.Equal(t, ruleset.MinimumFood, ruleset.Settings().MinimumFood)
assert.Equal(t, ruleset.FoodSpawnChance, ruleset.Settings().FoodSpawnChance)
assert.Equal(t, ruleset.HazardDamagePerTurn, ruleset.Settings().HazardDamagePerTurn)
assert.Equal(t, ruleset.HazardMap, ruleset.Settings().HazardMap)
assert.Equal(t, ruleset.HazardMapAuthor, ruleset.Settings().HazardMapAuthor)
}
func TestConstrictorRulesetSettings(t *testing.T) {
ruleset := rules.ConstrictorRuleset{
StandardRuleset: rules.StandardRuleset{
MinimumFood: 5,
FoodSpawnChance: 10,
HazardDamagePerTurn: 10,
HazardMap: "hz_spiral",
HazardMapAuthor: "altersaddle",
},
}
assert.Equal(t, ruleset.MinimumFood, ruleset.Settings().MinimumFood)
assert.Equal(t, ruleset.FoodSpawnChance, ruleset.Settings().FoodSpawnChance)
assert.Equal(t, ruleset.HazardDamagePerTurn, ruleset.Settings().HazardDamagePerTurn)
assert.Equal(t, ruleset.HazardMap, ruleset.Settings().HazardMap)
assert.Equal(t, ruleset.HazardMapAuthor, ruleset.Settings().HazardMapAuthor)
}
func TestSquadRulesetSettings(t *testing.T) {
ruleset := rules.SquadRuleset{
AllowBodyCollisions: true,
SharedElimination: false,
SharedHealth: true,
SharedLength: false,
StandardRuleset: rules.StandardRuleset{
MinimumFood: 5,
FoodSpawnChance: 10,
HazardDamagePerTurn: 10,
HazardMap: "hz_spiral",
HazardMapAuthor: "altersaddle",
},
}
assert.Equal(t, ruleset.AllowBodyCollisions, ruleset.Settings().SquadSettings.AllowBodyCollisions)
assert.Equal(t, ruleset.SharedElimination, ruleset.Settings().SquadSettings.SharedElimination)
assert.Equal(t, ruleset.SharedHealth, ruleset.Settings().SquadSettings.SharedHealth)
assert.Equal(t, ruleset.SharedLength, ruleset.Settings().SquadSettings.SharedLength)
assert.Equal(t, ruleset.MinimumFood, ruleset.Settings().MinimumFood)
assert.Equal(t, ruleset.FoodSpawnChance, ruleset.Settings().FoodSpawnChance)
assert.Equal(t, ruleset.HazardDamagePerTurn, ruleset.Settings().HazardDamagePerTurn)
assert.Equal(t, ruleset.HazardMap, ruleset.Settings().HazardMap)
assert.Equal(t, ruleset.HazardMapAuthor, ruleset.Settings().HazardMapAuthor)
}
func TestRulesetBuilder(t *testing.T) {
// Test that a fresh instance can produce a Ruleset
require.NotNil(t, rules.NewRulesetBuilder().Ruleset())
require.Equal(t, rules.GameTypeStandard, rules.NewRulesetBuilder().Ruleset().Name(), "should default to standard game")
// test nil safety / defaults
require.NotNil(t, rules.NewRulesetBuilder().Ruleset())
// make sure it works okay for lots of game types
expectedResults := []struct {
GameType string
Snakes map[string]string
}{
{GameType: rules.GameTypeStandard},
{GameType: rules.GameTypeWrapped},
{GameType: rules.GameTypeRoyale},
{GameType: rules.GameTypeSolo},
{GameType: rules.GameTypeSquad, Snakes: map[string]string{
"one": "s1",
"two": "s1",
"three": "s2",
"four": "s2",
"five": "s3",
"six": "s3",
"seven": "s4",
"eight": "s4",
}},
{GameType: rules.GameTypeConstrictor},
}
for _, expected := range expectedResults {
t.Run(expected.GameType, func(t *testing.T) {
rsb := rules.NewRulesetBuilder()
rsb.WithParams(map[string]string{
// apply the standard rule params
rules.ParamGameType: expected.GameType,
rules.ParamFoodSpawnChance: "10",
rules.ParamMinimumFood: "5",
rules.ParamHazardDamagePerTurn: "12",
rules.ParamHazardMap: "test",
rules.ParamHazardMapAuthor: "tester",
})
// add any snake squads
for id, squad := range expected.Snakes {
rsb = rsb.AddSnakeToSquad(id, squad)
}
require.NotNil(t, rsb.Ruleset())
require.Equal(t, expected.GameType, rsb.Ruleset().Name())
// All the standard settings should always be copied over
require.Equal(t, int32(10), rsb.Ruleset().Settings().FoodSpawnChance)
require.Equal(t, int32(12), rsb.Ruleset().Settings().HazardDamagePerTurn)
require.Equal(t, int32(5), rsb.Ruleset().Settings().MinimumFood)
require.Equal(t, "test", rsb.Ruleset().Settings().HazardMap)
require.Equal(t, "tester", rsb.Ruleset().Settings().HazardMapAuthor)
})
}
}
func TestStageFuncContract(t *testing.T) {
//nolint:gosimple
var stage rules.StageFunc
stage = func(bs *rules.BoardState, s rules.Settings, sm []rules.SnakeMove) (bool, error) {
return true, nil
}
ended, err := stage(nil, rules.NewRulesetBuilder().Ruleset().Settings(), nil)
require.NoError(t, err)
require.True(t, ended)
}

View file

@ -4,9 +4,13 @@ type SoloRuleset struct {
StandardRuleset
}
func (r *SoloRuleset) Name() string { return "solo" }
func (r *SoloRuleset) Name() string { return GameTypeSolo }
func (r *SoloRuleset) IsGameOver(b *BoardState) (bool, error) {
return r.callStageFunc(GameOverSolo, b, []SnakeMove{})
}
func GameOverSolo(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
for i := 0; i < len(b.Snakes); i++ {
if b.Snakes[i].EliminatedCause == NotEliminated {
return false, nil

View file

@ -16,9 +16,7 @@ type SquadRuleset struct {
SharedLength bool
}
const EliminatedBySquad = "squad-eliminated"
func (r *SquadRuleset) Name() string { return "squad" }
func (r *SquadRuleset) Name() string { return GameTypeSquad }
func (r *SquadRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) {
nextBoardState, err := r.StandardRuleset.CreateNextBoardState(prevState, moves)
@ -26,13 +24,11 @@ func (r *SquadRuleset) CreateNextBoardState(prevState *BoardState, moves []Snake
return nil, err
}
// TODO: LOG?
err = r.resurrectSquadBodyCollisions(nextBoardState)
if err != nil {
return nil, err
}
// TODO: LOG?
err = r.shareSquadAttributes(nextBoardState)
if err != nil {
return nil, err
@ -41,38 +37,50 @@ func (r *SquadRuleset) CreateNextBoardState(prevState *BoardState, moves []Snake
return nextBoardState, nil
}
func (r *SquadRuleset) areSnakesOnSameSquad(snake *Snake, other *Snake) bool {
return r.areSnakeIDsOnSameSquad(snake.ID, other.ID)
func areSnakesOnSameSquad(squadMap map[string]string, snake *Snake, other *Snake) bool {
return areSnakeIDsOnSameSquad(squadMap, snake.ID, other.ID)
}
func (r *SquadRuleset) areSnakeIDsOnSameSquad(snakeID string, otherID string) bool {
return r.SquadMap[snakeID] == r.SquadMap[otherID]
func areSnakeIDsOnSameSquad(squadMap map[string]string, snakeID string, otherID string) bool {
return squadMap[snakeID] == squadMap[otherID]
}
func (r *SquadRuleset) resurrectSquadBodyCollisions(b *BoardState) error {
if !r.AllowBodyCollisions {
return nil
_, err := r.callStageFunc(ResurrectSnakesSquad, b, []SnakeMove{})
return err
}
func ResurrectSnakesSquad(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
if !settings.SquadSettings.AllowBodyCollisions {
return false, nil
}
for i := 0; i < len(b.Snakes); i++ {
snake := &b.Snakes[i]
if snake.EliminatedCause == EliminatedByCollision {
if snake.EliminatedBy == "" {
return errors.New("snake eliminated by collision and eliminatedby is not set")
return false, errors.New("snake eliminated by collision and eliminatedby is not set")
}
if snake.ID != snake.EliminatedBy && r.areSnakeIDsOnSameSquad(snake.ID, snake.EliminatedBy) {
if snake.ID != snake.EliminatedBy && areSnakeIDsOnSameSquad(settings.SquadSettings.squadMap, snake.ID, snake.EliminatedBy) {
snake.EliminatedCause = NotEliminated
snake.EliminatedBy = ""
}
}
}
return nil
return false, nil
}
func (r *SquadRuleset) shareSquadAttributes(b *BoardState) error {
if !(r.SharedElimination || r.SharedLength || r.SharedHealth) {
return nil
_, err := r.callStageFunc(ShareAttributesSquad, b, []SnakeMove{})
return err
}
func ShareAttributesSquad(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
squadSettings := settings.SquadSettings
if !(squadSettings.SharedElimination || squadSettings.SharedLength || squadSettings.SharedHealth) {
return false, nil
}
for i := 0; i < len(b.Snakes); i++ {
@ -83,21 +91,21 @@ func (r *SquadRuleset) shareSquadAttributes(b *BoardState) error {
for j := 0; j < len(b.Snakes); j++ {
other := &b.Snakes[j]
if r.areSnakesOnSameSquad(snake, other) {
if r.SharedHealth {
if areSnakesOnSameSquad(squadSettings.squadMap, snake, other) {
if squadSettings.SharedHealth {
if snake.Health < other.Health {
snake.Health = other.Health
}
}
if r.SharedLength {
if squadSettings.SharedLength {
if len(snake.Body) == 0 || len(other.Body) == 0 {
return errors.New("found snake of zero length")
return false, errors.New("found snake of zero length")
}
for len(snake.Body) < len(other.Body) {
r.growSnake(snake)
growSnake(snake)
}
}
if r.SharedElimination {
if squadSettings.SharedElimination {
if snake.EliminatedCause == NotEliminated && other.EliminatedCause != NotEliminated {
snake.EliminatedCause = EliminatedBySquad
// We intentionally do not set snake.EliminatedBy because there might be multiple culprits.
@ -108,10 +116,14 @@ func (r *SquadRuleset) shareSquadAttributes(b *BoardState) error {
}
}
return nil
return false, nil
}
func (r *SquadRuleset) IsGameOver(b *BoardState) (bool, error) {
return r.callStageFunc(GameOverSquad, b, []SnakeMove{})
}
func GameOverSquad(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
snakesRemaining := []*Snake{}
for i := 0; i < len(b.Snakes); i++ {
if b.Snakes[i].EliminatedCause == NotEliminated {
@ -120,7 +132,7 @@ func (r *SquadRuleset) IsGameOver(b *BoardState) (bool, error) {
}
for i := 0; i < len(snakesRemaining); i++ {
if !r.areSnakesOnSameSquad(snakesRemaining[i], snakesRemaining[0]) {
if !areSnakesOnSameSquad(settings.SquadSettings.squadMap, snakesRemaining[i], snakesRemaining[0]) {
// There are multiple squads remaining
return false, nil
}
@ -128,3 +140,20 @@ func (r *SquadRuleset) IsGameOver(b *BoardState) (bool, error) {
// no snakes or single squad remaining
return true, nil
}
func (r SquadRuleset) Settings() Settings {
s := r.StandardRuleset.Settings()
s.SquadSettings = SquadSettings{
squadMap: r.SquadMap,
AllowBodyCollisions: r.AllowBodyCollisions,
SharedElimination: r.SharedElimination,
SharedHealth: r.SharedHealth,
SharedLength: r.SharedLength,
}
return s
}
// Adaptor for integrating stages into SquadRuleset
func (r *SquadRuleset) callStageFunc(stage StageFunc, boardState *BoardState, moves []SnakeMove) (bool, error) {
return stage(boardState, r.Settings(), moves)
}

View file

@ -102,8 +102,8 @@ func TestSquadAllowBodyCollisions(t *testing.T) {
func TestSquadAllowBodyCollisionsEliminatedByNotSet(t *testing.T) {
boardState := &BoardState{
Snakes: []Snake{
Snake{ID: "1", EliminatedCause: EliminatedByCollision},
Snake{ID: "2"},
{ID: "1", EliminatedCause: EliminatedByCollision},
{ID: "2"},
},
}
r := SquadRuleset{
@ -280,8 +280,8 @@ func TestSquadSharedElimination(t *testing.T) {
func TestSquadSharedAttributesErrorLengthZero(t *testing.T) {
boardState := &BoardState{
Snakes: []Snake{
Snake{ID: "1"},
Snake{ID: "2"},
{ID: "1"},
{ID: "2"},
},
}
r := SquadRuleset{

View file

@ -9,9 +9,11 @@ type StandardRuleset struct {
FoodSpawnChance int32 // [0, 100]
MinimumFood int32
HazardDamagePerTurn int32
HazardMap string // optional
HazardMapAuthor string // optional
}
func (r *StandardRuleset) Name() string { return "standard" }
func (r *StandardRuleset) Name() string { return GameTypeStandard }
func (r *StandardRuleset) ModifyInitialBoardState(initialState *BoardState) (*BoardState, error) {
// No-op
@ -22,15 +24,11 @@ func (r *StandardRuleset) CreateNextBoardState(prevState *BoardState, moves []Sn
// We specifically want to copy prevState, so as not to alter it directly.
nextState := prevState.Clone()
// TODO: Gut check the BoardState?
// TODO: LOG?
err := r.moveSnakes(nextState, moves)
if err != nil {
return nil, err
}
// TODO: LOG?
err = r.reduceSnakeHealth(nextState)
if err != nil {
return nil, err
@ -41,7 +39,6 @@ func (r *StandardRuleset) CreateNextBoardState(prevState *BoardState, moves []Sn
return nil, err
}
// TODO: LOG?
// bvanvugt: We specifically want this to happen before elimination for two reasons:
// 1) We want snakes to be able to eat on their very last turn and still survive.
// 2) So that head-to-head collisions on food still remove the food.
@ -52,13 +49,11 @@ func (r *StandardRuleset) CreateNextBoardState(prevState *BoardState, moves []Sn
return nil, err
}
// TODO: LOG?
err = r.maybeSpawnFood(nextState)
if err != nil {
return nil, err
}
// TODO: LOG?
err = r.maybeEliminateSnakes(nextState)
if err != nil {
return nil, err
@ -68,6 +63,16 @@ func (r *StandardRuleset) CreateNextBoardState(prevState *BoardState, moves []Sn
}
func (r *StandardRuleset) moveSnakes(b *BoardState, moves []SnakeMove) error {
_, err := r.callStageFunc(MoveSnakesStandard, b, moves)
return err
}
func MoveSnakesStandard(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
// If no moves are passed, pass on modifying the initial board state
if len(moves) == 0 {
return false, nil
}
// Sanity check that all non-eliminated snakes have moves and bodies.
for i := 0; i < len(b.Snakes); i++ {
snake := &b.Snakes[i]
@ -76,7 +81,7 @@ func (r *StandardRuleset) moveSnakes(b *BoardState, moves []SnakeMove) error {
}
if len(snake.Body) == 0 {
return ErrorZeroLengthSnake
return false, ErrorZeroLengthSnake
}
moveFound := false
for _, move := range moves {
@ -86,7 +91,7 @@ func (r *StandardRuleset) moveSnakes(b *BoardState, moves []SnakeMove) error {
}
}
if !moveFound {
return ErrorNoMoveFound
return false, ErrorNoMoveFound
}
}
@ -103,7 +108,7 @@ func (r *StandardRuleset) moveSnakes(b *BoardState, moves []SnakeMove) error {
case MoveUp, MoveDown, MoveRight, MoveLeft:
break
default:
appliedMove = r.getDefaultMove(snake.Body)
appliedMove = getDefaultMove(snake.Body)
}
newHead := Point{}
@ -128,10 +133,10 @@ func (r *StandardRuleset) moveSnakes(b *BoardState, moves []SnakeMove) error {
}
}
}
return nil
return false, nil
}
func (r *StandardRuleset) getDefaultMove(snakeBody []Point) string {
func getDefaultMove(snakeBody []Point) string {
if len(snakeBody) >= 2 {
// Use neck to determine last move made
head, neck := snakeBody[0], snakeBody[1]
@ -160,15 +165,25 @@ func (r *StandardRuleset) getDefaultMove(snakeBody []Point) string {
}
func (r *StandardRuleset) reduceSnakeHealth(b *BoardState) error {
_, err := r.callStageFunc(ReduceSnakeHealthStandard, b, []SnakeMove{})
return err
}
func ReduceSnakeHealthStandard(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
for i := 0; i < len(b.Snakes); i++ {
if b.Snakes[i].EliminatedCause == NotEliminated {
b.Snakes[i].Health = b.Snakes[i].Health - 1
}
}
return nil
return false, nil
}
func (r *StandardRuleset) maybeDamageHazards(b *BoardState) error {
_, err := r.callStageFunc(DamageHazardsStandard, b, []SnakeMove{})
return err
}
func DamageHazardsStandard(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
for i := 0; i < len(b.Snakes); i++ {
snake := &b.Snakes[i]
if snake.EliminatedCause != NotEliminated {
@ -189,21 +204,26 @@ func (r *StandardRuleset) maybeDamageHazards(b *BoardState) error {
}
// Snake is in a hazard, reduce health
snake.Health = snake.Health - r.HazardDamagePerTurn
snake.Health = snake.Health - settings.HazardDamagePerTurn
if snake.Health < 0 {
snake.Health = 0
}
if r.snakeIsOutOfHealth(snake) {
if snakeIsOutOfHealth(snake) {
snake.EliminatedCause = EliminatedByOutOfHealth
}
}
}
}
return nil
return false, nil
}
func (r *StandardRuleset) maybeEliminateSnakes(b *BoardState) error {
_, err := r.callStageFunc(EliminateSnakesStandard, b, []SnakeMove{})
return err
}
func EliminateSnakesStandard(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
// First order snake indices by length.
// In multi-collision scenarios we want to always attribute elimination to the longest snake.
snakeIndicesByLength := make([]int, len(b.Snakes))
@ -224,15 +244,15 @@ func (r *StandardRuleset) maybeEliminateSnakes(b *BoardState) error {
continue
}
if len(snake.Body) <= 0 {
return ErrorZeroLengthSnake
return false, ErrorZeroLengthSnake
}
if r.snakeIsOutOfHealth(snake) {
if snakeIsOutOfHealth(snake) {
snake.EliminatedCause = EliminatedByOutOfHealth
continue
}
if r.snakeIsOutOfBounds(snake, b.Width, b.Height) {
if snakeIsOutOfBounds(snake, b.Width, b.Height) {
snake.EliminatedCause = EliminatedByOutOfBounds
continue
}
@ -252,11 +272,11 @@ func (r *StandardRuleset) maybeEliminateSnakes(b *BoardState) error {
continue
}
if len(snake.Body) <= 0 {
return ErrorZeroLengthSnake
return false, ErrorZeroLengthSnake
}
// Check for self-collisions first
if r.snakeHasBodyCollided(snake, snake) {
if snakeHasBodyCollided(snake, snake) {
collisionEliminations = append(collisionEliminations, CollisionElimination{
ID: snake.ID,
Cause: EliminatedBySelfCollision,
@ -272,7 +292,7 @@ func (r *StandardRuleset) maybeEliminateSnakes(b *BoardState) error {
if other.EliminatedCause != NotEliminated {
continue
}
if snake.ID != other.ID && r.snakeHasBodyCollided(snake, other) {
if snake.ID != other.ID && snakeHasBodyCollided(snake, other) {
collisionEliminations = append(collisionEliminations, CollisionElimination{
ID: snake.ID,
Cause: EliminatedByCollision,
@ -293,7 +313,7 @@ func (r *StandardRuleset) maybeEliminateSnakes(b *BoardState) error {
if other.EliminatedCause != NotEliminated {
continue
}
if snake.ID != other.ID && r.snakeHasLostHeadToHead(snake, other) {
if snake.ID != other.ID && snakeHasLostHeadToHead(snake, other) {
collisionEliminations = append(collisionEliminations, CollisionElimination{
ID: snake.ID,
Cause: EliminatedByHeadToHeadCollision,
@ -320,14 +340,14 @@ func (r *StandardRuleset) maybeEliminateSnakes(b *BoardState) error {
}
}
return nil
return false, nil
}
func (r *StandardRuleset) snakeIsOutOfHealth(s *Snake) bool {
func snakeIsOutOfHealth(s *Snake) bool {
return s.Health <= 0
}
func (r *StandardRuleset) snakeIsOutOfBounds(s *Snake, boardWidth int32, boardHeight int32) bool {
func snakeIsOutOfBounds(s *Snake, boardWidth int32, boardHeight int32) bool {
for _, point := range s.Body {
if (point.X < 0) || (point.X >= boardWidth) {
return true
@ -339,7 +359,7 @@ func (r *StandardRuleset) snakeIsOutOfBounds(s *Snake, boardWidth int32, boardHe
return false
}
func (r *StandardRuleset) snakeHasBodyCollided(s *Snake, other *Snake) bool {
func snakeHasBodyCollided(s *Snake, other *Snake) bool {
head := s.Body[0]
for i, body := range other.Body {
if i == 0 {
@ -351,7 +371,7 @@ func (r *StandardRuleset) snakeHasBodyCollided(s *Snake, other *Snake) bool {
return false
}
func (r *StandardRuleset) snakeHasLostHeadToHead(s *Snake, other *Snake) bool {
func snakeHasLostHeadToHead(s *Snake, other *Snake) bool {
if s.Body[0].X == other.Body[0].X && s.Body[0].Y == other.Body[0].Y {
return len(s.Body) <= len(other.Body)
}
@ -359,6 +379,11 @@ func (r *StandardRuleset) snakeHasLostHeadToHead(s *Snake, other *Snake) bool {
}
func (r *StandardRuleset) maybeFeedSnakes(b *BoardState) error {
_, err := r.callStageFunc(FeedSnakesStandard, b, []SnakeMove{})
return err
}
func FeedSnakesStandard(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
newFood := []Point{}
for _, food := range b.Food {
foodHasBeenEaten := false
@ -371,7 +396,7 @@ func (r *StandardRuleset) maybeFeedSnakes(b *BoardState) error {
}
if snake.Body[0].X == food.X && snake.Body[0].Y == food.Y {
r.feedSnake(snake)
feedSnake(snake)
foodHasBeenEaten = true
}
}
@ -382,31 +407,41 @@ func (r *StandardRuleset) maybeFeedSnakes(b *BoardState) error {
}
b.Food = newFood
return nil
return false, nil
}
func (r *StandardRuleset) feedSnake(snake *Snake) {
r.growSnake(snake)
func feedSnake(snake *Snake) {
growSnake(snake)
snake.Health = SnakeMaxHealth
}
func (r *StandardRuleset) growSnake(snake *Snake) {
func growSnake(snake *Snake) {
if len(snake.Body) > 0 {
snake.Body = append(snake.Body, snake.Body[len(snake.Body)-1])
}
}
func (r *StandardRuleset) maybeSpawnFood(b *BoardState) error {
_, err := r.callStageFunc(SpawnFoodStandard, b, []SnakeMove{})
return err
}
func SpawnFoodStandard(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
numCurrentFood := int32(len(b.Food))
if numCurrentFood < r.MinimumFood {
return PlaceFoodRandomly(b, r.MinimumFood-numCurrentFood)
} else if r.FoodSpawnChance > 0 && int32(rand.Intn(100)) < r.FoodSpawnChance {
return PlaceFoodRandomly(b, 1)
if numCurrentFood < settings.MinimumFood {
return false, PlaceFoodRandomly(b, settings.MinimumFood-numCurrentFood)
}
return nil
if settings.FoodSpawnChance > 0 && int32(rand.Intn(100)) < settings.FoodSpawnChance {
return false, PlaceFoodRandomly(b, 1)
}
return false, nil
}
func (r *StandardRuleset) IsGameOver(b *BoardState) (bool, error) {
return r.callStageFunc(GameOverStandard, b, []SnakeMove{})
}
func GameOverStandard(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
numSnakesRemaining := 0
for i := 0; i < len(b.Snakes); i++ {
if b.Snakes[i].EliminatedCause == NotEliminated {
@ -415,3 +450,18 @@ func (r *StandardRuleset) IsGameOver(b *BoardState) (bool, error) {
}
return numSnakesRemaining <= 1, nil
}
func (r StandardRuleset) Settings() Settings {
return Settings{
FoodSpawnChance: r.FoodSpawnChance,
MinimumFood: r.MinimumFood,
HazardDamagePerTurn: r.HazardDamagePerTurn,
HazardMap: r.HazardMap,
HazardMapAuthor: r.HazardMapAuthor,
}
}
// Adaptor for integrating stages into StandardRuleset
func (r *StandardRuleset) callStageFunc(stage StageFunc, boardState *BoardState, moves []SnakeMove) (bool, error) {
return stage(boardState, r.Settings(), moves)
}

View file

@ -64,7 +64,9 @@ var standardCaseErrNoMoveFound = gameTestCase{
Food: []Point{{0, 0}, {1, 0}},
Hazards: []Point{},
},
[]SnakeMove{},
[]SnakeMove{
{ID: "one", Move: MoveUp},
},
ErrorNoMoveFound,
nil,
}
@ -767,9 +769,8 @@ func TestGetDefaultMove(t *testing.T) {
},
}
r := StandardRuleset{}
for _, test := range tests {
actualMove := r.getDefaultMove(test.SnakeBody)
actualMove := getDefaultMove(test.SnakeBody)
require.Equal(t, test.ExpectedMove, actualMove)
}
}
@ -835,10 +836,9 @@ func TestSnakeIsOutOfHealth(t *testing.T) {
{Health: math.MaxInt32, Expected: false},
}
r := StandardRuleset{}
for _, test := range tests {
s := &Snake{Health: test.Health}
require.Equal(t, test.Expected, r.snakeIsOutOfHealth(s), "Health: %+v", test.Health)
require.Equal(t, test.Expected, snakeIsOutOfHealth(s), "Health: %+v", test.Health)
}
}
@ -877,14 +877,13 @@ func TestSnakeIsOutOfBounds(t *testing.T) {
{Point{X: math.MaxInt32, Y: math.MaxInt32}, true},
}
r := StandardRuleset{}
for _, test := range tests {
// Test with point as head
s := Snake{Body: []Point{test.Point}}
require.Equal(t, test.Expected, r.snakeIsOutOfBounds(&s, boardWidth, boardHeight), "Head%+v", test.Point)
require.Equal(t, test.Expected, snakeIsOutOfBounds(&s, boardWidth, boardHeight), "Head%+v", test.Point)
// Test with point as body
s = Snake{Body: []Point{{0, 0}, {0, 0}, test.Point}}
require.Equal(t, test.Expected, r.snakeIsOutOfBounds(&s, boardWidth, boardHeight), "Body%+v", test.Point)
require.Equal(t, test.Expected, snakeIsOutOfBounds(&s, boardWidth, boardHeight), "Body%+v", test.Point)
}
}
@ -915,10 +914,9 @@ func TestSnakeHasBodyCollidedSelf(t *testing.T) {
{[]Point{{3, 3}, {3, 4}, {3, 3}, {4, 4}, {4, 5}}, true},
}
r := StandardRuleset{}
for _, test := range tests {
s := Snake{Body: test.Body}
require.Equal(t, test.Expected, r.snakeHasBodyCollided(&s, &s), "Body%q", s.Body)
require.Equal(t, test.Expected, snakeHasBodyCollided(&s, &s), "Body%q", s.Body)
}
}
@ -966,11 +964,10 @@ func TestSnakeHasBodyCollidedOther(t *testing.T) {
},
}
r := StandardRuleset{}
for _, test := range tests {
s := &Snake{Body: test.SnakeBody}
o := &Snake{Body: test.OtherBody}
require.Equal(t, test.Expected, r.snakeHasBodyCollided(s, o), "Snake%q Other%q", s.Body, o.Body)
require.Equal(t, test.Expected, snakeHasBodyCollided(s, o), "Snake%q Other%q", s.Body, o.Body)
}
}
@ -1031,12 +1028,11 @@ func TestSnakeHasLostHeadToHead(t *testing.T) {
},
}
r := StandardRuleset{}
for _, test := range tests {
s := Snake{Body: test.SnakeBody}
o := Snake{Body: test.OtherBody}
require.Equal(t, test.Expected, r.snakeHasLostHeadToHead(&s, &o), "Snake%q Other%q", s.Body, o.Body)
require.Equal(t, test.ExpectedOpposite, r.snakeHasLostHeadToHead(&o, &s), "Snake%q Other%q", s.Body, o.Body)
require.Equal(t, test.Expected, snakeHasLostHeadToHead(&s, &o), "Snake%q Other%q", s.Body, o.Body)
require.Equal(t, test.ExpectedOpposite, snakeHasLostHeadToHead(&o, &s), "Snake%q Other%q", s.Body, o.Body)
}
}

View file

@ -4,17 +4,7 @@ type WrappedRuleset struct {
StandardRuleset
}
func (r *WrappedRuleset) Name() string { return "wrapped" }
func replace(value, min, max int32) int32 {
if value < min {
return max
}
if value > max {
return min
}
return value
}
func (r *WrappedRuleset) Name() string { return GameTypeWrapped }
func (r *WrappedRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) {
nextState := prevState.Clone()
@ -53,9 +43,14 @@ func (r *WrappedRuleset) CreateNextBoardState(prevState *BoardState, moves []Sna
}
func (r *WrappedRuleset) moveSnakes(b *BoardState, moves []SnakeMove) error {
err := r.StandardRuleset.moveSnakes(b, moves)
_, err := r.callStageFunc(MoveSnakesWrapped, b, moves)
return err
}
func MoveSnakesWrapped(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
_, err := MoveSnakesStandard(b, settings, moves)
if err != nil {
return err
return false, err
}
for i := 0; i < len(b.Snakes); i++ {
@ -63,9 +58,19 @@ func (r *WrappedRuleset) moveSnakes(b *BoardState, moves []SnakeMove) error {
if snake.EliminatedCause != NotEliminated {
continue
}
snake.Body[0].X = replace(snake.Body[0].X, 0, b.Width-1)
snake.Body[0].Y = replace(snake.Body[0].Y, 0, b.Height-1)
snake.Body[0].X = wrap(snake.Body[0].X, 0, b.Width-1)
snake.Body[0].Y = wrap(snake.Body[0].Y, 0, b.Height-1)
}
return nil
return false, nil
}
func wrap(value, min, max int32) int32 {
if value < min {
return max
}
if value > max {
return min
}
return value
}

View file

@ -3,6 +3,7 @@ package rules
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -247,6 +248,20 @@ func TestEdgeCrossingEating(t *testing.T) {
}
}
func TestWrap(t *testing.T) {
// no wrap
assert.Equal(t, int32(0), wrap(0, 0, 0))
assert.Equal(t, int32(0), wrap(0, 1, 0))
assert.Equal(t, int32(0), wrap(0, 0, 1))
assert.Equal(t, int32(1), wrap(1, 0, 1))
// wrap to min
assert.Equal(t, int32(0), wrap(2, 0, 1))
// wrap to max
assert.Equal(t, int32(1), wrap(-1, 0, 1))
}
// Checks that snakes moving out of bounds get wrapped to the other side.
var wrappedCaseMoveAndWrap = gameTestCase{
"Wrapped Case Move and Wrap",