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 HazardDamagePerTurn int32
var ShrinkEveryNTurns 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{ var playCmd = &cobra.Command{
Use: "play", Use: "play",
Short: "Play a game of Battlesnake locally.", Short: "Play a game of Battlesnake locally.",
Long: "Play a game of Battlesnake locally.", Long: "Play a game of Battlesnake locally.",
Run: run, Run: run,
PreRun: playPreRun,
} }
func init() { func init() {
@ -90,6 +101,10 @@ func init() {
playCmd.Flags().SortFlags = false playCmd.Flags().SortFlags = false
} }
func playPreRun(cmd *cobra.Command, args []string) {
initialiseGameConfig()
}
var run = func(cmd *cobra.Command, args []string) { var run = func(cmd *cobra.Command, args []string) {
rand.Seed(Seed) 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 { func getRuleset(seed int64, snakeStates map[string]SnakeState) rules.Ruleset {
var ruleset rules.Ruleset rb := rules.NewRulesetBuilder().WithSeed(seed).WithParams(defaultConfig)
var royale rules.RoyaleRuleset
standard := rules.StandardRuleset{ for _, s := range snakeStates {
FoodSpawnChance: FoodSpawnChance, rb.AddSnakeToSquad(s.ID, s.Squad)
MinimumFood: MinimumFood,
HazardDamagePerTurn: 0,
} }
switch GameType { return rb.Ruleset()
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
} }
func initializeBoardFromArgs(ruleset rules.Ruleset, snakeStates map[string]SnakeState) *rules.BoardState { 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 { func createClientGame(ruleset rules.Ruleset) client.Game {
return client.Game{ID: GameId, Timeout: Timeout, Ruleset: client.Ruleset{ return 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: client.RulesetSettings{ Settings: ruleset.Settings(),
HazardDamagePerTurn: HazardDamagePerTurn,
FoodSpawnChance: FoodSpawnChance,
MinimumFood: MinimumFood,
RoyaleSettings: client.RoyaleSettings{
ShrinkEveryNTurns: ShrinkEveryNTurns,
},
SquadSettings: client.SquadSettings{
AllowBodyCollisions: true,
SharedElimination: true,
SharedHealth: true,
SharedLength: true,
},
},
}} }}
} }

View file

@ -1,6 +1,7 @@
package commands package commands
import ( import (
"fmt"
"testing" "testing"
"github.com/BattlesnakeOfficial/rules" "github.com/BattlesnakeOfficial/rules"
@ -35,8 +36,69 @@ func TestGetIndividualBoardStateForSnake(t *testing.T) {
s1State.ID: s1State, s1State.ID: s1State,
s2State.ID: s2State, 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) requestBody := serialiseSnakeRequest(snakeRequest)
test.RequireJSONMatchesFixture(t, "testdata/snake_request_body.json", string(requestBody)) 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": "", "hazardMap": "",
"hazardMapAuthor": "", "hazardMapAuthor": "",
"royale": { "royale": {
"shrinkEveryNTurns": 25 "shrinkEveryNTurns": 0
}, },
"squad": { "squad": {
"allowBodyCollisions": true, "allowBodyCollisions": false,
"sharedElimination": true, "sharedElimination": false,
"sharedHealth": true, "sharedHealth": false,
"sharedLength": true "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 package client
import "github.com/BattlesnakeOfficial/rules"
func exampleSnakeRequest() SnakeRequest { func exampleSnakeRequest() SnakeRequest {
return SnakeRequest{ return SnakeRequest{
Game: Game{ Game: Game{
@ -72,18 +74,18 @@ func exampleSnakeRequest() SnakeRequest {
} }
} }
var exampleRulesetSettings = RulesetSettings{ var exampleRulesetSettings = rules.Settings{
FoodSpawnChance: 10, FoodSpawnChance: 10,
MinimumFood: 20, MinimumFood: 20,
HazardDamagePerTurn: 30, HazardDamagePerTurn: 30,
HazardMap: "hz_spiral", HazardMap: "hz_spiral",
HazardMapAuthor: "altersaddle", HazardMapAuthor: "altersaddle",
RoyaleSettings: RoyaleSettings{ RoyaleSettings: rules.RoyaleSettings{
ShrinkEveryNTurns: 40, ShrinkEveryNTurns: 40,
}, },
SquadSettings: SquadSettings{ SquadSettings: rules.SquadSettings{
AllowBodyCollisions: true, AllowBodyCollisions: true,
SharedElimination: true, SharedElimination: true,
SharedHealth: true, SharedHealth: true,

View file

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

View file

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

View file

@ -4,29 +4,32 @@ type ConstrictorRuleset struct {
StandardRuleset StandardRuleset
} }
func (r *ConstrictorRuleset) Name() string { return "constrictor" } func (r *ConstrictorRuleset) Name() string { return GameTypeConstrictor }
func (r *ConstrictorRuleset) ModifyInitialBoardState(initialBoardState *BoardState) (*BoardState, error) { func (r *ConstrictorRuleset) ModifyInitialBoardState(initialBoardState *BoardState) (*BoardState, error) {
initialBoardState, err := r.StandardRuleset.ModifyInitialBoardState(initialBoardState) initialBoardState, err := r.StandardRuleset.ModifyInitialBoardState(initialBoardState)
if err != nil { if err != nil {
return nil, err return nil, err
} }
newBoardState := initialBoardState.Clone()
err = r.applyConstrictorRules(newBoardState) r.removeFood(initialBoardState)
err = r.applyConstrictorRules(initialBoardState)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return newBoardState, nil return initialBoardState, nil
} }
func (r *ConstrictorRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) { func (r *ConstrictorRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) {
nextState, err := r.StandardRuleset.CreateNextBoardState(prevState, moves) nextState, err := r.StandardRuleset.CreateNextBoardState(prevState, moves)
if err != nil { if err != nil {
return nil, err return nil, err
} }
r.removeFood(nextState)
err = r.applyConstrictorRules(nextState) err = r.applyConstrictorRules(nextState)
if err != nil { if err != nil {
return nil, err return nil, err
@ -35,10 +38,23 @@ func (r *ConstrictorRuleset) CreateNextBoardState(prevState *BoardState, moves [
return nextState, nil 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 // Remove all food from the board
b.Food = []Point{} 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 // Set all snakes to max health and ensure they grow next turn
for i := 0; i < len(b.Snakes); i++ { for i := 0; i < len(b.Snakes); i++ {
b.Snakes[i].Health = SnakeMaxHealth 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] tail := b.Snakes[i].Body[len(b.Snakes[i].Body)-1]
subTail := b.Snakes[i].Body[len(b.Snakes[i].Body)-2] subTail := b.Snakes[i].Body[len(b.Snakes[i].Body)-2]
if tail != subTail { 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 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) { func (r *RoyaleRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) {
if r.StandardRuleset.HazardDamagePerTurn < 1 { 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. // 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 { if err != nil {
return nil, err return nil, err
} }
@ -34,20 +34,28 @@ func (r *RoyaleRuleset) CreateNextBoardState(prevState *BoardState, moves []Snak
return nextBoardState, nil 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{} b.Hazards = []Point{}
if r.ShrinkEveryNTurns < 1 { // Royale uses the current turn to generate hazards, not the previous turn that's in the board state
return errors.New("royale game can't shrink more frequently than every turn") 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 { if turn < settings.RoyaleSettings.ShrinkEveryNTurns {
return nil 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 minX, maxX := int32(0), b.Width-1
minY, maxY := int32(0), b.Height-1 minY, maxY := int32(0), b.Height-1
for i := int32(0); i < numShrinks; i++ { 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 { for _, test := range tests {
b := &BoardState{ b := &BoardState{
Turn: test.Turn, Turn: test.Turn - 1,
Width: test.Width, Width: test.Width,
Height: test.Height, Height: test.Height,
} }
@ -102,7 +102,7 @@ func TestRoyaleHazards(t *testing.T) {
ShrinkEveryNTurns: test.ShrinkEveryNTurns, ShrinkEveryNTurns: test.ShrinkEveryNTurns,
} }
err := r.populateHazards(b, test.Turn) err := r.populateHazards(b)
require.Equal(t, test.Error, err) require.Equal(t, test.Error, err)
if err == nil { if err == nil {
// Obstacles should match // Obstacles should match
@ -131,7 +131,7 @@ func TestRoyalDamageNextTurn(t *testing.T) {
stateAfterTurn := func(prevState *BoardState, turn int32) *BoardState { stateAfterTurn := func(prevState *BoardState, turn int32) *BoardState {
nextState := prevState.Clone() nextState := prevState.Clone()
nextState.Turn = turn - 1 nextState.Turn = turn - 1
err := r.populateHazards(nextState, turn) err := r.populateHazards(nextState)
require.NoError(t, err) require.NoError(t, err)
nextState.Turn = turn nextState.Turn = turn
return nextState return nextState

View file

@ -1,5 +1,9 @@
package rules package rules
import (
"strconv"
)
type RulesetError string type RulesetError string
func (err RulesetError) Error() string { return string(err) } func (err RulesetError) Error() string { return string(err) }
@ -24,6 +28,7 @@ const (
EliminatedByOutOfHealth = "out-of-health" EliminatedByOutOfHealth = "out-of-health"
EliminatedByHeadToHeadCollision = "head-collision" EliminatedByHeadToHeadCollision = "head-collision"
EliminatedByOutOfBounds = "wall-collision" EliminatedByOutOfBounds = "wall-collision"
EliminatedBySquad = "squad-eliminated"
// TODO - Error consts // TODO - Error consts
ErrorTooManySnakes = RulesetError("too many snakes for fixed start positions") ErrorTooManySnakes = RulesetError("too many snakes for fixed start positions")
@ -31,8 +36,147 @@ const (
ErrorNoRoomForFood = RulesetError("not enough space to place food") ErrorNoRoomForFood = RulesetError("not enough space to place food")
ErrorNoMoveFound = RulesetError("move not provided for snake") ErrorNoMoveFound = RulesetError("move not provided for snake")
ErrorZeroLengthSnake = RulesetError("snake is length zero") 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 { type Point struct {
X int32 X int32
Y int32 Y int32
@ -57,4 +201,41 @@ type Ruleset interface {
ModifyInitialBoardState(initialState *BoardState) (*BoardState, error) ModifyInitialBoardState(initialState *BoardState) (*BoardState, error)
CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error)
IsGameOver(state *BoardState) (bool, 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 ( import (
"testing" "testing"
"github.com/BattlesnakeOfficial/rules"
"github.com/stretchr/testify/assert"
"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 TestStandardRulesetSettings(t *testing.T) {
err := (error)(RulesetError("test error string")) ruleset := rules.StandardRuleset{
require.Equal(t, "test error string", err.Error()) 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 StandardRuleset
} }
func (r *SoloRuleset) Name() string { return "solo" } func (r *SoloRuleset) Name() string { return GameTypeSolo }
func (r *SoloRuleset) IsGameOver(b *BoardState) (bool, error) { 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++ { for i := 0; i < len(b.Snakes); i++ {
if b.Snakes[i].EliminatedCause == NotEliminated { if b.Snakes[i].EliminatedCause == NotEliminated {
return false, nil return false, nil

View file

@ -16,9 +16,7 @@ type SquadRuleset struct {
SharedLength bool SharedLength bool
} }
const EliminatedBySquad = "squad-eliminated" func (r *SquadRuleset) Name() string { return GameTypeSquad }
func (r *SquadRuleset) Name() string { return "squad" }
func (r *SquadRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) { func (r *SquadRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) {
nextBoardState, err := r.StandardRuleset.CreateNextBoardState(prevState, moves) nextBoardState, err := r.StandardRuleset.CreateNextBoardState(prevState, moves)
@ -26,13 +24,11 @@ func (r *SquadRuleset) CreateNextBoardState(prevState *BoardState, moves []Snake
return nil, err return nil, err
} }
// TODO: LOG?
err = r.resurrectSquadBodyCollisions(nextBoardState) err = r.resurrectSquadBodyCollisions(nextBoardState)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// TODO: LOG?
err = r.shareSquadAttributes(nextBoardState) err = r.shareSquadAttributes(nextBoardState)
if err != nil { if err != nil {
return nil, err return nil, err
@ -41,38 +37,50 @@ func (r *SquadRuleset) CreateNextBoardState(prevState *BoardState, moves []Snake
return nextBoardState, nil return nextBoardState, nil
} }
func (r *SquadRuleset) areSnakesOnSameSquad(snake *Snake, other *Snake) bool { func areSnakesOnSameSquad(squadMap map[string]string, snake *Snake, other *Snake) bool {
return r.areSnakeIDsOnSameSquad(snake.ID, other.ID) return areSnakeIDsOnSameSquad(squadMap, snake.ID, other.ID)
} }
func (r *SquadRuleset) areSnakeIDsOnSameSquad(snakeID string, otherID string) bool { func areSnakeIDsOnSameSquad(squadMap map[string]string, snakeID string, otherID string) bool {
return r.SquadMap[snakeID] == r.SquadMap[otherID] return squadMap[snakeID] == squadMap[otherID]
} }
func (r *SquadRuleset) resurrectSquadBodyCollisions(b *BoardState) error { func (r *SquadRuleset) resurrectSquadBodyCollisions(b *BoardState) error {
if !r.AllowBodyCollisions { _, err := r.callStageFunc(ResurrectSnakesSquad, b, []SnakeMove{})
return nil 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++ { for i := 0; i < len(b.Snakes); i++ {
snake := &b.Snakes[i] snake := &b.Snakes[i]
if snake.EliminatedCause == EliminatedByCollision { if snake.EliminatedCause == EliminatedByCollision {
if snake.EliminatedBy == "" { 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.EliminatedCause = NotEliminated
snake.EliminatedBy = "" snake.EliminatedBy = ""
} }
} }
} }
return nil return false, nil
} }
func (r *SquadRuleset) shareSquadAttributes(b *BoardState) error { func (r *SquadRuleset) shareSquadAttributes(b *BoardState) error {
if !(r.SharedElimination || r.SharedLength || r.SharedHealth) { _, err := r.callStageFunc(ShareAttributesSquad, b, []SnakeMove{})
return nil 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++ { 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++ { for j := 0; j < len(b.Snakes); j++ {
other := &b.Snakes[j] other := &b.Snakes[j]
if r.areSnakesOnSameSquad(snake, other) { if areSnakesOnSameSquad(squadSettings.squadMap, snake, other) {
if r.SharedHealth { if squadSettings.SharedHealth {
if snake.Health < other.Health { if snake.Health < other.Health {
snake.Health = other.Health snake.Health = other.Health
} }
} }
if r.SharedLength { if squadSettings.SharedLength {
if len(snake.Body) == 0 || len(other.Body) == 0 { 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) { 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 { if snake.EliminatedCause == NotEliminated && other.EliminatedCause != NotEliminated {
snake.EliminatedCause = EliminatedBySquad snake.EliminatedCause = EliminatedBySquad
// We intentionally do not set snake.EliminatedBy because there might be multiple culprits. // 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) { 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{} snakesRemaining := []*Snake{}
for i := 0; i < len(b.Snakes); i++ { for i := 0; i < len(b.Snakes); i++ {
if b.Snakes[i].EliminatedCause == NotEliminated { 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++ { 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 // There are multiple squads remaining
return false, nil return false, nil
} }
@ -128,3 +140,20 @@ func (r *SquadRuleset) IsGameOver(b *BoardState) (bool, error) {
// no snakes or single squad remaining // no snakes or single squad remaining
return true, nil 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) { func TestSquadAllowBodyCollisionsEliminatedByNotSet(t *testing.T) {
boardState := &BoardState{ boardState := &BoardState{
Snakes: []Snake{ Snakes: []Snake{
Snake{ID: "1", EliminatedCause: EliminatedByCollision}, {ID: "1", EliminatedCause: EliminatedByCollision},
Snake{ID: "2"}, {ID: "2"},
}, },
} }
r := SquadRuleset{ r := SquadRuleset{
@ -280,8 +280,8 @@ func TestSquadSharedElimination(t *testing.T) {
func TestSquadSharedAttributesErrorLengthZero(t *testing.T) { func TestSquadSharedAttributesErrorLengthZero(t *testing.T) {
boardState := &BoardState{ boardState := &BoardState{
Snakes: []Snake{ Snakes: []Snake{
Snake{ID: "1"}, {ID: "1"},
Snake{ID: "2"}, {ID: "2"},
}, },
} }
r := SquadRuleset{ r := SquadRuleset{

View file

@ -9,9 +9,11 @@ type StandardRuleset struct {
FoodSpawnChance int32 // [0, 100] FoodSpawnChance int32 // [0, 100]
MinimumFood int32 MinimumFood int32
HazardDamagePerTurn 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) { func (r *StandardRuleset) ModifyInitialBoardState(initialState *BoardState) (*BoardState, error) {
// No-op // 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. // We specifically want to copy prevState, so as not to alter it directly.
nextState := prevState.Clone() nextState := prevState.Clone()
// TODO: Gut check the BoardState?
// TODO: LOG?
err := r.moveSnakes(nextState, moves) err := r.moveSnakes(nextState, moves)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// TODO: LOG?
err = r.reduceSnakeHealth(nextState) err = r.reduceSnakeHealth(nextState)
if err != nil { if err != nil {
return nil, err return nil, err
@ -41,7 +39,6 @@ func (r *StandardRuleset) CreateNextBoardState(prevState *BoardState, moves []Sn
return nil, err return nil, err
} }
// TODO: LOG?
// bvanvugt: We specifically want this to happen before elimination for two reasons: // 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. // 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. // 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 return nil, err
} }
// TODO: LOG?
err = r.maybeSpawnFood(nextState) err = r.maybeSpawnFood(nextState)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// TODO: LOG?
err = r.maybeEliminateSnakes(nextState) err = r.maybeEliminateSnakes(nextState)
if err != nil { if err != nil {
return nil, err return nil, err
@ -68,6 +63,16 @@ func (r *StandardRuleset) CreateNextBoardState(prevState *BoardState, moves []Sn
} }
func (r *StandardRuleset) moveSnakes(b *BoardState, moves []SnakeMove) error { 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. // Sanity check that all non-eliminated snakes have moves and bodies.
for i := 0; i < len(b.Snakes); i++ { for i := 0; i < len(b.Snakes); i++ {
snake := &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 { if len(snake.Body) == 0 {
return ErrorZeroLengthSnake return false, ErrorZeroLengthSnake
} }
moveFound := false moveFound := false
for _, move := range moves { for _, move := range moves {
@ -86,7 +91,7 @@ func (r *StandardRuleset) moveSnakes(b *BoardState, moves []SnakeMove) error {
} }
} }
if !moveFound { 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: case MoveUp, MoveDown, MoveRight, MoveLeft:
break break
default: default:
appliedMove = r.getDefaultMove(snake.Body) appliedMove = getDefaultMove(snake.Body)
} }
newHead := Point{} 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 { if len(snakeBody) >= 2 {
// Use neck to determine last move made // Use neck to determine last move made
head, neck := snakeBody[0], snakeBody[1] head, neck := snakeBody[0], snakeBody[1]
@ -160,15 +165,25 @@ func (r *StandardRuleset) getDefaultMove(snakeBody []Point) string {
} }
func (r *StandardRuleset) reduceSnakeHealth(b *BoardState) error { 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++ { for i := 0; i < len(b.Snakes); i++ {
if b.Snakes[i].EliminatedCause == NotEliminated { if b.Snakes[i].EliminatedCause == NotEliminated {
b.Snakes[i].Health = b.Snakes[i].Health - 1 b.Snakes[i].Health = b.Snakes[i].Health - 1
} }
} }
return nil return false, nil
} }
func (r *StandardRuleset) maybeDamageHazards(b *BoardState) error { 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++ { for i := 0; i < len(b.Snakes); i++ {
snake := &b.Snakes[i] snake := &b.Snakes[i]
if snake.EliminatedCause != NotEliminated { if snake.EliminatedCause != NotEliminated {
@ -189,21 +204,26 @@ func (r *StandardRuleset) maybeDamageHazards(b *BoardState) error {
} }
// Snake is in a hazard, reduce health // Snake is in a hazard, reduce health
snake.Health = snake.Health - r.HazardDamagePerTurn snake.Health = snake.Health - settings.HazardDamagePerTurn
if snake.Health < 0 { if snake.Health < 0 {
snake.Health = 0 snake.Health = 0
} }
if r.snakeIsOutOfHealth(snake) { if snakeIsOutOfHealth(snake) {
snake.EliminatedCause = EliminatedByOutOfHealth snake.EliminatedCause = EliminatedByOutOfHealth
} }
} }
} }
} }
return nil return false, nil
} }
func (r *StandardRuleset) maybeEliminateSnakes(b *BoardState) error { 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. // First order snake indices by length.
// In multi-collision scenarios we want to always attribute elimination to the longest snake. // In multi-collision scenarios we want to always attribute elimination to the longest snake.
snakeIndicesByLength := make([]int, len(b.Snakes)) snakeIndicesByLength := make([]int, len(b.Snakes))
@ -224,15 +244,15 @@ func (r *StandardRuleset) maybeEliminateSnakes(b *BoardState) error {
continue continue
} }
if len(snake.Body) <= 0 { if len(snake.Body) <= 0 {
return ErrorZeroLengthSnake return false, ErrorZeroLengthSnake
} }
if r.snakeIsOutOfHealth(snake) { if snakeIsOutOfHealth(snake) {
snake.EliminatedCause = EliminatedByOutOfHealth snake.EliminatedCause = EliminatedByOutOfHealth
continue continue
} }
if r.snakeIsOutOfBounds(snake, b.Width, b.Height) { if snakeIsOutOfBounds(snake, b.Width, b.Height) {
snake.EliminatedCause = EliminatedByOutOfBounds snake.EliminatedCause = EliminatedByOutOfBounds
continue continue
} }
@ -252,11 +272,11 @@ func (r *StandardRuleset) maybeEliminateSnakes(b *BoardState) error {
continue continue
} }
if len(snake.Body) <= 0 { if len(snake.Body) <= 0 {
return ErrorZeroLengthSnake return false, ErrorZeroLengthSnake
} }
// Check for self-collisions first // Check for self-collisions first
if r.snakeHasBodyCollided(snake, snake) { if snakeHasBodyCollided(snake, snake) {
collisionEliminations = append(collisionEliminations, CollisionElimination{ collisionEliminations = append(collisionEliminations, CollisionElimination{
ID: snake.ID, ID: snake.ID,
Cause: EliminatedBySelfCollision, Cause: EliminatedBySelfCollision,
@ -272,7 +292,7 @@ func (r *StandardRuleset) maybeEliminateSnakes(b *BoardState) error {
if other.EliminatedCause != NotEliminated { if other.EliminatedCause != NotEliminated {
continue continue
} }
if snake.ID != other.ID && r.snakeHasBodyCollided(snake, other) { if snake.ID != other.ID && snakeHasBodyCollided(snake, other) {
collisionEliminations = append(collisionEliminations, CollisionElimination{ collisionEliminations = append(collisionEliminations, CollisionElimination{
ID: snake.ID, ID: snake.ID,
Cause: EliminatedByCollision, Cause: EliminatedByCollision,
@ -293,7 +313,7 @@ func (r *StandardRuleset) maybeEliminateSnakes(b *BoardState) error {
if other.EliminatedCause != NotEliminated { if other.EliminatedCause != NotEliminated {
continue continue
} }
if snake.ID != other.ID && r.snakeHasLostHeadToHead(snake, other) { if snake.ID != other.ID && snakeHasLostHeadToHead(snake, other) {
collisionEliminations = append(collisionEliminations, CollisionElimination{ collisionEliminations = append(collisionEliminations, CollisionElimination{
ID: snake.ID, ID: snake.ID,
Cause: EliminatedByHeadToHeadCollision, 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 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 { for _, point := range s.Body {
if (point.X < 0) || (point.X >= boardWidth) { if (point.X < 0) || (point.X >= boardWidth) {
return true return true
@ -339,7 +359,7 @@ func (r *StandardRuleset) snakeIsOutOfBounds(s *Snake, boardWidth int32, boardHe
return false return false
} }
func (r *StandardRuleset) snakeHasBodyCollided(s *Snake, other *Snake) bool { func snakeHasBodyCollided(s *Snake, other *Snake) bool {
head := s.Body[0] head := s.Body[0]
for i, body := range other.Body { for i, body := range other.Body {
if i == 0 { if i == 0 {
@ -351,7 +371,7 @@ func (r *StandardRuleset) snakeHasBodyCollided(s *Snake, other *Snake) bool {
return false 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 { if s.Body[0].X == other.Body[0].X && s.Body[0].Y == other.Body[0].Y {
return len(s.Body) <= len(other.Body) 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 { 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{} newFood := []Point{}
for _, food := range b.Food { for _, food := range b.Food {
foodHasBeenEaten := false 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 { if snake.Body[0].X == food.X && snake.Body[0].Y == food.Y {
r.feedSnake(snake) feedSnake(snake)
foodHasBeenEaten = true foodHasBeenEaten = true
} }
} }
@ -382,31 +407,41 @@ func (r *StandardRuleset) maybeFeedSnakes(b *BoardState) error {
} }
b.Food = newFood b.Food = newFood
return nil return false, nil
} }
func (r *StandardRuleset) feedSnake(snake *Snake) { func feedSnake(snake *Snake) {
r.growSnake(snake) growSnake(snake)
snake.Health = SnakeMaxHealth snake.Health = SnakeMaxHealth
} }
func (r *StandardRuleset) growSnake(snake *Snake) { func growSnake(snake *Snake) {
if len(snake.Body) > 0 { if len(snake.Body) > 0 {
snake.Body = append(snake.Body, snake.Body[len(snake.Body)-1]) snake.Body = append(snake.Body, snake.Body[len(snake.Body)-1])
} }
} }
func (r *StandardRuleset) maybeSpawnFood(b *BoardState) error { 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)) numCurrentFood := int32(len(b.Food))
if numCurrentFood < r.MinimumFood { if numCurrentFood < settings.MinimumFood {
return PlaceFoodRandomly(b, r.MinimumFood-numCurrentFood) return false, PlaceFoodRandomly(b, settings.MinimumFood-numCurrentFood)
} else if r.FoodSpawnChance > 0 && int32(rand.Intn(100)) < r.FoodSpawnChance {
return PlaceFoodRandomly(b, 1)
} }
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) { 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 numSnakesRemaining := 0
for i := 0; i < len(b.Snakes); i++ { for i := 0; i < len(b.Snakes); i++ {
if b.Snakes[i].EliminatedCause == NotEliminated { if b.Snakes[i].EliminatedCause == NotEliminated {
@ -415,3 +450,18 @@ func (r *StandardRuleset) IsGameOver(b *BoardState) (bool, error) {
} }
return numSnakesRemaining <= 1, nil 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}}, Food: []Point{{0, 0}, {1, 0}},
Hazards: []Point{}, Hazards: []Point{},
}, },
[]SnakeMove{}, []SnakeMove{
{ID: "one", Move: MoveUp},
},
ErrorNoMoveFound, ErrorNoMoveFound,
nil, nil,
} }
@ -767,9 +769,8 @@ func TestGetDefaultMove(t *testing.T) {
}, },
} }
r := StandardRuleset{}
for _, test := range tests { for _, test := range tests {
actualMove := r.getDefaultMove(test.SnakeBody) actualMove := getDefaultMove(test.SnakeBody)
require.Equal(t, test.ExpectedMove, actualMove) require.Equal(t, test.ExpectedMove, actualMove)
} }
} }
@ -835,10 +836,9 @@ func TestSnakeIsOutOfHealth(t *testing.T) {
{Health: math.MaxInt32, Expected: false}, {Health: math.MaxInt32, Expected: false},
} }
r := StandardRuleset{}
for _, test := range tests { for _, test := range tests {
s := &Snake{Health: test.Health} 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}, {Point{X: math.MaxInt32, Y: math.MaxInt32}, true},
} }
r := StandardRuleset{}
for _, test := range tests { for _, test := range tests {
// Test with point as head // Test with point as head
s := Snake{Body: []Point{test.Point}} 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 // Test with point as body
s = Snake{Body: []Point{{0, 0}, {0, 0}, test.Point}} 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}, {[]Point{{3, 3}, {3, 4}, {3, 3}, {4, 4}, {4, 5}}, true},
} }
r := StandardRuleset{}
for _, test := range tests { for _, test := range tests {
s := Snake{Body: test.Body} 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 { for _, test := range tests {
s := &Snake{Body: test.SnakeBody} s := &Snake{Body: test.SnakeBody}
o := &Snake{Body: test.OtherBody} 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 { for _, test := range tests {
s := Snake{Body: test.SnakeBody} s := Snake{Body: test.SnakeBody}
o := Snake{Body: test.OtherBody} 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.Expected, 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.ExpectedOpposite, snakeHasLostHeadToHead(&o, &s), "Snake%q Other%q", s.Body, o.Body)
} }
} }

View file

@ -4,17 +4,7 @@ type WrappedRuleset struct {
StandardRuleset StandardRuleset
} }
func (r *WrappedRuleset) Name() string { return "wrapped" } func (r *WrappedRuleset) Name() string { return GameTypeWrapped }
func replace(value, min, max int32) int32 {
if value < min {
return max
}
if value > max {
return min
}
return value
}
func (r *WrappedRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) { func (r *WrappedRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) {
nextState := prevState.Clone() nextState := prevState.Clone()
@ -53,9 +43,14 @@ func (r *WrappedRuleset) CreateNextBoardState(prevState *BoardState, moves []Sna
} }
func (r *WrappedRuleset) moveSnakes(b *BoardState, moves []SnakeMove) error { 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 { if err != nil {
return err return false, err
} }
for i := 0; i < len(b.Snakes); i++ { 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 { if snake.EliminatedCause != NotEliminated {
continue continue
} }
snake.Body[0].X = replace(snake.Body[0].X, 0, b.Width-1) snake.Body[0].X = wrap(snake.Body[0].X, 0, b.Width-1)
snake.Body[0].Y = replace(snake.Body[0].Y, 0, b.Height-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 ( import (
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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. // Checks that snakes moving out of bounds get wrapped to the other side.
var wrappedCaseMoveAndWrap = gameTestCase{ var wrappedCaseMoveAndWrap = gameTestCase{
"Wrapped Case Move and Wrap", "Wrapped Case Move and Wrap",